diff --git a/docs/api/index.html b/docs/api/index.html index d146efc..426f6a7 100644 --- a/docs/api/index.html +++ b/docs/api/index.html @@ -937,6 +937,36 @@ } }, "description" : "A request by a given authenticated user to access the resources matching the request properties" +}; + defs["AccessSummary"] = { + "type" : "object", + "properties" : { + "workspaces" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/WorkspaceAccessSummary" + } + } + } +}; + defs["AccessSummaryRequest"] = { + "required" : [ "roles", "user" ], + "type" : "object", + "properties" : { + "user" : { + "type" : "string", + "description" : "the authentication user name" + }, + "roles" : { + "uniqueItems" : true, + "type" : "array", + "description" : "The roles the requesting user belongs to", + "nullable" : false, + "items" : { + "type" : "string" + } + } + } }; defs["AddressRangeFilter"] = { "type" : "object", @@ -1324,6 +1354,35 @@ } } }; + defs["WorkspaceAccessSummary"] = { + "type" : "object", + "properties" : { + "workspace" : { + "type" : "string" + }, + "adminAccess" : { + "$ref" : "#/components/schemas/AdminGrantType" + }, + "allowed" : { + "uniqueItems" : true, + "type" : "array", + "description" : "The set of visible layer names in `workspace` the user from the\n`AccessSummaryRequest` can somehow see, even if only under specific circumstances like for a\ngiven OWS/request combination, resulting from `GrantType.ALLOW` rules.", + "nullable" : true, + "items" : { + "type" : "string" + } + }, + "forbidden" : { + "uniqueItems" : true, + "type" : "array", + "description" : "The set of forbidden layer names in `workspace` the user from the\n`AccessSummaryRequest` definitely cannot see, resulting from `GrantType.DENY rules.\nComplements the `allowed` list as there may be rules allowing access all layers in\na workspace after a rules denying access to specific layers in the same workspace.", + "nullable" : true, + "items" : { + "type" : "string" + } + } + } +}; var errs = {}; @@ -1352,6 +1411,9 @@
  • getMatchingRules
  • +
  • + getUserAccessSummary +
  • countAllRules @@ -2721,6 +2783,412 @@


    +
    +
    +
    +

    getUserAccessSummary

    +

    +
    +
    +
    +

    +

    +

    +
    +
    /authorization/accesssummary
    +

    +

    Usage and SDK Samples

    +

    + + +
    +
    +
    curl -X POST \
    + -H "Authorization: Basic [[basicHash]]" \
    + -H "Accept: application/json,application/x-jackson-smile" \
    + -H "Content-Type: application/json,application/x-jackson-smile" \
    + "/api/authorization/accesssummary" \
    + -d '{
    +  "roles" : [ "roles", "roles" ],
    +  "user" : "user"
    +}' \
    + -d 'Custom MIME type example not yet supported: application/x-jackson-smile'
    +
    +
    +
    +
    import org.openapitools.client.*;
    +import org.openapitools.client.auth.*;
    +import org.openapitools.client.model.*;
    +import org.openapitools.client.api.AuthorizationApi;
    +
    +import java.io.File;
    +import java.util.*;
    +
    +public class AuthorizationApiExample {
    +    public static void main(String[] args) {
    +        ApiClient defaultClient = Configuration.getDefaultApiClient();
    +        // Configure HTTP basic authorization: basicAuth
    +        HttpBasicAuth basicAuth = (HttpBasicAuth) defaultClient.getAuthentication("basicAuth");
    +        basicAuth.setUsername("YOUR USERNAME");
    +        basicAuth.setPassword("YOUR PASSWORD");
    +
    +        // Create an instance of the API class
    +        AuthorizationApi apiInstance = new AuthorizationApi();
    +        AccessSummaryRequest accessSummaryRequest = ; // AccessSummaryRequest | 
    +
    +        try {
    +            AccessSummary result = apiInstance.getUserAccessSummary(accessSummaryRequest);
    +            System.out.println(result);
    +        } catch (ApiException e) {
    +            System.err.println("Exception when calling AuthorizationApi#getUserAccessSummary");
    +            e.printStackTrace();
    +        }
    +    }
    +}
    +
    +
    + +
    +
    import 'package:openapi/api.dart';
    +
    +final api_instance = DefaultApi();
    +
    +final AccessSummaryRequest accessSummaryRequest = new AccessSummaryRequest(); // AccessSummaryRequest | 
    +
    +try {
    +    final result = await api_instance.getUserAccessSummary(accessSummaryRequest);
    +    print(result);
    +} catch (e) {
    +    print('Exception when calling DefaultApi->getUserAccessSummary: $e\n');
    +}
    +
    +
    +
    + +
    +
    import org.openapitools.client.api.AuthorizationApi;
    +
    +public class AuthorizationApiExample {
    +    public static void main(String[] args) {
    +        AuthorizationApi apiInstance = new AuthorizationApi();
    +        AccessSummaryRequest accessSummaryRequest = ; // AccessSummaryRequest | 
    +
    +        try {
    +            AccessSummary result = apiInstance.getUserAccessSummary(accessSummaryRequest);
    +            System.out.println(result);
    +        } catch (ApiException e) {
    +            System.err.println("Exception when calling AuthorizationApi#getUserAccessSummary");
    +            e.printStackTrace();
    +        }
    +    }
    +}
    +
    + +
    +
    Configuration *apiConfig = [Configuration sharedConfig];
    +// Configure HTTP basic authorization (authentication scheme: basicAuth)
    +[apiConfig setUsername:@"YOUR_USERNAME"];
    +[apiConfig setPassword:@"YOUR_PASSWORD"];
    +
    +// Create an instance of the API class
    +AuthorizationApi *apiInstance = [[AuthorizationApi alloc] init];
    +AccessSummaryRequest *accessSummaryRequest = ; // 
    +
    +[apiInstance getUserAccessSummaryWith:accessSummaryRequest
    +              completionHandler: ^(AccessSummary output, NSError* error) {
    +    if (output) {
    +        NSLog(@"%@", output);
    +    }
    +    if (error) {
    +        NSLog(@"Error: %@", error);
    +    }
    +}];
    +
    +
    + +
    +
    var GeoServerAcl = require('geo_server_acl');
    +var defaultClient = GeoServerAcl.ApiClient.instance;
    +
    +// Configure HTTP basic authorization: basicAuth
    +var basicAuth = defaultClient.authentications['basicAuth'];
    +basicAuth.username = 'YOUR USERNAME';
    +basicAuth.password = 'YOUR PASSWORD';
    +
    +// Create an instance of the API class
    +var api = new GeoServerAcl.AuthorizationApi()
    +var accessSummaryRequest = ; // {AccessSummaryRequest} 
    +
    +var callback = function(error, data, response) {
    +  if (error) {
    +    console.error(error);
    +  } else {
    +    console.log('API called successfully. Returned data: ' + data);
    +  }
    +};
    +api.getUserAccessSummary(accessSummaryRequest, callback);
    +
    +
    + + +
    +
    using System;
    +using System.Diagnostics;
    +using Org.OpenAPITools.Api;
    +using Org.OpenAPITools.Client;
    +using Org.OpenAPITools.Model;
    +
    +namespace Example
    +{
    +    public class getUserAccessSummaryExample
    +    {
    +        public void main()
    +        {
    +            // Configure HTTP basic authorization: basicAuth
    +            Configuration.Default.Username = "YOUR_USERNAME";
    +            Configuration.Default.Password = "YOUR_PASSWORD";
    +
    +            // Create an instance of the API class
    +            var apiInstance = new AuthorizationApi();
    +            var accessSummaryRequest = new AccessSummaryRequest(); // AccessSummaryRequest | 
    +
    +            try {
    +                AccessSummary result = apiInstance.getUserAccessSummary(accessSummaryRequest);
    +                Debug.WriteLine(result);
    +            } catch (Exception e) {
    +                Debug.Print("Exception when calling AuthorizationApi.getUserAccessSummary: " + e.Message );
    +            }
    +        }
    +    }
    +}
    +
    +
    + +
    +
    <?php
    +require_once(__DIR__ . '/vendor/autoload.php');
    +// Configure HTTP basic authorization: basicAuth
    +OpenAPITools\Client\Configuration::getDefaultConfiguration()->setUsername('YOUR_USERNAME');
    +OpenAPITools\Client\Configuration::getDefaultConfiguration()->setPassword('YOUR_PASSWORD');
    +
    +// Create an instance of the API class
    +$api_instance = new OpenAPITools\Client\Api\AuthorizationApi();
    +$accessSummaryRequest = ; // AccessSummaryRequest | 
    +
    +try {
    +    $result = $api_instance->getUserAccessSummary($accessSummaryRequest);
    +    print_r($result);
    +} catch (Exception $e) {
    +    echo 'Exception when calling AuthorizationApi->getUserAccessSummary: ', $e->getMessage(), PHP_EOL;
    +}
    +?>
    +
    + +
    +
    use Data::Dumper;
    +use WWW::OPenAPIClient::Configuration;
    +use WWW::OPenAPIClient::AuthorizationApi;
    +# Configure HTTP basic authorization: basicAuth
    +$WWW::OPenAPIClient::Configuration::username = 'YOUR_USERNAME';
    +$WWW::OPenAPIClient::Configuration::password = 'YOUR_PASSWORD';
    +
    +# Create an instance of the API class
    +my $api_instance = WWW::OPenAPIClient::AuthorizationApi->new();
    +my $accessSummaryRequest = WWW::OPenAPIClient::Object::AccessSummaryRequest->new(); # AccessSummaryRequest | 
    +
    +eval {
    +    my $result = $api_instance->getUserAccessSummary(accessSummaryRequest => $accessSummaryRequest);
    +    print Dumper($result);
    +};
    +if ($@) {
    +    warn "Exception when calling AuthorizationApi->getUserAccessSummary: $@\n";
    +}
    +
    + +
    +
    from __future__ import print_statement
    +import time
    +import openapi_client
    +from openapi_client.rest import ApiException
    +from pprint import pprint
    +# Configure HTTP basic authorization: basicAuth
    +openapi_client.configuration.username = 'YOUR_USERNAME'
    +openapi_client.configuration.password = 'YOUR_PASSWORD'
    +
    +# Create an instance of the API class
    +api_instance = openapi_client.AuthorizationApi()
    +accessSummaryRequest =  # AccessSummaryRequest | 
    +
    +try:
    +    api_response = api_instance.get_user_access_summary(accessSummaryRequest)
    +    pprint(api_response)
    +except ApiException as e:
    +    print("Exception when calling AuthorizationApi->getUserAccessSummary: %s\n" % e)
    +
    + +
    +
    extern crate AuthorizationApi;
    +
    +pub fn main() {
    +    let accessSummaryRequest = ; // AccessSummaryRequest
    +
    +    let mut context = AuthorizationApi::Context::default();
    +    let result = client.getUserAccessSummary(accessSummaryRequest, &context).wait();
    +
    +    println!("{:?}", result);
    +}
    +
    +
    +
    + +

    Scopes

    + + +
    + +

    Parameters

    + + + +
    Body parameters
    + + + + + + + + + +
    NameDescription
    accessSummaryRequest * +

    + +
    +
    + + + +

    Responses

    +

    +

    + + + + + + +
    +
    +
    + +
    + +
    +
    +
    +
    +

    DataRules

    diff --git a/src/application/authorization-api/pom.xml b/src/application/authorization-api/pom.xml index c0c2d4f..3c4f4ae 100644 --- a/src/application/authorization-api/pom.xml +++ b/src/application/authorization-api/pom.xml @@ -26,5 +26,10 @@ org.projectlombok lombok + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/src/application/authorization-api/src/main/java/org/geoserver/acl/authorization/AccessSummary.java b/src/application/authorization-api/src/main/java/org/geoserver/acl/authorization/AccessSummary.java new file mode 100644 index 0000000..52ca9fc --- /dev/null +++ b/src/application/authorization-api/src/main/java/org/geoserver/acl/authorization/AccessSummary.java @@ -0,0 +1,105 @@ +/* (c) 2024 Open Source Geospatial Foundation - all rights reserved + * This code is licensed under the GPL 2.0 license, available at the root + * application directory. + */ +package org.geoserver.acl.authorization; + +import lombok.EqualsAndHashCode; +import lombok.NonNull; + +import org.geoserver.acl.domain.adminrules.AdminGrantType; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.stream.Collectors; + +/** + * Represents the converged set of visible layer names of a specific workspace for for a {@link + * AccessSummaryRequest} + * + * @since 2.3 + * @see WorkspaceAccessSummary + */ +@EqualsAndHashCode +public class AccessSummary { + + private static final WorkspaceAccessSummary HIDDEN = + WorkspaceAccessSummary.builder() + .workspace("*") + .adminAccess(null) + .addForbidden("*") + .build(); + + /** Immutable mapping of workspace name to summary */ + private Map workspaceSummaries; + + private AccessSummary(Map workspaceSummaries) { + this.workspaceSummaries = workspaceSummaries; + } + + public static AccessSummary of(WorkspaceAccessSummary... workspaces) { + return AccessSummary.of(Arrays.asList(workspaces)); + } + + public static AccessSummary of(List workspaces) { + Map summaries = new LinkedHashMap<>(); + workspaces.forEach(ws -> summaries.put(ws.getWorkspace(), ws)); + return new AccessSummary(summaries); + } + + public List getWorkspaces() { + return List.copyOf(workspaceSummaries.values()); + } + + public WorkspaceAccessSummary workspace(String workspace) { + return workspaceSummaries.get(workspace); + } + + public boolean hasAdminReadAccess(@NonNull String workspaceName) { + boolean user = workspaceSummaries.getOrDefault("*", HIDDEN).isUser(); + return user ? user : workspaceSummaries.getOrDefault(workspaceName, HIDDEN).isUser(); + } + + public boolean hasAdminWriteAccess(@NonNull String workspaceName) { + boolean admin = workspaceSummaries.getOrDefault("*", HIDDEN).isAdmin(); + return admin ? admin : workspaceSummaries.getOrDefault(workspaceName, HIDDEN).isAdmin(); + } + + public boolean canSeeLayer(String workspaceName, @NonNull String layerName) { + if (null == workspaceName) workspaceName = WorkspaceAccessSummary.NO_WORKSPACE; + WorkspaceAccessSummary summary = summary(workspaceName); + return summary.canSeeLayer(layerName); + } + + private WorkspaceAccessSummary summary(@NonNull String workspaceName) { + var summary = workspaceSummaries.get(workspaceName); + if (null == summary) summary = workspaceSummaries.getOrDefault("*", HIDDEN); + return summary; + } + + public Set visibleWorkspaces() { + return workspaceSummaries.keySet(); + } + + public Set adminableWorkspaces() { + return workspaceSummaries.keySet().stream() + .filter(this::hasAdminWriteAccess) + .collect(Collectors.toSet()); + } + + @Override + public String toString() { + var values = new TreeMap<>(workspaceSummaries).values(); + return "%s[%s]".formatted(getClass().getSimpleName(), values); + } + + public boolean hasAdminRightsToAnyWorkspace() { + return workspaceSummaries.values().stream() + .map(WorkspaceAccessSummary::getAdminAccess) + .anyMatch(AdminGrantType.ADMIN::equals); + } +} diff --git a/src/application/authorization-api/src/main/java/org/geoserver/acl/authorization/AccessSummaryRequest.java b/src/application/authorization-api/src/main/java/org/geoserver/acl/authorization/AccessSummaryRequest.java new file mode 100644 index 0000000..fc64d61 --- /dev/null +++ b/src/application/authorization-api/src/main/java/org/geoserver/acl/authorization/AccessSummaryRequest.java @@ -0,0 +1,64 @@ +/* (c) 2024 Open Source Geospatial Foundation - all rights reserved + * This code is licensed under the GPL 2.0 license, available at the root + * application directory. + */ +package org.geoserver.acl.authorization; + +import lombok.Builder; +import lombok.NonNull; +import lombok.Value; + +import java.util.Set; + +/** + * Request object for {@link AuthorizationService#getUserAccessSummary} + * + * @since 2.3 + * @see WorkspaceAccessSummary + * @see AuthorizationService#getUserAccessSummary(AccessSummaryRequest) + */ +@Value +@Builder(builderClassName = "Builder") +public class AccessSummaryRequest { + + /** + * The authentication user name on behalf of which the request is performed, {@code null} only + * if the request is anonymous, and hence {@link #roles} would contain some role name with + * anonymous meaning (usually {@literal ROLE_ANONYMOUS}). + */ + private final String user; + + /** The authentication role names on behalf of which the request is performed. */ + @NonNull private final Set roles; + + public static class Builder { + // Ignore squid:S1068, private field required for the lombok-generated build() method + @SuppressWarnings({"unused", "squid:S1068"}) + private Set roles = Set.of(); + + public Builder roles(String... roleNames) { + if (null == roleNames) return roles(Set.of()); + return roles(Set.of(roleNames)); + } + + public Builder roles(Set roleNames) { + if (null == roleNames) { + this.roles = Set.of(); + return this; + } + this.roles = Set.copyOf(roleNames); + return this; + } + + public AccessSummaryRequest build() { + if (this.user == null && this.roles.isEmpty()) { + throw new IllegalStateException( + "AccessSummaryRequest requires user and roles not to be null or empty at the same time. Got user: " + + user + + ", roles: " + + roles); + } + return new AccessSummaryRequest(this.user, this.roles); + } + } +} diff --git a/src/application/authorization-api/src/main/java/org/geoserver/acl/authorization/AuthorizationService.java b/src/application/authorization-api/src/main/java/org/geoserver/acl/authorization/AuthorizationService.java index 5096425..90ddc51 100644 --- a/src/application/authorization-api/src/main/java/org/geoserver/acl/authorization/AuthorizationService.java +++ b/src/application/authorization-api/src/main/java/org/geoserver/acl/authorization/AuthorizationService.java @@ -7,16 +7,21 @@ package org.geoserver.acl.authorization; +import org.geoserver.acl.domain.adminrules.AdminRule; +import org.geoserver.acl.domain.adminrules.AdminRuleAdminService; import org.geoserver.acl.domain.rules.Rule; +import org.geoserver.acl.domain.rules.RuleAdminService; import java.util.List; /** - * Operations on + * The {@code AuthorizationService} implements the business logic to grant or deny access to layers + * by processing the {@link Rule}s in the {@link RuleAdminService}, and to determine the admin + * access level to workspaces based on the {@link AdminRule}s in the {@link AdminRuleAdminService}. * * @author Emanuele Tajariol (etj at geo-solutions.it) (originally as part of GeoFence's * AdminRuleService) - * @author Gabriel Roldan adapt from RuleFilter to immutable parameters and return types + * @author Gabriel Roldan adapt from {@code RuleFilter} to immutable parameters and return types */ public interface AuthorizationService { @@ -37,7 +42,13 @@ public interface AuthorizationService { AdminAccessInfo getAdminAuthorization(AdminAccessRequest request); /** - * Return the unprocessed {@link Rule} list matching a given filter, sorted by priority. + * Returns a summary of workspace names and the layers a user denoted by the {@code request} can + * somehow see, and in the case of workspaces, whether it's an administrator of. + */ + AccessSummary getUserAccessSummary(AccessSummaryRequest request); + + /** + * Return the unprocessed {@link Rule} list matching a given request, sorted by priority. * *

    Use {@link #getAccessInfo(AccessRequest)} and {@link * #getAdminAuthorization(AdminAccessRequest)} if you need the resulting coalesced access info. diff --git a/src/application/authorization-api/src/main/java/org/geoserver/acl/authorization/WorkspaceAccessSummary.java b/src/application/authorization-api/src/main/java/org/geoserver/acl/authorization/WorkspaceAccessSummary.java new file mode 100644 index 0000000..ea382ac --- /dev/null +++ b/src/application/authorization-api/src/main/java/org/geoserver/acl/authorization/WorkspaceAccessSummary.java @@ -0,0 +1,128 @@ +/* (c) 2024 Open Source Geospatial Foundation - all rights reserved + * This code is licensed under the GPL 2.0 license, available at the root + * application directory. + */ +package org.geoserver.acl.authorization; + +import lombok.Builder; +import lombok.NonNull; +import lombok.Value; + +import org.geoserver.acl.domain.adminrules.AdminGrantType; +import org.geoserver.acl.domain.rules.GrantType; + +import java.util.Set; +import java.util.TreeSet; + +/** + * Represents the converged set of visible layer names of a specific workspace for for a {@link + * AccessSummaryRequest} + * + * @since 2.3 + * @see AccessSummaryRequest + */ +@Value +@Builder(builderClassName = "Builder") +public class WorkspaceAccessSummary implements Comparable { + public static final String NO_WORKSPACE = ""; + public static final String ANY = "*"; + + /** + * The workspace name. The special value {@link #NO_WORKSPACE} represents global entities such + * as global layer groups + */ + @NonNull private String workspace; + + /** + * Whether the user from the {@link AccessSummaryRequest} is an administrator for {@link + * #workspace} + */ + private AdminGrantType adminAccess; + + /** + * The set of visible layer names in {@link #workspace} the user from the {@link + * AccessSummaryRequest} can somehow see, even if only under specific circumstances like for a + * given OWS/request combination, resulting from {@link GrantType#ALLOW allow} rules. + */ + @NonNull private Set allowed; + + /** + * The set of forbidden layer names in {@link #workspace} the user from the {@link + * AccessSummaryRequest} definitely cannot see, resulting from {@link GrantType#DENY deny} + * rules. + * + *

    Complements the {@link #allowed} list as there may be rules allowing access all layers in + * a workspace after a rules denying access to specific layers in the same workspace. + */ + @NonNull private Set forbidden; + + @Override + public String toString() { + return "[%s: admin: %s, allowed: %s, forbidden: %s]" + .formatted(workspace, adminAccess, allowed, forbidden); + } + + public boolean canSeeLayer(String layerName) { + if (allowed.contains(ANY)) { + return !forbidden.contains(layerName); + } + return allowed.contains(layerName); + } + + public static class Builder { + + private String workspace = ANY; + private AdminGrantType adminAccess; + private Set allowed = new TreeSet<>(); + private Set forbidden = new TreeSet<>(); + + public Builder allowed(@NonNull Set layers) { + this.allowed = new TreeSet<>(layers); + forbidden.removeAll(layers); + return this; + } + + public Builder forbidden(@NonNull Set layers) { + this.forbidden = new TreeSet<>(layers); + allowed.removeAll(layers); + return this; + } + + public Builder addAllowed(@NonNull String layer) { + allowed.add(layer); + forbidden.remove(layer); + return this; + } + + public Builder addForbidden(@NonNull String layer) { + forbidden.add(layer); + allowed.remove(layer); + return this; + } + + public WorkspaceAccessSummary build() { + Set allowedLayers = conflate(allowed); + Set forbiddenLayers = conflate(forbidden); + + return new WorkspaceAccessSummary( + workspace, adminAccess, allowedLayers, forbiddenLayers); + } + + private static Set conflate(Set layers) { + return layers.contains(ANY) ? Set.of(ANY) : Set.copyOf(layers); + } + } + + @Override + public int compareTo(WorkspaceAccessSummary o) { + return workspace.compareTo(o.getWorkspace()); + } + + public boolean isAdmin() { + return adminAccess == AdminGrantType.ADMIN; + } + + public boolean isUser() { + return adminAccess == AdminGrantType.USER || adminAccess == AdminGrantType.ADMIN; + } +} diff --git a/src/application/authorization-api/src/test/java/org/geoserver/acl/authorization/AccessSummaryRequestTest.java b/src/application/authorization-api/src/test/java/org/geoserver/acl/authorization/AccessSummaryRequestTest.java new file mode 100644 index 0000000..78df902 --- /dev/null +++ b/src/application/authorization-api/src/test/java/org/geoserver/acl/authorization/AccessSummaryRequestTest.java @@ -0,0 +1,46 @@ +/* (c) 2024 Open Source Geospatial Foundation - all rights reserved + * This code is licensed under the GPL 2.0 license, available at the root + * application directory. + */ +package org.geoserver.acl.authorization; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.geoserver.acl.authorization.AccessSummaryRequest.*; +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +import java.util.Set; + +class AccessSummaryRequestTest { + + @Test + void testPreconditions() { + IllegalStateException ex = assertThrows(IllegalStateException.class, builder()::build); + assertThat(ex) + .hasMessageContaining( + "AccessSummaryRequest requires user and roles not to be null or empty at the same time"); + } + + @Test + void testBuild() { + AccessSummaryRequest req = + builder().user("user").roles("ROLE_ADMINISTRATOR", "ROLE_AUTHENTICATED").build(); + assertThat(req.getUser()).isEqualTo("user"); + assertThat(req.getRoles()) + .containsExactlyInAnyOrder("ROLE_ADMINISTRATOR", "ROLE_AUTHENTICATED"); + + req = builder().user("user").roles(Set.of("ROLE_1")).build(); + assertThat(req.getRoles()).containsExactlyInAnyOrder("ROLE_1"); + + req = builder().user("user").roles(Set.of("ROLE_1", "ROLE_2", "ROLE_3")).build(); + assertThat(req.getRoles()).containsExactlyInAnyOrder("ROLE_1", "ROLE_2", "ROLE_3"); + } + + @Test + void testNullUserAllowedIfRolesIsNotEmpty() { + AccessSummaryRequest req = builder().roles("ROLE_ANONYMOUS").build(); + assertThat(req.getUser()).isNull(); + assertThat(req.getRoles()).isEqualTo(Set.of("ROLE_ANONYMOUS")); + } +} diff --git a/src/application/authorization-api/src/test/java/org/geoserver/acl/authorization/WorkspaceAccessSummaryTest.java b/src/application/authorization-api/src/test/java/org/geoserver/acl/authorization/WorkspaceAccessSummaryTest.java new file mode 100644 index 0000000..2c5d90f --- /dev/null +++ b/src/application/authorization-api/src/test/java/org/geoserver/acl/authorization/WorkspaceAccessSummaryTest.java @@ -0,0 +1,147 @@ +/* (c) 2024 Open Source Geospatial Foundation - all rights reserved + * This code is licensed under the GPL 2.0 license, available at the root + * application directory. + */ +package org.geoserver.acl.authorization; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.geoserver.acl.authorization.WorkspaceAccessSummary.builder; + +import org.geoserver.acl.authorization.WorkspaceAccessSummary.Builder; +import org.geoserver.acl.domain.adminrules.AdminGrantType; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +class WorkspaceAccessSummaryTest { + + @Test + void buildDefaults() { + WorkspaceAccessSummary was = builder().build(); + assertThat(was.getWorkspace()).isEqualTo("*"); + assertThat(was.getAllowed()).isEmpty(); + assertThat(was.getForbidden()).isEmpty(); + assertThat(was.getAdminAccess()).isNull(); + assertThat(was.isAdmin()).isFalse(); + assertThat(was.isUser()).isFalse(); + } + + @Test + void buildOnlyWorkspace() { + WorkspaceAccessSummary was = builderWithWorkspace().build(); + assertThat(was.getWorkspace()).isEqualTo("cite"); + assertThat(was.getAllowed()).isEmpty(); + assertThat(was.getForbidden()).isEmpty(); + assertThat(was.getAdminAccess()).isNull(); + assertThat(was.isAdmin()).isFalse(); + assertThat(was.isUser()).isFalse(); + } + + @Test + void buildAdmin() { + WorkspaceAccessSummary was = + builderWithWorkspace().adminAccess(AdminGrantType.ADMIN).build(); + assertThat(was.getWorkspace()).isEqualTo("cite"); + assertThat(was.getAdminAccess()).isEqualTo(AdminGrantType.ADMIN); + assertThat(was.isAdmin()).isTrue(); + assertThat(was.isUser()).isTrue(); + } + + @Test + void buildUser() { + WorkspaceAccessSummary was = + builderWithWorkspace().adminAccess(AdminGrantType.USER).build(); + assertThat(was.getWorkspace()).isEqualTo("cite"); + assertThat(was.getAdminAccess()).isEqualTo(AdminGrantType.USER); + assertThat(was.isAdmin()).isFalse(); + assertThat(was.isUser()).isTrue(); + } + + @Test + void allowedLayers() { + WorkspaceAccessSummary was = builderWithWorkspace().allowed(Set.of("*")).build(); + assertThat(was.getAllowed()).isEqualTo(Set.of("*")); + + was = builderWithWorkspace().allowed(Set.of("layer1", "layer2")).build(); + assertThat(was.getAllowed()).isEqualTo(Set.of("layer1", "layer2")); + } + + @Test + void addAllowedLayers() { + WorkspaceAccessSummary was = builderWithWorkspace().addAllowed("*").build(); + assertThat(was.getWorkspace()).isEqualTo("cite"); + assertThat(was.getAllowed()).isEqualTo(Set.of("*")); + + was = builderWithWorkspace().addAllowed("layer1").addAllowed("layer2").build(); + assertThat(was.getAllowed()).isEqualTo(Set.of("layer1", "layer2")); + } + + @Test + void allowedLayersConflates() { + WorkspaceAccessSummary was = + builderWithWorkspace() + .addAllowed("layer1") + .addAllowed("layer2") + .addAllowed("*") + .build(); + assertThat(was.getAllowed()).isEqualTo(Set.of("*")); + + was = + builderWithWorkspace() + .addAllowed("layer1") + .addAllowed("layer2") + .addAllowed("*") + .addAllowed("layer3") + .addAllowed("layer4") + .build(); + assertThat(was.getAllowed()).isEqualTo(Set.of("*")); + } + + @Test + void forbiddenLayers() { + WorkspaceAccessSummary was = builderWithWorkspace().forbidden(Set.of("*")).build(); + assertThat(was.getForbidden()).isEqualTo(Set.of("*")); + assertThat(was.getAllowed()).isEmpty(); + + was = builderWithWorkspace().forbidden(Set.of("l1", "l2")).build(); + assertThat(was.getForbidden()).isEqualTo(Set.of("l1", "l2")); + assertThat(was.getAllowed()).isEmpty(); + } + + @Test + void addForbiddenLayers() { + WorkspaceAccessSummary was = builderWithWorkspace().addForbidden("*").build(); + assertThat(was.getForbidden()).isEqualTo(Set.of("*")); + + was = builderWithWorkspace().addForbidden("l1").addForbidden("l2").build(); + assertThat(was.getForbidden()).isEqualTo(Set.of("l1", "l2")); + assertThat(was.getAllowed()).isEmpty(); + } + + @Test + void canSeeLayer() { + var was = builderWithWorkspace().addForbidden("*").addAllowed("L1").build(); + assertThat(was.canSeeLayer("L1")).isTrue(); + assertThat(was.canSeeLayer("L2")).isFalse(); + + was = builderWithWorkspace().addAllowed("*").addForbidden("L1").build(); + assertThat(was.canSeeLayer("L1")).isFalse(); + assertThat(was.canSeeLayer("L2")).isTrue(); + + was = builderWithWorkspace().addAllowed("L1").addForbidden("L2").build(); + assertThat(was.canSeeLayer("L1")).isTrue(); + assertThat(was.canSeeLayer("L2")).isFalse(); + + was = builderWithWorkspace().addForbidden("L2").addAllowed("L1").build(); + assertThat(was.canSeeLayer("L1")).isTrue(); + assertThat(was.canSeeLayer("L2")).isFalse(); + + was = builderWithWorkspace().addAllowed("L1").addForbidden("L1").addForbidden("L2").build(); + assertThat(was.canSeeLayer("L2")).isFalse(); + assertThat(was.canSeeLayer("L1")).isFalse(); + } + + private Builder builderWithWorkspace() { + return builder().workspace("cite"); + } +} diff --git a/src/application/authorization-impl/src/main/java/org/geoserver/acl/authorization/AuthorizationServiceImpl.java b/src/application/authorization-impl/src/main/java/org/geoserver/acl/authorization/AuthorizationServiceImpl.java index 1b9eb65..fffa4fd 100644 --- a/src/application/authorization-impl/src/main/java/org/geoserver/acl/authorization/AuthorizationServiceImpl.java +++ b/src/application/authorization-impl/src/main/java/org/geoserver/acl/authorization/AuthorizationServiceImpl.java @@ -20,10 +20,14 @@ import static org.geoserver.acl.domain.rules.SpatialFilterType.CLIP; import static org.geoserver.acl.domain.rules.SpatialFilterType.INTERSECT; +import static java.util.Comparator.comparing; +import static java.util.Comparator.naturalOrder; + import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.geoserver.acl.domain.adminrules.AdminGrantType; import org.geoserver.acl.domain.adminrules.AdminRule; import org.geoserver.acl.domain.adminrules.AdminRuleAdminService; import org.geoserver.acl.domain.adminrules.AdminRuleFilter; @@ -48,6 +52,7 @@ import org.locationtech.jts.geom.Geometry; import java.util.ArrayList; +import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -56,6 +61,11 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.function.BinaryOperator; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; /** * Note: service and request params are usually set by the client, and by @@ -124,6 +134,157 @@ public AdminAccessInfo getAdminAuthorization(@NonNull AdminAccessRequest request .build(); } + @Override + public AccessSummary getUserAccessSummary(AccessSummaryRequest request) { + String user = request.getUser(); + Set roles = request.getRoles(); + + Map> wsAdminRules = getAdminRulesByWorkspace(user, roles); + Map> wsRules = getRulesByWorkspace(user, roles); + + Set workspaces = union(wsAdminRules.keySet(), wsRules.keySet()); + + List summaries = + workspaces.stream() + .map(ws -> conflateViewables(ws, wsAdminRules, wsRules)) + .toList(); + + return AccessSummary.of(summaries); + } + + private Set union(Set s1, Set s2) { + return Stream.concat(s1.stream(), s2.stream()).collect(Collectors.toSet()); + } + + private WorkspaceAccessSummary conflateViewables( + String workspace, + Map> adminRulesByWorkspace, + Map> rulesByWorkspace) { + + var builder = WorkspaceAccessSummary.builder(); + builder.workspace(workspace); + conflateAdminRules(builder, adminRulesByWorkspace.getOrDefault(workspace, List.of())); + conflateRules(builder, rulesByWorkspace.getOrDefault(workspace, List.of())); + return builder.build(); + } + + void conflateAdminRules(WorkspaceAccessSummary.Builder builder, List rules) { + + AdminRule rule = + rules.stream() + .sorted(Comparator.comparingLong(AdminRule::getPriority)) + .findFirst() + .orElse(null); + if (rule != null) { + AdminGrantType adminAccess = rule.getAccess(); + builder.adminAccess(adminAccess); + } + } + + void conflateRules(WorkspaceAccessSummary.Builder builder, List rules) { + + // LIMIT rules don't provide access level + Predicate notLimitRule = r -> r.getIdentifier().getAccess() != GrantType.LIMIT; + // reverse priority sort so the most important ones are added the latest and the + // builder creates the allowed/forbidden sets correctly + Comparator reversePriority = Comparator.comparing(Rule::getPriority).reversed(); + + // add deny rules first, and allow rules after, so allow rules prevail (i.e. + // their layer names get removed from the summary's "forbidden" list, since this + // is a summary of somehow visible layers, we give preference to allow rules + // regardless of the priority + assert GrantType.ALLOW.compareTo(GrantType.DENY) < 0; + Comparator comparator = + Comparator.comparing(Rule::access).reversed().thenComparing(reversePriority); + + rules.stream() + .filter(notLimitRule) + .sorted(comparator) + .forEach( + r -> { + GrantType access = r.getIdentifier().getAccess(); + String layer = r.getIdentifier().getLayer(); + if (null == layer) layer = WorkspaceAccessSummary.ANY; + switch (access) { + case ALLOW -> builder.addAllowed(layer); + case DENY -> { + // only add forbidden layers if they're so for all services, to + // comply with the "somehow can see" motto of the summary + String service = r.getIdentifier().getService(); + if (null == service) { + builder.addForbidden(layer); + } + } + default -> throw new IllegalArgumentException(); + } + }); + } + + Map> getAdminRulesByWorkspace(String user, Set roles) { + + // Filter is SpecialFilterType.ANY to return all rules + AdminRuleFilter filter = new AdminRuleFilter(SpecialFilterType.ANY); + filter.getUser().setHeuristically(user); + filter.getRole().setHeuristically(roles); + + Function workspaceMapper = + workspaceMapper(r -> r.getIdentifier().getWorkspace()); + Comparator workspaceComparator = workspaceComparator(workspaceMapper); + RuleQuery query = RuleQuery.of(filter); + BinaryOperator> mergeFunction = mergeFunction(); + try (Stream all = this.adminRuleService.getAll(query)) { + return all.sorted(workspaceComparator) + .distinct() + .collect(Collectors.toMap(workspaceMapper, List::of, mergeFunction)); + } + } + + Map> getRulesByWorkspace(String user, Set roles) { + + // Filter is SpecialFilterType.ANY to return all rules + RuleFilter filter = new RuleFilter(SpecialFilterType.ANY); + filter.getUser().setHeuristically(user); + filter.getRole().setHeuristically(roles); + List rules = getRulesByRoleIncludeDefault(filter); + + Function workspaceMapper = + workspaceMapper(r -> r.getIdentifier().getWorkspace()); + Comparator workspaceComparator = workspaceComparator(workspaceMapper); + + Function> valueMapper = List::of; + BinaryOperator> mergeFunction = mergeFunction(); + return rules.stream() + .sorted(workspaceComparator) + .distinct() + .sorted(Comparator.comparing(Rule::getPriority)) + .collect(Collectors.toMap(workspaceMapper, valueMapper, mergeFunction)); + } + + private Comparator workspaceComparator(Function workspaceMapper) { + return comparing(workspaceMapper, naturalOrder()); + } + + private Function workspaceMapper(Function workspaceExtractor) { + return workspaceExtractor.andThen(ws -> null == ws ? WorkspaceAccessSummary.ANY : ws); + } + + private BinaryOperator> mergeFunction() { + return (l1, l2) -> { + if (l1 instanceof ArrayList) { + l1.addAll(l2); + return l1; + } + if (l2 instanceof ArrayList) { + l2.addAll(l1); + return l2; + } + List ret = new ArrayList<>(); + ret.addAll(l1); + ret.addAll(l2); + return ret; + }; + } + private AccessInfo enlargeAccessInfo(AccessInfo baseAccess, AccessInfo moreAccess) { if (baseAccess == null) { if (moreAccess == null) return null; @@ -325,7 +486,8 @@ private AccessInfo buildAllowAccessInfo(Rule rule, List limits) { accessInfo.catalogMode(cmode); if (area != null) { - // if we have a clip area we apply clip type since is more restrictive, otherwise we + // if we have a clip area we apply clip type since is more restrictive, + // otherwise we // keep the intersect org.geolatte.geom.Geometry finalArea = org.geolatte.geom.jts.JTS.from(area); if (atLeastOneClip) { @@ -431,6 +593,7 @@ protected static CatalogMode getLarger(CatalogMode m1, CatalogMode m2) { * @return a Map having role names as keys, and the list of matching Rules as values. The NULL * key holds the rules for the DEFAULT group. */ + @SuppressWarnings("java:S125") protected Map> getMatchingRulesByRole(AccessRequest request) throws IllegalArgumentException { @@ -458,17 +621,25 @@ protected Map> getMatchingRulesByRole(AccessRequest request) List found = ruleService.getAll(RuleQuery.of(filter)).toList(); ret.put(null, found); } else { - for (String role : finalRoleFilter) { - List found = getRulesByRole(filter, role); - ret.put(role, found); + // used to be: for(role: finalRoleFilter) getRulesByRole(filter, role);, + // conflated to a single query with all roles here + List rules = getRulesByRoleIncludeDefault(filter); + finalRoleFilter.forEach(r -> ret.put(r, new ArrayList<>())); + for (Rule rule : rules) { + String rolename = rule.getIdentifier().getRolename(); + boolean isdefault = null == rolename; + if (isdefault) { + finalRoleFilter.forEach(role -> ret.get(role).add(rule)); + } else { + ret.get(rolename).add(rule); + } } } return ret; } - private List getRulesByRole(RuleFilter filter, String role) { + private List getRulesByRoleIncludeDefault(RuleFilter filter) { filter = filter.clone(); - filter.setRole(role); filter.getRole().setIncludeDefault(true); return ruleService.getAll(RuleQuery.of(filter)).toList(); } diff --git a/src/application/authorization-impl/src/test/java/org/geoserver/acl/authorization/AuthorizationServiceAccessSummaryTest.java b/src/application/authorization-impl/src/test/java/org/geoserver/acl/authorization/AuthorizationServiceAccessSummaryTest.java new file mode 100644 index 0000000..d7532c5 --- /dev/null +++ b/src/application/authorization-impl/src/test/java/org/geoserver/acl/authorization/AuthorizationServiceAccessSummaryTest.java @@ -0,0 +1,245 @@ +/* (c) 2024 Open Source Geospatial Foundation - all rights reserved + * This code is licensed under the GPL 2.0 license, available at the root + * application directory. + */ + +package org.geoserver.acl.authorization; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.geoserver.acl.domain.adminrules.AdminGrantType.*; +import static org.geoserver.acl.domain.rules.GrantType.ALLOW; +import static org.geoserver.acl.domain.rules.GrantType.DENY; + +import lombok.NonNull; + +import org.geoserver.acl.domain.adminrules.AdminGrantType; +import org.geoserver.acl.domain.rules.GrantType; +import org.geoserver.acl.domain.rules.Rule; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +import java.util.Arrays; +import java.util.List; +import java.util.Set; + +/** + * Integration/comformance test for {@link + * AuthorizationService#getUserAccessSummary(AccessSummaryRequest)} + * + * @since 2.3 + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public abstract class AuthorizationServiceAccessSummaryTest extends BaseAuthorizationServiceTest { + + AccessSummaryRequest req(@NonNull String user, @NonNull String... roles) { + return AccessSummaryRequest.builder().user(user).roles(Set.copyOf(List.of(roles))).build(); + } + + @Test + @Order(9) + void adminRules() { + insert(ADMIN, 1, "*", "ROLE_1", "ws1"); + var req = req("*", "ROLE_1"); + Set allowedLayers = Set.of(); + Set forbiddenLayers = Set.of(); + var expected = summary(workspace(ADMIN, "ws1", allowedLayers, forbiddenLayers)); + var actual = authorizationService.getUserAccessSummary(req); + assertThat(actual).isEqualTo(expected); + } + + @Test + @Order(10) + @DisplayName("given an empty rule database, nothing is visible") + void empty() { + var req = req("user1", "ROLE_1"); + var viewables = authorizationService.getUserAccessSummary(req); + assertThat(viewables).isNotNull(); + assertThat(viewables.getWorkspaces()).isEmpty(); + } + + @Test + @Order(20) + @DisplayName( + "given a single matching rule on role with no layer, all layers in the workspace are visible") + void singleWorkspaceRuleAllowAllLayers() { + var req = req("user1", "ROLE_1"); + insert(1, "*", "ROLE_1", "w1", "*", ALLOW); + var viewables = authorizationService.getUserAccessSummary(req); + var expected = summary(workspace("w1", "*")); + assertThat(viewables).isEqualTo(expected); + } + + @Test + @Order(30) + void rulesMatchingUsernameAndRoles() { + var req = req("user1", "ROLE_1", "ROLE_2"); + insert(1, "*", "ROLE_1", "w1", "*", ALLOW); + insert(2, "*", "ROLE_2", "w2", "allowed1", ALLOW); + insert(3, "user1", null, "w3", "L3", ALLOW); + var viewables = authorizationService.getUserAccessSummary(req); + var expected = + summary(workspace("w1", "*"), workspace("w2", "allowed1"), workspace("w3", "L3")); + assertThat(viewables).isEqualTo(expected); + } + + @Test + @Order(40) + void denyRulePreserverdIfCatchesAllRequests() { + AuthorizationService service = authorizationService; + var req = req("user1", "ROLE_1", "ROLE_2"); + insert(1, "user1", "*", "w1", "hidden1", DENY); + insert(2, "*", "ROLE_1", "w1", "*", ALLOW); + + var accessRequestBuilder = + AccessRequest.builder().user("user1").roles("ROLE_1", "ROLE_2").workspace("w1"); + + // preflight + var accessInfo = service.getAccessInfo(accessRequestBuilder.layer("visible").build()); + assertThat(accessInfo.getGrant()).isEqualTo(ALLOW); + + accessInfo = service.getAccessInfo(accessRequestBuilder.layer("hidden1").build()); + assertThat(accessInfo.getGrant()).isEqualTo(DENY); + + // summary test + var viewables = authorizationService.getUserAccessSummary(req); + Set allowed = Set.of("*"); + Set forbidden = Set.of("hidden1"); + var expected = summary(workspace("w1", allowed, forbidden)); + assertThat(viewables).isEqualTo(expected); + } + + @Test + @Order(41) + void denyRuleRemovedIfNotCatchAll() { + // rule 1 is denies access to layer hidden1 for a specific service, but rule 2 grants access + // to all, so rule 2 prevails because the summary tells layers that are somehow visible and + // only list forbidden layers that can never be visible + insert(1, "user1", "*", null, "WMS", null, null, "w1", "hidden1", DENY); + insert(2, "*", "ROLE_1", "w1", "*", ALLOW); + + var req = req("user1", "ROLE_1", "ROLE_2"); + var viewables = authorizationService.getUserAccessSummary(req); + Set allowed = Set.of("*"); + Set forbidden = Set.of(); + var expected = summary(workspace("w1", allowed, forbidden)); + assertThat(viewables).isEqualTo(expected); + } + + @Test + @Order(42) + void denyAllPreservedButExplicitAllowRuleAlsoPreserved() { + // a deny all rule does not prevent specific allowed layers to be seen + var req = req("user1", "ROLE_1", "ROLE_2"); + insert(1, "*", "ROLE_1", "w1", "L1", ALLOW); + insert(2, "*", "ROLE_2", "w1", "L2", ALLOW); + insert(3, "user1", "*", "w1", "*", DENY); + + var viewables = authorizationService.getUserAccessSummary(req); + Set allowed = Set.of("L1", "L2"); + Set forbidden = Set.of("*"); + var expected = summary(workspace("w1", allowed, forbidden)); + assertThat(viewables).isEqualTo(expected); + } + + @Test + @Order(50) + void singleRoleMatchAndDefaultAllow() { + var req = req("user1", "ROLE_1"); + // matching rule + insert(1, "user1", "ROLE_1", "w1", "*", ALLOW); + // default rule allowing all on w2 + insert(2, null, null, "w2", null, ALLOW); + var expected = summary(workspace("w1", "*"), workspace("w2", "*")); + var viewables = authorizationService.getUserAccessSummary(req); + assertThat(viewables).isEqualTo(expected); + } + + @Test + @Order(60) + void workspaceAdminMustAdhereToExplicitlyHiddenLayers() { + // matching rule + insert(1, "user1", "*", "w1", null, ALLOW); + insert(2, "*", "ROLE_1", "w1", "hiddenlayer", DENY); + insert(3, "*", "ROLE_2", "w1", "*", ALLOW); + + // preflight, layer initially hidden + var expected = summary(workspace("w1", "*", "hiddenlayer")); + + var req = req("user1", "ROLE_1", "ROLE_2"); + var viewables = authorizationService.getUserAccessSummary(req); + assertThat(viewables).isEqualTo(expected); + + insert(4, "*", "ROLE_2", "*", "*", ALLOW); + // make it an admin, still can't see w1:hiddenlayer + insert(ADMIN, 1, "*", "ROLE_1", "w1"); + + expected = + summary( + workspace("*", "*"), + workspace(ADMIN, "w1", Set.of("*"), Set.of("hiddenlayer"))); + viewables = authorizationService.getUserAccessSummary(req); + assertThat(viewables).isEqualTo(expected); + } + + protected WorkspaceAccessSummary workspace( + @NonNull String workspace, @NonNull String allowedLayer) { + return workspace(workspace, Set.of(allowedLayer), Set.of()); + } + + protected WorkspaceAccessSummary workspace( + @NonNull String workspace, + @NonNull String allowedLayer, + @NonNull String forbiddenLayer) { + return workspace(workspace, Set.of(allowedLayer), Set.of(forbiddenLayer)); + } + + protected WorkspaceAccessSummary workspace( + AdminGrantType admin, @NonNull String workspace, @NonNull String allowedLayer) { + return WorkspaceAccessSummary.builder() + .workspace(workspace) + .adminAccess(admin) + .addAllowed(allowedLayer) + .build(); + } + + protected WorkspaceAccessSummary workspace( + @NonNull String workspace, + @NonNull Set allowedLayers, + @NonNull Set forbiddenLayers) { + return workspace(null, workspace, allowedLayers, forbiddenLayers); + } + + protected WorkspaceAccessSummary workspace( + AdminGrantType admin, + @NonNull String workspace, + @NonNull Set allowedLayers, + @NonNull Set forbiddenLayers) { + return WorkspaceAccessSummary.builder() + .workspace(workspace) + .adminAccess(admin) + .allowed(allowedLayers) + .forbidden(forbiddenLayers) + .build(); + } + + protected Rule insert( + int priority, + String user, + String role, + String workspace, + String layer, + GrantType access) { + if ("*".equals(user)) user = null; + if ("*".equals(role)) role = null; + if ("*".equals(workspace)) workspace = null; + if ("*".equals(layer)) layer = null; + return super.insert(priority, user, role, null, null, null, null, workspace, layer, access); + } + + private AccessSummary summary(WorkspaceAccessSummary... workspaceSummaries) { + return AccessSummary.of(Arrays.asList(workspaceSummaries)); + } +} diff --git a/src/application/authorization-impl/src/test/java/org/geoserver/acl/authorization/AuthorizationServiceGeomTest.java b/src/application/authorization-impl/src/test/java/org/geoserver/acl/authorization/AuthorizationServiceGeomTest.java index d742dd1..8c9fc88 100644 --- a/src/application/authorization-impl/src/test/java/org/geoserver/acl/authorization/AuthorizationServiceGeomTest.java +++ b/src/application/authorization-impl/src/test/java/org/geoserver/acl/authorization/AuthorizationServiceGeomTest.java @@ -31,9 +31,10 @@ /** * {@link AuthorizationService} integration/conformance test working with geometries * - *

    Concrete implementations must supply the required services in {@link ServiceTestBase} + *

    Concrete implementations must supply the required services in {@link + * BaseAuthorizationServiceTest} */ -public abstract class AuthorizationServiceGeomTest extends AuthorizationServiceTest { +public abstract class AuthorizationServiceGeomTest extends BaseAuthorizationServiceTest { private static final String WKT_WGS84_1 = "SRID=4326;MultiPolygon (((-1.93327272727272859 5.5959090909090925, 2.22727272727272707 5.67609090909091041, 2.00454545454545441 4.07245454545454599, -1.92436363636363761 4.54463636363636425, -1.92436363636363761 4.54463636363636425, -1.93327272727272859 5.5959090909090925)))"; private static final String WKT_WGS84_2 = diff --git a/src/application/authorization-impl/src/test/java/org/geoserver/acl/authorization/AuthorizationServiceImplAccessSummaryTest.java b/src/application/authorization-impl/src/test/java/org/geoserver/acl/authorization/AuthorizationServiceImplAccessSummaryTest.java new file mode 100644 index 0000000..98f1cac --- /dev/null +++ b/src/application/authorization-impl/src/test/java/org/geoserver/acl/authorization/AuthorizationServiceImplAccessSummaryTest.java @@ -0,0 +1,40 @@ +/* (c) 2024 Open Source Geospatial Foundation - all rights reserved + * This code is licensed under the GPL 2.0 license, available at the root + * application directory. + */ + +package org.geoserver.acl.authorization; + +import org.geoserver.acl.domain.adminrules.AdminRuleAdminService; +import org.geoserver.acl.domain.adminrules.AdminRuleAdminServiceImpl; +import org.geoserver.acl.domain.adminrules.MemoryAdminRuleRepository; +import org.geoserver.acl.domain.rules.MemoryRuleRepository; +import org.geoserver.acl.domain.rules.RuleAdminService; +import org.geoserver.acl.domain.rules.RuleAdminServiceImpl; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.TestMethodOrder; + +/** + * Integration/conformance test for {@link + * AuthorizationService#getUserAccessSummary(AccessSummaryRequest)} + * + * @since 2.3 + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class AuthorizationServiceImplAccessSummaryTest extends AuthorizationServiceAccessSummaryTest { + + @Override + protected RuleAdminService getRuleAdminService() { + return new RuleAdminServiceImpl(new MemoryRuleRepository()); + } + + @Override + protected AdminRuleAdminService getAdminRuleAdminService() { + return new AdminRuleAdminServiceImpl(new MemoryAdminRuleRepository()); + } + + @Override + protected AuthorizationService getAuthorizationService() { + return new AuthorizationServiceImpl(super.adminruleAdminService, super.ruleAdminService); + } +} diff --git a/src/application/authorization-impl/src/test/java/org/geoserver/acl/authorization/AuthorizationServiceImplGeomTest.java b/src/application/authorization-impl/src/test/java/org/geoserver/acl/authorization/AuthorizationServiceImplGeomTest.java index f6c1823..2149282 100644 --- a/src/application/authorization-impl/src/test/java/org/geoserver/acl/authorization/AuthorizationServiceImplGeomTest.java +++ b/src/application/authorization-impl/src/test/java/org/geoserver/acl/authorization/AuthorizationServiceImplGeomTest.java @@ -14,7 +14,8 @@ /** * {@link AuthorizationService} integration/conformance test working with geometries * - *

    Concrete implementations must supply the required services in {@link ServiceTestBase} + *

    Concrete implementations must supply the required services in {@link + * BaseAuthorizationServiceTest} */ class AuthorizationServiceImplGeomTest extends AuthorizationServiceGeomTest { diff --git a/src/application/authorization-impl/src/test/java/org/geoserver/acl/authorization/AuthorizationServiceImplTest.java b/src/application/authorization-impl/src/test/java/org/geoserver/acl/authorization/AuthorizationServiceImplTest.java index c9f32f5..84b994f 100644 --- a/src/application/authorization-impl/src/test/java/org/geoserver/acl/authorization/AuthorizationServiceImplTest.java +++ b/src/application/authorization-impl/src/test/java/org/geoserver/acl/authorization/AuthorizationServiceImplTest.java @@ -7,21 +7,39 @@ package org.geoserver.acl.authorization; +import static org.assertj.core.api.Assertions.assertThat; +import static org.geoserver.acl.domain.adminrules.AdminGrantType.ADMIN; +import static org.geoserver.acl.domain.adminrules.AdminGrantType.USER; +import static org.geoserver.acl.domain.rules.GrantType.ALLOW; +import static org.geoserver.acl.domain.rules.GrantType.DENY; +import static org.geoserver.acl.domain.rules.GrantType.LIMIT; + +import org.geoserver.acl.authorization.WorkspaceAccessSummary.Builder; +import org.geoserver.acl.domain.adminrules.AdminRule; import org.geoserver.acl.domain.adminrules.AdminRuleAdminService; import org.geoserver.acl.domain.adminrules.AdminRuleAdminServiceImpl; import org.geoserver.acl.domain.adminrules.MemoryAdminRuleRepository; +import org.geoserver.acl.domain.rules.GrantType; import org.geoserver.acl.domain.rules.MemoryRuleRepository; +import org.geoserver.acl.domain.rules.Rule; import org.geoserver.acl.domain.rules.RuleAdminService; import org.geoserver.acl.domain.rules.RuleAdminServiceImpl; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.Set; /** * {@link AuthorizationService} integration/conformance test * - *

    Concrete implementations must supply the required services in {@link ServiceTestBase} + *

    Concrete implementations must supply the required services in {@link + * BaseAuthorizationServiceTest} * * @author Emanuele Tajariol (etj at geo-solutions.it) (originally as part of GeoFence) */ -class AuthorizationServiceImplTest extends AuthorizationServiceTest { +@SuppressWarnings("java:S5786") // class is public cause it's inherited +public class AuthorizationServiceImplTest extends AuthorizationServiceTest { @Override protected RuleAdminService getRuleAdminService() { @@ -37,4 +55,161 @@ protected AdminRuleAdminService getAdminRuleAdminService() { protected AuthorizationService getAuthorizationService() { return new AuthorizationServiceImpl(super.adminruleAdminService, super.ruleAdminService); } + + @Test + void getAdminRulesByWorkspace() { + AuthorizationServiceImpl service = (AuthorizationServiceImpl) super.authorizationService; + String user = "user1"; + Set roles = Set.of("ROLE_1", "ROLE_2"); + + Map> adminRules; + + adminRules = service.getAdminRulesByWorkspace(user, roles); + assertThat(adminRules).isEmpty(); + + AdminRule r1 = insert(ADMIN, 1, "*", "ROLE_1", "ws1"); + adminRules = service.getAdminRulesByWorkspace(user, roles); + assertThat(adminRules).isEqualTo(Map.of("ws1", list(r1))); + + AdminRule r2 = insert(ADMIN, 2, "*", "ROLE_2", "ws2"); + adminRules = service.getAdminRulesByWorkspace(user, roles); + assertThat(adminRules).isEqualTo(Map.of("ws1", list(r1), "ws2", list(r2))); + + AdminRule ws3UserRule = insert(USER, 3, "user1", "*", "ws3"); + adminRules = service.getAdminRulesByWorkspace(user, roles); + assertThat(adminRules) + .isEqualTo(Map.of("ws1", list(r1), "ws2", list(r2), "ws3", list(ws3UserRule))); + + AdminRule ws3RoleRule = insert(ADMIN, 4, "*", "ROLE_2", "ws3"); + adminRules = service.getAdminRulesByWorkspace(user, roles); + assertThat(adminRules) + .isEqualTo( + Map.of( + "ws1", + list(r1), + "ws2", + list(r2), + "ws3", + list(ws3UserRule, ws3RoleRule))); + } + + @Test + void conflateAdminRules() { + AuthorizationServiceImpl service = (AuthorizationServiceImpl) super.authorizationService; + + AdminRule r1 = insert(ADMIN, 1, "*", "ROLE_1", "ws1"); + AdminRule r2 = insert(ADMIN, 2, "*", "ROLE_2", "ws2"); + AdminRule r3 = insert(USER, 3, "user1", "*", "ws3"); + AdminRule r4 = insert(ADMIN, 4, "*", "ROLE_2", "ws3"); + + AdminAccessRequest ws3AdminReq = + AdminAccessRequest.builder() + .user("user1") + .roles("ROLE_1", "ROLE_2") + .workspace("ws3") + .build(); + // verify r3 takes over r4 + AdminAccessInfo ws3Auth = service.getAdminAuthorization(ws3AdminReq); + assertThat(ws3Auth.isAdmin()).isFalse(); + + // conflate should give only user access + var builder = WorkspaceAccessSummary.builder().workspace("ws3"); + service.conflateAdminRules(builder, List.of(r3, r4)); + var wsSummary = builder.build(); + assertThat(wsSummary.getAdminAccess()).isEqualTo(USER); + + builder = WorkspaceAccessSummary.builder().workspace("ws3"); + service.conflateAdminRules(builder, List.of(r4, r3)); + wsSummary = builder.build(); + assertThat(wsSummary.getAdminAccess()).isEqualTo(USER); + } + + @Test + void getRulesByWorkspace() { + String user = "user1"; + Set roles = Set.of("ROLE_1", "ROLE_2"); + AuthorizationServiceImpl service = (AuthorizationServiceImpl) super.authorizationService; + Map> actual; + + actual = service.getRulesByWorkspace(user, roles); + assertThat(actual).isEmpty(); + + Rule r1 = insert(ALLOW, 1, "*", "ROLE_1", "ws1", "*"); + Rule r2 = insert(ALLOW, 2, "*", "ROLE_2", "ws2", "*"); + Rule r3 = insert(ALLOW, 3, "user1", "*", "ws1", "*"); + Rule r4 = insert(ALLOW, 4, "user1", "*", "ws2", "*"); + insert(ALLOW, 5, "user2", "*", "ws2", "*"); + insert(DENY, 6, "*", "ROLE_3", "ws2", "*"); + + actual = service.getRulesByWorkspace(user, roles); + assertThat(actual) + .isEqualTo( + Map.of( + "ws1", list(r1, r3), + "ws2", list(r2, r4))); + } + + @Test + void getRulesByWorkspace2() { + Rule r1 = insert(ALLOW, 1, "*", "ROLE_1", "w1", "L1"); + Rule r2 = insert(ALLOW, 2, "*", "ROLE_2", "w1", "L2"); + Rule r3 = insert(DENY, 3, "user1", "*", "w1", "*"); + insert(ALLOW, 5, "user2", "*", "w1", "*"); + + AuthorizationServiceImpl service = (AuthorizationServiceImpl) super.authorizationService; + String user = "user1"; + Set roles = Set.of("ROLE_1", "ROLE_2"); + var actual = service.getRulesByWorkspace(user, roles); + assertThat(actual).isEqualTo(Map.of("w1", list(r1, r2, r3))); + } + + @Test + void conflateRules() { + Rule r1 = insert(LIMIT, 1, "*", "ROLE_1", "ws1", "L1"); + var wsSummary = conflateRules("ws1", r1); + assertThat(wsSummary.getAllowed()).isEmpty(); + assertThat(wsSummary.getForbidden()).isEmpty(); + + Rule r2 = insert(ALLOW, 2, "*", "ROLE_2", "ws1", "L1"); + wsSummary = conflateRules("ws1", r1, r2); + assertThat(wsSummary.getAllowed()).containsOnly("L1"); + assertThat(wsSummary.getForbidden()).isEmpty(); + + Rule r3 = insert(ALLOW, 3, "user1", "*", "ws1", "*"); + Rule r4 = insert(DENY, 4, "user1", "*", "ws1", "L3"); + + wsSummary = conflateRules("ws1", r1, r2, r3, r4); + assertThat(wsSummary.getAllowed()).containsOnly("*"); + assertThat(wsSummary.getForbidden()).containsOnly("L3"); + } + + WorkspaceAccessSummary conflateRules(String ws, Rule... rules) { + Builder builder = builder(ws); + AuthorizationServiceImpl service = (AuthorizationServiceImpl) super.authorizationService; + service.conflateRules(builder, list(rules)); + return builder.build(); + } + + private WorkspaceAccessSummary.Builder builder(String ws) { + return WorkspaceAccessSummary.builder().workspace(ws); + } + + private List list(@SuppressWarnings("unchecked") T... items) { + return List.of(items); + } + + protected Rule insert( + GrantType access, + long priority, + String user, + String role, + String workspace, + String layer) { + + if ("*".equals(user)) user = null; + if ("*".equals(role)) role = null; + if ("*".equals(workspace)) workspace = null; + if ("*".equals(layer)) layer = null; + return insert(priority, user, role, null, null, null, null, workspace, layer, access); + } } diff --git a/src/application/authorization-impl/src/test/java/org/geoserver/acl/authorization/AuthorizationServiceTest.java b/src/application/authorization-impl/src/test/java/org/geoserver/acl/authorization/AuthorizationServiceTest.java index 2b04de0..869b362 100644 --- a/src/application/authorization-impl/src/test/java/org/geoserver/acl/authorization/AuthorizationServiceTest.java +++ b/src/application/authorization-impl/src/test/java/org/geoserver/acl/authorization/AuthorizationServiceTest.java @@ -37,11 +37,12 @@ /** * {@link AuthorizationService} integration/conformance test * - *

    Concrete implementations must supply the required services in {@link ServiceTestBase} + *

    Concrete implementations must supply the required services in {@link + * BaseAuthorizationServiceTest} * * @author Emanuele Tajariol (etj at geo-solutions.it) (originally as part of GeoFence) */ -public abstract class AuthorizationServiceTest extends ServiceTestBase { +public abstract class AuthorizationServiceTest extends BaseAuthorizationServiceTest { protected abstract RuleAdminService getRuleAdminService(); diff --git a/src/application/authorization-impl/src/test/java/org/geoserver/acl/authorization/ServiceTestBase.java b/src/application/authorization-impl/src/test/java/org/geoserver/acl/authorization/BaseAuthorizationServiceTest.java similarity index 81% rename from src/application/authorization-impl/src/test/java/org/geoserver/acl/authorization/ServiceTestBase.java rename to src/application/authorization-impl/src/test/java/org/geoserver/acl/authorization/BaseAuthorizationServiceTest.java index 1f34177..09dea87 100644 --- a/src/application/authorization-impl/src/test/java/org/geoserver/acl/authorization/ServiceTestBase.java +++ b/src/application/authorization-impl/src/test/java/org/geoserver/acl/authorization/BaseAuthorizationServiceTest.java @@ -8,8 +8,10 @@ import static org.assertj.core.api.Assertions.assertThat; +import org.geoserver.acl.domain.adminrules.AdminGrantType; import org.geoserver.acl.domain.adminrules.AdminRule; import org.geoserver.acl.domain.adminrules.AdminRuleAdminService; +import org.geoserver.acl.domain.adminrules.AdminRuleIdentifier; import org.geoserver.acl.domain.rules.GrantType; import org.geoserver.acl.domain.rules.Rule; import org.geoserver.acl.domain.rules.RuleAdminService; @@ -24,7 +26,7 @@ /** * @author Emanuele Tajariol (etj at geo-solutions.it) (originally as part of GeoFence) */ -public abstract class ServiceTestBase { +public abstract class BaseAuthorizationServiceTest { protected RuleAdminService ruleAdminService; protected AdminRuleAdminService adminruleAdminService; @@ -125,4 +127,21 @@ protected Rule rule( protected AdminRule insert(AdminRule adminRule) { return adminruleAdminService.insert(adminRule); } + + protected AdminRule insert( + AdminGrantType admin, int priority, String user, String role, String workspace) { + if ("*".equals(user)) user = null; + if ("*".equals(role)) role = null; + if ("*".equals(workspace)) workspace = null; + AdminRuleAdminService service = getAdminRuleAdminService(); + AdminRuleIdentifier identifier = + AdminRuleIdentifier.builder() + .username(user) + .rolename(role) + .workspace(workspace) + .build(); + AdminRule rule = + AdminRule.builder().priority(priority).access(admin).identifier(identifier).build(); + return insert(rule); + } } diff --git a/src/domain/adminrule-management/src/main/java/org/geoserver/acl/domain/adminrules/AdminRule.java b/src/domain/adminrule-management/src/main/java/org/geoserver/acl/domain/adminrules/AdminRule.java index f9d4923..fb6885e 100644 --- a/src/domain/adminrule-management/src/main/java/org/geoserver/acl/domain/adminrules/AdminRule.java +++ b/src/domain/adminrule-management/src/main/java/org/geoserver/acl/domain/adminrules/AdminRule.java @@ -49,6 +49,10 @@ public class AdminRule { return String.format("AdminRule[id: %s, %s]", id, toShortString()); } + public boolean isAdmin() { + return access == AdminGrantType.ADMIN; + } + public AdminRule withUsername(String username) { return withIdentifier(identifier.withUsername(username)); } diff --git a/src/domain/rule-management/src/main/java/org/geoserver/acl/domain/rules/Rule.java b/src/domain/rule-management/src/main/java/org/geoserver/acl/domain/rules/Rule.java index ee99db9..fbbc9b5 100644 --- a/src/domain/rule-management/src/main/java/org/geoserver/acl/domain/rules/Rule.java +++ b/src/domain/rule-management/src/main/java/org/geoserver/acl/domain/rules/Rule.java @@ -41,6 +41,10 @@ public class Rule { return String.format("Rule[id: %s, %s]", id, toShortString()); } + public GrantType access() { + return getIdentifier().getAccess(); + } + public String ipAddressRange() { return getIdentifier().getAddressRange(); } diff --git a/src/integration/openapi/java-client/src/main/java/org/geoserver/acl/api/client/integration/AuthorizationServiceClientAdaptor.java b/src/integration/openapi/java-client/src/main/java/org/geoserver/acl/api/client/integration/AuthorizationServiceClientAdaptor.java index 56ec6dc..8388a74 100644 --- a/src/integration/openapi/java-client/src/main/java/org/geoserver/acl/api/client/integration/AuthorizationServiceClientAdaptor.java +++ b/src/integration/openapi/java-client/src/main/java/org/geoserver/acl/api/client/integration/AuthorizationServiceClientAdaptor.java @@ -12,6 +12,8 @@ import org.geoserver.acl.api.mapper.AuthorizationModelApiMapper; import org.geoserver.acl.api.mapper.RuleApiMapper; import org.geoserver.acl.authorization.AccessInfo; +import org.geoserver.acl.authorization.AccessSummary; +import org.geoserver.acl.authorization.AccessSummaryRequest; import org.geoserver.acl.authorization.AdminAccessInfo; import org.geoserver.acl.authorization.AuthorizationService; import org.geoserver.acl.domain.rules.Rule; @@ -74,4 +76,20 @@ public List getMatchingRules( throw e; } } + + @Override + public AccessSummary getUserAccessSummary(AccessSummaryRequest request) { + + org.geoserver.acl.api.model.AccessSummaryRequest apiRequest; + org.geoserver.acl.api.model.AccessSummary apiResponse; + + try { + apiRequest = mapper.toApi(request); + apiResponse = apiClient.getUserAccessSummary(apiRequest); + return mapper.toModel(apiResponse); + } catch (RuntimeException e) { + log.error("Error getting user access summary for {}", request, e); + throw e; + } + } } diff --git a/src/integration/openapi/java-e2e/src/test/java/org/geoserver/acl/api/it/accesscontrol/AuthorizationServiceClientAdaptorAccessSummaryIT.java b/src/integration/openapi/java-e2e/src/test/java/org/geoserver/acl/api/it/accesscontrol/AuthorizationServiceClientAdaptorAccessSummaryIT.java new file mode 100644 index 0000000..990b728 --- /dev/null +++ b/src/integration/openapi/java-e2e/src/test/java/org/geoserver/acl/api/it/accesscontrol/AuthorizationServiceClientAdaptorAccessSummaryIT.java @@ -0,0 +1,105 @@ +/* (c) 2023 Open Source Geospatial Foundation - all rights reserved + * This code is licensed under the GPL 2.0 license, available at the root + * application directory. + */ +package org.geoserver.acl.api.it.accesscontrol; + +import org.geoserver.acl.api.client.integration.AuthorizationServiceClientAdaptor; +import org.geoserver.acl.api.it.support.ClientContextSupport; +import org.geoserver.acl.api.it.support.IntegrationTestsApplication; +import org.geoserver.acl.api.it.support.ServerContextSupport; +import org.geoserver.acl.authorization.AuthorizationService; +import org.geoserver.acl.authorization.AuthorizationServiceAccessSummaryTest; +import org.geoserver.acl.domain.adminrules.AdminRuleAdminService; +import org.geoserver.acl.domain.rules.RuleAdminService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.annotation.DirtiesContext; + +/** + * {@link AuthorizationServiceAccessSummaryTest} end to end integration test with {@link + * AuthorizationServiceClientAdaptor} hitting the authorization API directly through HTTP. + * + *

    {@code
    + * CLIENT:            AuthorizationServiceClientAdaptor <>
    + *                                 |
    + *                                 |
    + *                                 | 
    + * SERVER:                         |
    + *                                 v
    + *                      AuthorizationApiController 
    + *                                 |
    + *                                 v
    + *                        AuthorizationApiImpl
    + *                                 |
    + *                                 v
    + *                       AuthorizationServiceImpl
    + *                        |                  |
    + *                        |                  |
    + *                        v                  v
    + *               RuleAdminService       AdminRuleAdminService
    + *                    |                         |
    + *                    v                         v
    + *        RuleRepositoryJpaAdaptor      AdminRuleRepositoryJpaAdaptor
    + *                       \                   /
    + *                        \                 /
    + *                         \               /
    + *                          \_____________/
    + *                          |             |
    + *                          |  Database   |
    + *                          |_____________|
    + *
    + * }
    + * + * @since 2.3 + * @see AuthorizationServiceAccessSummaryTest + */ +@DirtiesContext +@SpringBootTest( + webEnvironment = WebEnvironment.RANDOM_PORT, + properties = { + "geoserver.acl.jpa.show-sql=false", + "geoserver.acl.jpa.properties.hibernate.hbm2ddl.auto=create", + "geoserver.acl.datasource.url=jdbc:h2:mem:geoserver-acl" + }, + classes = {IntegrationTestsApplication.class}) +class AuthorizationServiceClientAdaptorAccessSummaryIT + extends AuthorizationServiceAccessSummaryTest { + + private @Autowired ServerContextSupport serverContext; + private @LocalServerPort int serverPort; + + private ClientContextSupport clientContext; + + @Override + @BeforeEach + protected void setUp() throws Exception { + clientContext = new ClientContextSupport().log(false).serverPort(serverPort).setUp(); + serverContext.setUp(); + super.setUp(); + } + + @AfterEach + void tearDown() { + clientContext.close(); + } + + @Override + protected RuleAdminService getRuleAdminService() { + return clientContext.getRuleAdminServiceClient(); + } + + @Override + protected AdminRuleAdminService getAdminRuleAdminService() { + return clientContext.getAdminRuleAdminServiceClient(); + } + + @Override + protected AuthorizationService getAuthorizationService() { + return clientContext.getAuthorizationServiceClientAdaptor(); + } +} diff --git a/src/integration/openapi/java-e2e/src/test/java/org/geoserver/acl/api/it/accesscontrol/AuthorizationServiceImplAccessSummaryApiIT.java b/src/integration/openapi/java-e2e/src/test/java/org/geoserver/acl/api/it/accesscontrol/AuthorizationServiceImplAccessSummaryApiIT.java new file mode 100644 index 0000000..c29cc89 --- /dev/null +++ b/src/integration/openapi/java-e2e/src/test/java/org/geoserver/acl/api/it/accesscontrol/AuthorizationServiceImplAccessSummaryApiIT.java @@ -0,0 +1,118 @@ +/* (c) 2023 Open Source Geospatial Foundation - all rights reserved + * This code is licensed under the GPL 2.0 license, available at the root + * application directory. + */ +package org.geoserver.acl.api.it.accesscontrol; + +import org.geoserver.acl.api.it.support.ClientContextSupport; +import org.geoserver.acl.api.it.support.IntegrationTestsApplication; +import org.geoserver.acl.api.it.support.ServerContextSupport; +import org.geoserver.acl.authorization.AuthorizationService; +import org.geoserver.acl.authorization.AuthorizationServiceAccessSummaryTest; +import org.geoserver.acl.domain.adminrules.AdminRuleAdminService; +import org.geoserver.acl.domain.adminrules.AdminRuleRepository; +import org.geoserver.acl.domain.rules.RuleAdminService; +import org.geoserver.acl.domain.rules.RuleRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.annotation.DirtiesContext; + +/** + * {@link AuthorizationService} end to end integration test with {@link AuthorizationService} + * hitting {@link RuleAdminService} and {@link AdminRuleAdminService} backed by OpenAPI Java Client + * adaptors for {@link RuleRepository} and {@link AdminRuleRepository}, which in turn make real HTTP + * calls to a running server API. + * + *
    {@code
    + * CLIENT:               AuthorizationServiceImpl
    + *                                |          |
    + *                                v          v
    + *                   RuleAdminService    AdminRuleAdminService
    + *                          |                    |
    + *                          v                    v
    + *         RuleRepositoryClientAdaptor    AdminRuleRepositoryClientAdaptor
    + *                    |                             |
    + *                    v                             v
    + *                RulesApi            AdminRulesApi 
    + *                    |                             |
    + *                    |                             |
    + *                    |                       | 
    + * SERVER:            |                             |
    + *                    v                             v
    + *       DataRulesApiController   WorkspaceAdminRulesApiController 
    + *                    |                             |
    + *                    v                             v
    + *            DataRulesApiImpl              WorkspaceAdminRulesApiImpl
    + *                    |                             |
    + *                    v                             v
    + *            RuleAdminService            AdminRuleAdminService
    + *                    |                             |
    + *                    v                             v
    + *        RuleRepositoryJpaAdaptor      AdminRuleRepositoryJpaAdaptor
    + *                       \                   /
    + *                        \                 /
    + *                         \               /
    + *                          \_____________/
    + *                          |             |
    + *                          |  Database   |
    + *                          |_____________|
    + *
    + * }
    + * + * @since 2.3 + * @see AuthorizationServiceAccessSummaryTest + */ +@DirtiesContext +@SpringBootTest( + webEnvironment = WebEnvironment.RANDOM_PORT, + properties = { + "geoserver.acl.jpa.show-sql=false", + "geoserver.acl.jpa.properties.hibernate.hbm2ddl.auto=create", + "geoserver.acl.datasource.url=jdbc:h2:mem:geoserver-acl" + }, + classes = {IntegrationTestsApplication.class}) +class AuthorizationServiceImplAccessSummaryApiIT extends AuthorizationServiceAccessSummaryTest { + + private @Autowired ServerContextSupport serverContext; + private @LocalServerPort int serverPort; + + private ClientContextSupport clientContext; + + @Override + @BeforeEach + protected void setUp() throws Exception { + clientContext = + new ClientContextSupport() + // logging breaks client exception handling, only enable if need to see the + // request/response bodies + .log(false) + .serverPort(serverPort) + .setUp(); + serverContext.setUp(); + super.setUp(); + } + + @AfterEach + void tearDown() { + clientContext.close(); + } + + @Override + protected RuleAdminService getRuleAdminService() { + return clientContext.getRuleAdminServiceClient(); + } + + @Override + protected AdminRuleAdminService getAdminRuleAdminService() { + return clientContext.getAdminRuleAdminServiceClient(); + } + + @Override + protected AuthorizationService getAuthorizationService() { + return clientContext.getInProcessAuthorizationService(); + } +} diff --git a/src/integration/openapi/model-mapping/src/main/java/org/geoserver/acl/api/mapper/AuthorizationModelApiMapper.java b/src/integration/openapi/model-mapping/src/main/java/org/geoserver/acl/api/mapper/AuthorizationModelApiMapper.java index f91959f..eac4bc4 100644 --- a/src/integration/openapi/model-mapping/src/main/java/org/geoserver/acl/api/mapper/AuthorizationModelApiMapper.java +++ b/src/integration/openapi/model-mapping/src/main/java/org/geoserver/acl/api/mapper/AuthorizationModelApiMapper.java @@ -6,12 +6,18 @@ import org.geoserver.acl.api.model.AccessInfo; import org.geoserver.acl.api.model.AccessRequest; +import org.geoserver.acl.api.model.AccessSummaryRequest; import org.geoserver.acl.api.model.AdminAccessInfo; import org.geoserver.acl.api.model.AdminAccessRequest; +import org.geoserver.acl.authorization.AccessSummary; +import org.geoserver.acl.authorization.WorkspaceAccessSummary; import org.mapstruct.InjectionStrategy; import org.mapstruct.Mapper; import org.mapstruct.ReportingPolicy; +import java.util.List; +import java.util.Optional; + @Mapper( componentModel = "spring", injectionStrategy = InjectionStrategy.CONSTRUCTOR, @@ -34,4 +40,24 @@ public interface AuthorizationModelApiMapper { AdminAccessInfo toApi(org.geoserver.acl.authorization.AdminAccessInfo grant); org.geoserver.acl.authorization.AdminAccessInfo toModel(AdminAccessInfo grant); + + AccessSummaryRequest toApi(org.geoserver.acl.authorization.AccessSummaryRequest request); + + org.geoserver.acl.authorization.AccessSummaryRequest toModel(AccessSummaryRequest request); + + org.geoserver.acl.api.model.AccessSummary toApi(AccessSummary apiResponse); + + default AccessSummary toModel(org.geoserver.acl.api.model.AccessSummary apiResponse) { + if (apiResponse == null) { + return null; + } + List workspaces = + Optional.ofNullable(apiResponse.getWorkspaces()).orElse(List.of()).stream() + .map(this::workspaceAccessSummary) + .toList(); + return AccessSummary.of(workspaces); + } + + WorkspaceAccessSummary workspaceAccessSummary( + org.geoserver.acl.api.model.WorkspaceAccessSummary workspaceAccessSummary); } diff --git a/src/integration/openapi/spring-server/src/main/java/org/geoserver/acl/api/server/authorization/AuthorizationApiImpl.java b/src/integration/openapi/spring-server/src/main/java/org/geoserver/acl/api/server/authorization/AuthorizationApiImpl.java index e110dbf..ec08851 100644 --- a/src/integration/openapi/spring-server/src/main/java/org/geoserver/acl/api/server/authorization/AuthorizationApiImpl.java +++ b/src/integration/openapi/spring-server/src/main/java/org/geoserver/acl/api/server/authorization/AuthorizationApiImpl.java @@ -9,6 +9,8 @@ import org.geoserver.acl.api.model.AccessInfo; import org.geoserver.acl.api.model.AccessRequest; +import org.geoserver.acl.api.model.AccessSummary; +import org.geoserver.acl.api.model.AccessSummaryRequest; import org.geoserver.acl.api.model.AdminAccessInfo; import org.geoserver.acl.api.model.AdminAccessRequest; import org.geoserver.acl.api.model.Rule; @@ -65,4 +67,15 @@ public ResponseEntity> getMatchingRules(AccessRequest accessRequest) List apiResponse = modelResponse.stream().map(support::toApi).toList(); return ResponseEntity.ok(apiResponse); } + + @Override + public ResponseEntity getUserAccessSummary(AccessSummaryRequest request) { + org.geoserver.acl.authorization.AccessSummaryRequest modelRequest; + org.geoserver.acl.authorization.AccessSummary modelResponse; + + modelRequest = support.toModel(request); + modelResponse = service.getUserAccessSummary(modelRequest); + AccessSummary apiResponse = support.toApi(modelResponse); + return ResponseEntity.ok(apiResponse); + } } diff --git a/src/integration/openapi/spring-server/src/main/java/org/geoserver/acl/api/server/support/AuthorizationApiSupport.java b/src/integration/openapi/spring-server/src/main/java/org/geoserver/acl/api/server/support/AuthorizationApiSupport.java index 16f66c9..fa77397 100644 --- a/src/integration/openapi/spring-server/src/main/java/org/geoserver/acl/api/server/support/AuthorizationApiSupport.java +++ b/src/integration/openapi/spring-server/src/main/java/org/geoserver/acl/api/server/support/AuthorizationApiSupport.java @@ -13,6 +13,8 @@ import org.geoserver.acl.api.model.AdminAccessInfo; import org.geoserver.acl.api.model.AdminAccessRequest; import org.geoserver.acl.api.model.Rule; +import org.geoserver.acl.authorization.AccessSummary; +import org.geoserver.acl.authorization.AccessSummaryRequest; import org.springframework.web.context.request.NativeWebRequest; public class AuthorizationApiSupport @@ -58,4 +60,12 @@ public AdminAccessInfo toApi(org.geoserver.acl.authorization.AdminAccessInfo acc public org.geoserver.acl.authorization.AdminAccessInfo toModel(AdminAccessInfo access) { return mapper.toModel(access); } + + public AccessSummaryRequest toModel(org.geoserver.acl.api.model.AccessSummaryRequest request) { + return mapper.toModel(request); + } + + public org.geoserver.acl.api.model.AccessSummary toApi(AccessSummary response) { + return mapper.toApi(response); + } } diff --git a/src/integration/persistence-jpa/integration/src/test/java/org/geoserver/acl/integration/jpa/it/AuthorizationServiceImplAccessSummaryJpaIT.java b/src/integration/persistence-jpa/integration/src/test/java/org/geoserver/acl/integration/jpa/it/AuthorizationServiceImplAccessSummaryJpaIT.java new file mode 100644 index 0000000..9f78003 --- /dev/null +++ b/src/integration/persistence-jpa/integration/src/test/java/org/geoserver/acl/integration/jpa/it/AuthorizationServiceImplAccessSummaryJpaIT.java @@ -0,0 +1,73 @@ +/* (c) 2023 Open Source Geospatial Foundation - all rights reserved + * This code is licensed under the GPL 2.0 license, available at the root + * application directory. + */ +package org.geoserver.acl.integration.jpa.it; + +import org.geoserver.acl.authorization.AuthorizationService; +import org.geoserver.acl.authorization.AuthorizationServiceAccessSummaryTest; +import org.geoserver.acl.domain.adminrules.AdminRuleAdminService; +import org.geoserver.acl.domain.rules.RuleAdminService; +import org.geoserver.acl.integration.jpa.config.AuthorizationJPAPropertiesTestConfiguration; +import org.geoserver.acl.integration.jpa.config.JPAIntegrationConfiguration; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +/** + * {@link AuthorizationServiceAccessSummaryTest} integration test with JPA-backed repositories + * + *
    {@code
    + *                AuthorizationService
    + *                   |          |
    + *                   v          v
    + *         RuleAdminService  AdminRuleAdminService
    + *             |                     |
    + *             v                     v
    + * RuleRepositoryJpaAdaptor  AdminRuleRepositoryJpaAdaptor
    + *              \                   /
    + *               \                 /
    + *                \               /
    + *                 \_____________/
    + *                 |             |
    + *                 |  Database   |
    + *                 |_____________|
    + *
    + * }
    + * + * @since 2.3 + */ +@SpringBootTest( + classes = { + AuthorizationJPAPropertiesTestConfiguration.class, + JPAIntegrationConfiguration.class, + JpaIntegrationTestSupport.class + }) +@ActiveProfiles("test") // see config props in src/test/resource/application-test.yaml +class AuthorizationServiceImplAccessSummaryJpaIT extends AuthorizationServiceAccessSummaryTest { + + private @Autowired JpaIntegrationTestSupport support; + + @Override + @BeforeEach + protected void setUp() throws Exception { + support.setUp(); + super.setUp(); + } + + @Override + protected RuleAdminService getRuleAdminService() { + return support.getRuleAdminService(); + } + + @Override + protected AdminRuleAdminService getAdminRuleAdminService() { + return support.getAdminruleAdminService(); + } + + @Override + protected AuthorizationService getAuthorizationService() { + return support.getAuthorizationService(); + } +} diff --git a/src/integration/persistence-jpa/integration/src/test/java/org/geoserver/acl/integration/jpa/it/AuthorizationServiceImplJpaIT.java b/src/integration/persistence-jpa/integration/src/test/java/org/geoserver/acl/integration/jpa/it/AuthorizationServiceImplJpaIT.java index cf417e1..756e4fc 100644 --- a/src/integration/persistence-jpa/integration/src/test/java/org/geoserver/acl/integration/jpa/it/AuthorizationServiceImplJpaIT.java +++ b/src/integration/persistence-jpa/integration/src/test/java/org/geoserver/acl/integration/jpa/it/AuthorizationServiceImplJpaIT.java @@ -5,7 +5,7 @@ package org.geoserver.acl.integration.jpa.it; import org.geoserver.acl.authorization.AuthorizationService; -import org.geoserver.acl.authorization.AuthorizationServiceTest; +import org.geoserver.acl.authorization.AuthorizationServiceImplTest; import org.geoserver.acl.domain.adminrules.AdminRuleAdminService; import org.geoserver.acl.domain.rules.RuleAdminService; import org.geoserver.acl.integration.jpa.config.AuthorizationJPAPropertiesTestConfiguration; @@ -45,7 +45,7 @@ JpaIntegrationTestSupport.class }) @ActiveProfiles("test") // see config props in src/test/resource/application-test.yaml -class AuthorizationServiceImplJpaIT extends AuthorizationServiceTest { +class AuthorizationServiceImplJpaIT extends AuthorizationServiceImplTest { private @Autowired JpaIntegrationTestSupport support; diff --git a/src/integration/persistence-jpa/integration/src/test/resources/application-test.yaml b/src/integration/persistence-jpa/integration/src/test/resources/application-test.yaml index f7caf61..db7801f 100644 --- a/src/integration/persistence-jpa/integration/src/test/resources/application-test.yaml +++ b/src/integration/persistence-jpa/integration/src/test/resources/application-test.yaml @@ -19,7 +19,7 @@ geoserver.acl: logging: level: root: warn - '[org.geoserver.acl]': warn + '[org.geoserver.acl]': info '[org.springframework.boot.autoconfigure]': warn '[org.springframework.boot.test.context]': warn '[org.testcontainers]': info diff --git a/src/integration/spring/cache/src/main/java/org/geoserver/acl/authorization/cache/CachingAuthorizationService.java b/src/integration/spring/cache/src/main/java/org/geoserver/acl/authorization/cache/CachingAuthorizationService.java index 908b30f..d5e3d93 100644 --- a/src/integration/spring/cache/src/main/java/org/geoserver/acl/authorization/cache/CachingAuthorizationService.java +++ b/src/integration/spring/cache/src/main/java/org/geoserver/acl/authorization/cache/CachingAuthorizationService.java @@ -9,6 +9,8 @@ import org.geoserver.acl.authorization.AccessInfo; import org.geoserver.acl.authorization.AccessRequest; +import org.geoserver.acl.authorization.AccessSummary; +import org.geoserver.acl.authorization.AccessSummaryRequest; import org.geoserver.acl.authorization.AdminAccessInfo; import org.geoserver.acl.authorization.AdminAccessRequest; import org.geoserver.acl.authorization.AuthorizationService; @@ -33,26 +35,23 @@ public class CachingAuthorizationService extends ForwardingAuthorizationService private final ConcurrentMap ruleAccessCache; private final ConcurrentMap adminRuleAccessCache; + private final ConcurrentMap viewablesCache; public CachingAuthorizationService( @NonNull AuthorizationService delegate, @NonNull ConcurrentMap dataAccessCache, - @NonNull ConcurrentMap adminAccessCache) { + @NonNull ConcurrentMap adminAccessCache, + @NonNull ConcurrentMap viewablesCache) { super(delegate); this.ruleAccessCache = dataAccessCache; this.adminRuleAccessCache = adminAccessCache; + this.viewablesCache = viewablesCache; } @Override public AccessInfo getAccessInfo(@NonNull AccessRequest request) { - AccessInfo grant = ruleAccessCache.computeIfAbsent(request, this::load); - if (grant.getMatchingRules().isEmpty()) { - // do not cache results with no matching rules. It'll make it impossible to evict them - this.ruleAccessCache.remove(request); - } - - return grant; + return ruleAccessCache.computeIfAbsent(request, this::load); } private AccessInfo load(AccessRequest request) { @@ -61,18 +60,22 @@ private AccessInfo load(AccessRequest request) { @Override public AdminAccessInfo getAdminAuthorization(@NonNull AdminAccessRequest request) { - AdminAccessInfo grant = adminRuleAccessCache.computeIfAbsent(request, this::load); - if (grant.getMatchingAdminRule() == null) { - // do not cache results with no matching rules. It'll make it impossible to evict them - this.adminRuleAccessCache.remove(request); - } - return grant; + return adminRuleAccessCache.computeIfAbsent(request, this::load); } private AdminAccessInfo load(AdminAccessRequest request) { return logLoaded(request, super.getAdminAuthorization(request)); } + @Override + public AccessSummary getUserAccessSummary(@NonNull AccessSummaryRequest request) { + return viewablesCache.computeIfAbsent(request, this::load); + } + + private AccessSummary load(AccessSummaryRequest request) { + return logLoaded(request, super.getUserAccessSummary(request)); + } + private A logLoaded(Object request, A accessInfo) { log.debug("loaded and cached {} -> {}", request, accessInfo); return accessInfo; @@ -81,18 +84,24 @@ private A logLoaded(Object request, A accessInfo) { @EventListener(RuleEvent.class) public void onRuleEvent(RuleEvent event) { int evictCount = evictAll(ruleAccessCache); + evictViewables(); log.debug("evicted all {} authorizations upon event {}", evictCount, event); } + @EventListener(AdminRuleEvent.class) + public void onAdminRuleEvent(AdminRuleEvent event) { + int evictCount = evictAll(adminRuleAccessCache); + evictViewables(); + log.debug("evicted all {} admin authorizations upon event {}", evictCount, event); + } + private int evictAll(Map cache) { int size = cache.size(); cache.clear(); return size; } - @EventListener(AdminRuleEvent.class) - public void onAdminRuleEvent(AdminRuleEvent event) { - int evictCount = evictAll(adminRuleAccessCache); - log.debug("evicted all {} admin authorizations upon event {}", evictCount, event); + void evictViewables() { + evictAll(viewablesCache); } } diff --git a/src/integration/spring/cache/src/main/java/org/geoserver/acl/authorization/cache/CachingAuthorizationServiceConfiguration.java b/src/integration/spring/cache/src/main/java/org/geoserver/acl/authorization/cache/CachingAuthorizationServiceConfiguration.java index 9765332..a9a746c 100644 --- a/src/integration/spring/cache/src/main/java/org/geoserver/acl/authorization/cache/CachingAuthorizationServiceConfiguration.java +++ b/src/integration/spring/cache/src/main/java/org/geoserver/acl/authorization/cache/CachingAuthorizationServiceConfiguration.java @@ -9,6 +9,8 @@ import org.geoserver.acl.authorization.AccessInfo; import org.geoserver.acl.authorization.AccessRequest; +import org.geoserver.acl.authorization.AccessSummary; +import org.geoserver.acl.authorization.AccessSummaryRequest; import org.geoserver.acl.authorization.AdminAccessInfo; import org.geoserver.acl.authorization.AdminAccessRequest; import org.geoserver.acl.authorization.AuthorizationService; @@ -33,10 +35,11 @@ public class CachingAuthorizationServiceConfiguration { CachingAuthorizationService cachingAuthorizationService( AuthorizationService delegate, ConcurrentMap authorizationCache, - ConcurrentMap adminAuthorizationCache) { + ConcurrentMap adminAuthorizationCache, + ConcurrentMap viewablesCache) { return new CachingAuthorizationService( - delegate, authorizationCache, adminAuthorizationCache); + delegate, authorizationCache, adminAuthorizationCache, viewablesCache); } @Bean @@ -50,11 +53,17 @@ ConcurrentMap aclAdminAuthCache( return getCache(cacheManager, "acl-admin-grants"); } + @Bean + ConcurrentMap aclViewablesCache( + CacheManager cacheManager) { + return getCache(cacheManager, "acl-access-summary"); + } + + @SuppressWarnings("unchecked") private ConcurrentMap getCache(CacheManager cacheManager, String cacheName) { if (cacheManager instanceof CaffeineCacheManager ccf) { org.springframework.cache.Cache cache = ccf.getCache(cacheName); if (cache != null) { - @SuppressWarnings("unchecked") Cache caffeineCache = (Cache) cache.getNativeCache(); return caffeineCache.asMap(); } diff --git a/src/integration/spring/cache/src/test/java/org/geoserver/acl/authorization/cache/CachingAuthorizationServiceTest.java b/src/integration/spring/cache/src/test/java/org/geoserver/acl/authorization/cache/CachingAuthorizationServiceTest.java index 4b1a484..2fb8550 100644 --- a/src/integration/spring/cache/src/test/java/org/geoserver/acl/authorization/cache/CachingAuthorizationServiceTest.java +++ b/src/integration/spring/cache/src/test/java/org/geoserver/acl/authorization/cache/CachingAuthorizationServiceTest.java @@ -5,8 +5,7 @@ package org.geoserver.acl.authorization.cache; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -14,6 +13,8 @@ import org.geoserver.acl.authorization.AccessInfo; import org.geoserver.acl.authorization.AccessRequest; +import org.geoserver.acl.authorization.AccessSummary; +import org.geoserver.acl.authorization.AccessSummaryRequest; import org.geoserver.acl.authorization.AdminAccessInfo; import org.geoserver.acl.authorization.AdminAccessRequest; import org.geoserver.acl.authorization.AuthorizationService; @@ -35,13 +36,17 @@ class CachingAuthorizationServiceTest { private AuthorizationService delegate; private ConcurrentMap dataAccessCache; private ConcurrentMap adminAccessCache; + private ConcurrentMap viewablesCache; @BeforeEach void setUp() throws Exception { delegate = mock(AuthorizationService.class); dataAccessCache = new ConcurrentHashMap<>(); adminAccessCache = new ConcurrentHashMap<>(); - caching = new CachingAuthorizationService(delegate, dataAccessCache, adminAccessCache); + viewablesCache = new ConcurrentHashMap<>(); + caching = + new CachingAuthorizationService( + delegate, dataAccessCache, adminAccessCache, viewablesCache); } @Test @@ -49,8 +54,24 @@ void testCachingAuthorizationService() { var npe = NullPointerException.class; assertThrows( npe, - () -> new CachingAuthorizationService(null, dataAccessCache, adminAccessCache)); - assertThrows(npe, () -> new CachingAuthorizationService(delegate, null, adminAccessCache)); + () -> + new CachingAuthorizationService( + null, dataAccessCache, adminAccessCache, viewablesCache)); + assertThrows( + npe, + () -> + new CachingAuthorizationService( + delegate, null, adminAccessCache, viewablesCache)); + assertThrows( + npe, + () -> + new CachingAuthorizationService( + delegate, dataAccessCache, null, viewablesCache)); + assertThrows( + npe, + () -> + new CachingAuthorizationService( + delegate, dataAccessCache, adminAccessCache, null)); } @Test diff --git a/src/openapi/acl-api.yaml b/src/openapi/acl-api.yaml index 0c35d82..f4e1093 100644 --- a/src/openapi/acl-api.yaml +++ b/src/openapi/acl-api.yaml @@ -629,6 +629,23 @@ paths: responses: '200': $ref: '#/components/responses/PageOfRules' + /authorization/accesssummary: + post: + operationId: getUserAccessSummary + tags: + - Authorization + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AccessSummaryRequest' + application/x-jackson-smile: + schema: + $ref: '#/components/schemas/AccessSummaryRequest' + responses: + '200': + $ref: '#/components/responses/AccessSummary' components: securitySchemes: @@ -757,6 +774,15 @@ components: application/x-jackson-smile: schema: $ref: '#/components/schemas/AdminAccessInfo' + AccessSummary: + description: The list of per-workspace access summary for a user + content: + application/json: + schema: + $ref: '#/components/schemas/AccessSummary' + application/x-jackson-smile: + schema: + $ref: '#/components/schemas/AccessSummary' schemas: CatalogMode: type: string @@ -1156,4 +1182,58 @@ components: type: string matchingAdminRule: type: string - \ No newline at end of file + + AccessSummaryRequest: + type: object + required: + - user + - roles + properties: + user: + description: the authentication user name + type: string + roles: + description: The roles the requesting user belongs to + type: array + nullable: false + uniqueItems: true + items: + type: string + + AccessSummary: + type: object + properties: + workspaces: + type: array + items: + $ref: '#/components/schemas/WorkspaceAccessSummary' + + WorkspaceAccessSummary: + type: object + properties: + workspace: + type: string + adminAccess: + $ref: '#/components/schemas/AdminGrantType' + description: The roles the requesting user belongs to + allowed: + description: |- + The set of visible layer names in `workspace` the user from the + `AccessSummaryRequest` can somehow see, even if only under specific circumstances like for a + given OWS/request combination, resulting from `GrantType.ALLOW` rules. + type: array + nullable: true + uniqueItems: true + items: + type: string + forbidden: + description: |- + The set of forbidden layer names in `workspace` the user from the + `AccessSummaryRequest` definitely cannot see, resulting from `GrantType.DENY rules. + Complements the `allowed` list as there may be rules allowing access all layers in + a workspace after a rules denying access to specific layers in the same workspace. + type: array + nullable: true + uniqueItems: true + items: + type: string diff --git a/src/plugin/accessmanager/src/main/java/org/geoserver/acl/plugin/accessmanager/ACLResourceAccessManager.java b/src/plugin/accessmanager/src/main/java/org/geoserver/acl/plugin/accessmanager/ACLResourceAccessManager.java index 0a62dcb..02764c8 100644 --- a/src/plugin/accessmanager/src/main/java/org/geoserver/acl/plugin/accessmanager/ACLResourceAccessManager.java +++ b/src/plugin/accessmanager/src/main/java/org/geoserver/acl/plugin/accessmanager/ACLResourceAccessManager.java @@ -6,6 +6,11 @@ */ package org.geoserver.acl.plugin.accessmanager; +import static org.geoserver.acl.domain.rules.GrantType.ALLOW; +import static org.geoserver.acl.domain.rules.GrantType.DENY; +import static org.geoserver.acl.domain.rules.GrantType.LIMIT; +import static org.geoserver.acl.plugin.accessmanager.CatalogSecurityFilterBuilder.buildSecurityFilter; + import static java.util.logging.Level.FINE; import static java.util.logging.Level.WARNING; @@ -14,8 +19,8 @@ import org.apache.commons.collections4.CollectionUtils; import org.geoserver.acl.authorization.AccessInfo; import org.geoserver.acl.authorization.AccessRequest; -import org.geoserver.acl.authorization.AdminAccessInfo; -import org.geoserver.acl.authorization.AdminAccessRequest; +import org.geoserver.acl.authorization.AccessSummary; +import org.geoserver.acl.authorization.AccessSummaryRequest; import org.geoserver.acl.authorization.AuthorizationService; import org.geoserver.acl.domain.rules.GrantType; import org.geoserver.acl.domain.rules.LayerAttribute; @@ -24,6 +29,7 @@ import org.geoserver.acl.plugin.accessmanager.wps.WPSAccessInfo; import org.geoserver.acl.plugin.accessmanager.wps.WPSHelper; import org.geoserver.acl.plugin.support.GeomHelper; +import org.geoserver.catalog.Catalog; import org.geoserver.catalog.CatalogInfo; import org.geoserver.catalog.CoverageInfo; import org.geoserver.catalog.FeatureTypeInfo; @@ -57,6 +63,7 @@ import org.geotools.api.filter.Filter; import org.geotools.api.filter.FilterFactory; import org.geotools.api.filter.expression.PropertyName; +import org.geotools.api.filter.spatial.Intersects; import org.geotools.api.referencing.crs.CoordinateReferenceSystem; import org.geotools.factory.CommonFactoryFinder; import org.geotools.filter.text.cql2.CQLException; @@ -77,6 +84,8 @@ import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; +import java.util.stream.Collectors; +import java.util.stream.Stream; /** * {@link ResourceAccessManager} to make GeoServer use the ACL {@link AuthorizationService} to @@ -99,7 +108,7 @@ enum PropertyAccessMode { static final CatalogMode DEFAULT_CATALOG_MODE = CatalogMode.HIDE; - private AuthorizationService aclService; + private AuthorizationService authorizationService; private final AccessManagerConfigProvider configProvider; @@ -113,45 +122,56 @@ public ACLResourceAccessManager( AccessManagerConfigProvider configurationManager, WPSHelper wpsHelper) { - this.aclService = aclService; + this.authorizationService = aclService; this.configProvider = configurationManager; this.groupsCache = groupsCache; this.wpsHelper = wpsHelper; } - public AccessManagerConfig getConfig() { - return this.configProvider.get(); - } - - static boolean isAuthenticated(Authentication user) { - return (user != null) && !(user instanceof AnonymousAuthenticationToken); + @Override + public int getPriority() { + return ExtensionPriority.LOWEST; } - static boolean isAdmin(Authentication user) { - if (isAuthenticated(user)) { - return user.getAuthorities().stream() - .map(GrantedAuthority::getAuthority) - .anyMatch(GeoServerRole.ADMIN_ROLE.getAuthority()::equals); + /** + * {@inheritDoc} + * + *

    Returns a {@link Filter} selecting only the objects authorized by the manager. May return + * null in which case the caller is responsible for building a filter based on calls to the + * manager's other methods. + * + * @return {@link Filter#INCLUDE INCLUDE} if {@code user} is an {@link GeoServerRole#ADMIN_ROLE + * administrator}, {@link Filter#EXCLUDE EXCLUDE} if {@code user == null}, otherwise the + * filter built by {@link CatalogSecurityFilterBuilder} for the user's {@link AccessSummary} + * and {@link CatalogInfo} {@code infoType} + * @see AuthorizationService#getUserAccessSummary(AccessSummaryRequest) + * @see CatalogSecurityFilterBuilder + */ + @Override + public Filter getSecurityFilter(Authentication user, Class infoType) { + if (null == user) { + return Filter.EXCLUDE; } - return false; + if (isAdmin(user)) { + return Filter.INCLUDE; + } + AccessSummary viewables = getAccessSummary(user); + return buildSecurityFilter(viewables, infoType); } + /** {@inheritDoc} */ @Override public WorkspaceAccessLimits getAccessLimits(Authentication user, WorkspaceInfo workspace) { CatalogMode catalogMode = DEFAULT_CATALOG_MODE; boolean canRead; boolean canWrite; boolean canAdmin; - if (isAuthenticated(user)) { - // shortcut, if the user is the admin, he can do everything - if (isAdmin(user)) { - canRead = canWrite = canAdmin = true; - } else { - canRead = true; - canWrite = configProvider.get().isGrantWriteToWorkspacesToAuthenticatedUsers(); - String workspaceName = workspace.getName(); - canAdmin = isWorkspaceAdmin(user, workspaceName); - } + if (isAdmin(user)) { + canRead = canWrite = canAdmin = true; + } else if (isAuthenticated(user)) { + canRead = true; + canWrite = configProvider.get().isGrantWriteToWorkspacesToAuthenticatedUsers(); + canAdmin = isWorkspaceAdmin(user, workspace); } else { // further logic disabled because of // https://github.com/geosolutions-it/geofence/issues/6 (gone) @@ -162,30 +182,32 @@ public WorkspaceAccessLimits getAccessLimits(Authentication user, WorkspaceInfo return new WorkspaceAccessLimits(catalogMode, canRead, canWrite, canAdmin); } - /** We expect the user not to be null and not to be admin */ - private boolean isWorkspaceAdmin(Authentication user, String workspaceName) { - log( - FINE, - "Getting admin auth for user {0} on workspace {1}", - user.getName(), - workspaceName); - - AdminAccessRequest request = - new AdminAccessRequestBuilder(configProvider.get()) - .user(user) - .workspace(workspaceName) - .build(); - - AdminAccessInfo grant = aclService.getAdminAuthorization(request); - - log( - FINE, - "Admin auth for user {0} on workspace {1}: {2}", - user.getName(), - workspaceName, - grant.isAdmin()); + /** + * Overrides the default {@link ResourceAccessManager#isWorkspaceAdmin} to use the more + * efficient {@link AccessSummary#hasAdminRightsToAnyWorkspace()}. {@link AccessSummary} is + * obtained through {@link AuthorizationService#getUserAccessSummary(AccessSummaryRequest)} and + * provides a quick view of adminable workspaces and which layers can be seen. + * + * @see AuthorizationService#getUserAccessSummary(AccessSummaryRequest) + * @see #getSecurityFilter(Authentication, Class) + * @apiNote this method's {@code @Override} annotation is commented out while the GeoServer + * maintenance version is on the {@code 2.24.x} series, for the {@code + * ACLResourceAccessManager} to keep working and building against it. Add it back once the + * GeoServer maintenance version moves to the {@code 2.25.x} series. + */ + // @Override + public boolean isWorkspaceAdmin(Authentication user, Catalog catalog) { + AccessSummary accessSumary = getAccessSummary(user); + // revisit: catalog is unsused in this implementation, but maybe verify at least + // one of the workspaces in AccessSummary exists in catalog + return accessSumary.hasAdminRightsToAnyWorkspace(); + } - return grant.isAdmin(); + /** We expect the user not to be null and not to be admin */ + private boolean isWorkspaceAdmin(Authentication user, WorkspaceInfo workspace) { + String workspaceName = workspace.getName(); + AccessSummary accessSummary = getAccessSummary(user); + return accessSummary.hasAdminWriteAccess(workspaceName); } @Override @@ -194,20 +216,23 @@ public StyleAccessLimits getAccessLimits(Authentication user, StyleInfo style) { return null; } + /** {@inheritDoc} */ @Override public LayerGroupAccessLimits getAccessLimits(Authentication user, LayerGroupInfo layerInfo) { return getAccessLimits(user, layerInfo, Collections.emptyList()); } + /** {@inheritDoc} */ @Override public DataAccessLimits getAccessLimits(Authentication user, LayerInfo layer) { - log(Level.FINE, "Getting access limits for Layer {0}", layer.getName()); + log(FINE, "Getting access limits for Layer {0}", layer.getName()); return getAccessLimits(user, layer, Collections.emptyList()); } + /** {@inheritDoc} */ @Override public DataAccessLimits getAccessLimits(Authentication user, ResourceInfo resource) { - log(Level.FINE, "Getting access limits for Resource {0}", resource.getName()); + log(FINE, "Getting access limits for Resource {0}", resource.getName()); // extract the user name String workspace = resource.getStore().getWorkspace().getName(); String layer = resource.getName(); @@ -215,6 +240,7 @@ public DataAccessLimits getAccessLimits(Authentication user, ResourceInfo resour getAccessLimits(user, resource, layer, workspace, Collections.emptyList()); } + /** {@inheritDoc} */ @Override public DataAccessLimits getAccessLimits( Authentication user, LayerInfo layer, List containers) { @@ -223,6 +249,7 @@ public DataAccessLimits getAccessLimits( return (DataAccessLimits) getAccessLimits(user, layer, layerName, workspace, containers); } + /** {@inheritDoc} */ @Override public LayerGroupAccessLimits getAccessLimits( Authentication user, LayerGroupInfo layerGroup, List containers) { @@ -233,6 +260,25 @@ public LayerGroupAccessLimits getAccessLimits( getAccessLimits(user, layerGroup, layer, workspace, containers); } + public AccessManagerConfig getConfig() { + return this.configProvider.get(); + } + + static boolean isAuthenticated(Authentication user) { + return (user != null) && !(user instanceof AnonymousAuthenticationToken); + } + + static boolean isAdmin(Authentication user) { + if (isAuthenticated(user)) { + return roles(user).anyMatch(GeoServerRole.ADMIN_ROLE.getAuthority()::equals); + } + return false; + } + + static Stream roles(Authentication user) { + return user.getAuthorities().stream().map(GrantedAuthority::getAuthority); + } + private AccessLimits getAccessLimits( Authentication user, CatalogInfo info, @@ -263,7 +309,7 @@ private AccessLimits getAccessLimits( boolean noneSingle = noneSingle(summaries); // all opaque we deny and don't perform any resolution of group limits. if (allOpaque) { - accessInfo = accessInfo.withGrant(GrantType.DENY); + accessInfo = accessInfo.withGrant(DENY); } else if (noneSingle) { // if a single group is present we don't apply any limit from containers. processingResult = @@ -272,8 +318,8 @@ private AccessLimits getAccessLimits( } } } else if (layerGroupsRequested) { - // layer is requested in context of a layer group, we need to process the containers - // limits. + // layer is requested in context of a layer group, we need to process the + // containers limits. processingResult = getContainerResolverResult(info, layer, workspace, user, containers, List.of()); } @@ -335,7 +381,7 @@ private ProcessingResult wpsProcessingResult( private AccessInfo getAccessInfo(AccessRequest accessRequest) { final Level timeLogLevel = FINE; final Stopwatch sw = LOGGER.isLoggable(timeLogLevel) ? Stopwatch.createStarted() : null; - AccessInfo accessInfo = aclService.getAccessInfo(accessRequest); + AccessInfo accessInfo = authorizationService.getAccessInfo(accessRequest); if (null != sw) { sw.stop(); log(timeLogLevel, "ACL auth run in {0}: {1} -> {2}", sw, accessRequest, accessInfo); @@ -487,9 +533,9 @@ private VectorAccessLimits buildVectorAccessLimits( Filter readFilter = toFilter(accessInfo.getGrant(), accessInfo.getCqlFilterRead()); Filter writeFilter = toFilter(accessInfo.getGrant(), accessInfo.getCqlFilterWrite()); if (intersectsArea != null) { - Filter areaFilter = FF.intersects(FF.property(""), FF.literal(intersectsArea)); + Filter areaFilter = intersects(intersectsArea); if (clipArea != null) { - Filter intersectClipArea = FF.intersects(FF.property(""), FF.literal(clipArea)); + Filter intersectClipArea = intersects(clipArea); areaFilter = FF.or(areaFilter, intersectClipArea); } readFilter = mergeFilter(readFilter, areaFilter); @@ -510,6 +556,10 @@ private VectorAccessLimits buildVectorAccessLimits( return accessLimits; } + private Intersects intersects(final Geometry intersectsArea) { + return FF.intersects(FF.property(""), FF.literal(intersectsArea)); + } + private Geometry resolveIntersectsArea( ResourceInfo info, AccessInfo accessInfo, ProcessingResult resultLimits) { @@ -545,7 +595,7 @@ private Filter toFilter(GrantType actualGrant, @Nullable String cqlFilter) { "Invalid cql filter found: " + e.getMessage(), e); } } - boolean includeFilter = actualGrant == GrantType.ALLOW || actualGrant == GrantType.LIMIT; + boolean includeFilter = actualGrant == ALLOW || actualGrant == LIMIT; return includeFilter ? Filter.INCLUDE : Filter.EXCLUDE; } @@ -557,7 +607,7 @@ LayerGroupAccessLimits buildLayerGroupAccessLimits(AccessInfo accessInfo) { GrantType grant = accessInfo.getGrant(); // the SecureCatalog will grant access to the layerGroup // if AccessLimits are null - if (grant.equals(GrantType.ALLOW) || grant.equals(GrantType.LIMIT)) { + if (grant.equals(ALLOW) || grant.equals(LIMIT)) { return null; // null == no-limits } CatalogMode catalogMode = convert(accessInfo.getCatalogMode()); @@ -575,7 +625,13 @@ private ProcessingResult getContainerResolverResult( AccessManagerConfig configuration = configProvider.get(); ContainerLimitResolver resolver = ContainerLimitResolver.of( - containers, summaries, aclService, user, layer, workspace, configuration); + containers, + summaries, + authorizationService, + user, + layer, + workspace, + configuration); ProcessingResult result = resolver.resolveResourceInGroupLimits(); Geometry intersect = result.getIntersectArea(); @@ -687,14 +743,15 @@ private List toPropertyNames( return result; } - @Override - public Filter getSecurityFilter(Authentication user, Class clazz) { - // resort to the in-process filter until we can provide a faster alternative - return super.getSecurityFilter(user, clazz); + private AccessSummary getAccessSummary(Authentication user) { + AccessSummaryRequest request = buildAccessSummaryRequest(user); + + return authorizationService.getUserAccessSummary(request); } - @Override - public int getPriority() { - return ExtensionPriority.LOWEST; + private AccessSummaryRequest buildAccessSummaryRequest(Authentication user) { + String username = user.getName(); + Set roles = roles(user).collect(Collectors.toSet()); + return AccessSummaryRequest.builder().user(username).roles(roles).build(); } } diff --git a/src/plugin/accessmanager/src/main/java/org/geoserver/acl/plugin/accessmanager/CatalogSecurityFilterBuilder.java b/src/plugin/accessmanager/src/main/java/org/geoserver/acl/plugin/accessmanager/CatalogSecurityFilterBuilder.java new file mode 100644 index 0000000..7ba0274 --- /dev/null +++ b/src/plugin/accessmanager/src/main/java/org/geoserver/acl/plugin/accessmanager/CatalogSecurityFilterBuilder.java @@ -0,0 +1,261 @@ +/* (c) 2024 Open Source Geospatial Foundation - all rights reserved + * This code is licensed under the GPL 2.0 license, available at the root + * application directory. + * + * Original from GeoServer 2.24-SNAPSHOT under GPL 2.0 license + */ +package org.geoserver.acl.plugin.accessmanager; + +import static org.geoserver.acl.authorization.WorkspaceAccessSummary.ANY; +import static org.geoserver.acl.authorization.WorkspaceAccessSummary.NO_WORKSPACE; +import static org.geoserver.catalog.Predicates.*; +import static org.geoserver.catalog.Predicates.and; +import static org.geoserver.catalog.Predicates.equal; +import static org.geoserver.catalog.Predicates.in; +import static org.geoserver.catalog.Predicates.isInstanceOf; +import static org.geoserver.catalog.Predicates.isNull; +import static org.geoserver.catalog.Predicates.not; +import static org.geoserver.catalog.Predicates.or; +import static org.geotools.api.filter.Filter.EXCLUDE; +import static org.geotools.api.filter.Filter.INCLUDE; + +import org.geoserver.acl.authorization.AccessSummary; +import org.geoserver.acl.authorization.WorkspaceAccessSummary; +import org.geoserver.catalog.CatalogInfo; +import org.geoserver.catalog.LayerGroupInfo; +import org.geoserver.catalog.LayerInfo; +import org.geoserver.catalog.NamespaceInfo; +import org.geoserver.catalog.PublishedInfo; +import org.geoserver.catalog.ResourceInfo; +import org.geoserver.catalog.StoreInfo; +import org.geoserver.catalog.StyleInfo; +import org.geoserver.catalog.WorkspaceInfo; +import org.geotools.api.filter.Filter; +import org.geotools.filter.visitor.SimplifyingFilterVisitor; +import org.springframework.lang.NonNull; +import org.springframework.util.Assert; + +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; + +/** + * @author Gabriel Roldan - Camptocamp + */ +public class CatalogSecurityFilterBuilder { + + private final AccessSummary viewables; + + public CatalogSecurityFilterBuilder(AccessSummary viewables) { + this.viewables = Objects.requireNonNull(viewables); + } + + public static Filter buildSecurityFilter( + AccessSummary viewables, Class infoType) { + return new CatalogSecurityFilterBuilder(viewables).build(infoType); + } + + @SuppressWarnings("unchecked") + public Filter build(Class clazz) { + Objects.requireNonNull(clazz); + if (viewables.getWorkspaces().isEmpty()) { + return EXCLUDE; + } + if (WorkspaceInfo.class.isAssignableFrom(clazz)) { + return workspaceNameFilter("name"); + } + if (NamespaceInfo.class.isAssignableFrom(clazz)) { + return workspaceNameFilter("prefix"); + } + if (StoreInfo.class.isAssignableFrom(clazz)) { + return workspaceNameFilter("workspace.name"); + } + if (ResourceInfo.class.isAssignableFrom(clazz)) { + return layerFilter("store.workspace.name", "name", ResourceInfo.class); + } + if (PublishedInfo.class.isAssignableFrom(clazz)) { + return publishedInfoFilter((Class) clazz); + } + if (StyleInfo.class.isAssignableFrom(clazz)) { + return styleFilter(); + } + throw new UnsupportedOperationException( + "Unknown CatalogInfo type: " + clazz.getCanonicalName()); + } + + private Filter styleFilter() { + return workspaceNameFilter("workspace.name"); + } + + private Filter publishedInfoFilter(Class clazz) { + if (LayerInfo.class.isAssignableFrom(clazz)) { + return layerFilter("resource.store.workspace.name", "name", LayerInfo.class); + } + if (LayerGroupInfo.class.isAssignableFrom(clazz)) { + return layerFilter("workspace.name", "name", LayerGroupInfo.class); + } + Filter layerInfoFilter = build(LayerInfo.class); + Filter layerGroupInfoFilter = build(LayerGroupInfo.class); + + Filter layerFilter = and(isInstanceOf(LayerInfo.class), layerInfoFilter); + Filter groupFilter = and(isInstanceOf(LayerGroupInfo.class), layerGroupInfoFilter); + + if (EXCLUDE.equals(layerInfoFilter)) { + return groupFilter; + } else if (EXCLUDE.equals(layerGroupInfoFilter)) { + return layerFilter; + } + + return or(layerFilter, groupFilter); + } + + private Filter layerFilter( + String workspaceProperty, String nameProperty, Class type) { + List summaries = viewables.getWorkspaces(); + + Filter filter = acceptNone(); + Set hideAllWorkspaceNames = new TreeSet<>(); + for (WorkspaceAccessSummary wsSummary : summaries) { + String workspace = wsSummary.getWorkspace(); + if (isHideAll(wsSummary)) { + hideAllWorkspaceNames.add(workspace); + } else { + boolean isNullWorkspace = NO_WORKSPACE.equals(workspace); + boolean supportsNullWorkspace = LayerGroupInfo.class.equals(type); + // ignore if workspace is null and type is LayerInfo or ResourceInfo + if (!isNullWorkspace || supportsNullWorkspace) { + Filter wsLayersFitler = + filterLayersOnWorkspace(wsSummary, workspaceProperty, nameProperty); + + if (EXCLUDE.equals(filter)) { + filter = wsLayersFitler; + } else { + filter = or(filter, wsLayersFitler); + } + } + } + } + filter = prependHideAllWorkspaces(filter, workspaceProperty, hideAllWorkspaceNames); + return SimplifyingFilterVisitor.simplify(filter); + } + + private Filter prependHideAllWorkspaces( + Filter filter, String workspaceProperty, Set hideAllWorkspaceNames) { + if (hideAllWorkspaceNames.isEmpty()) { + return filter; + } + Filter hiddenWorkspaces = denyWorkspacesFilter(workspaceProperty, hideAllWorkspaceNames); + if (INCLUDE.equals(filter)) { + return hiddenWorkspaces; + } + return and(hiddenWorkspaces, filter); + } + + private Filter denyWorkspacesFilter( + String workspaceProperty, Set hideAllWorkspaceNames) { + Assert.isTrue(!hideAllWorkspaceNames.isEmpty(), "hidden workspace names can't be empty"); + return notEqualOrIn(workspaceProperty, hideAllWorkspaceNames, EXCLUDE); + } + + private boolean isHideAll(WorkspaceAccessSummary ws) { + return ws.getAllowed().isEmpty() && ws.getForbidden().contains(ANY); + } + + @NonNull + private Filter filterLayersOnWorkspace( + WorkspaceAccessSummary vl, String workspaceProperty, String nameProperty) { + + final String workspace = vl.getWorkspace(); + final Set allowed = vl.getAllowed(); + final Set forbidden = vl.getForbidden(); + + Filter workspaceFilter = workspaceNameFilter(workspaceProperty, Set.of(workspace)); + Filter filter; + if (allowed.isEmpty() && forbidden.isEmpty()) { + filter = workspaceFilter; + } else { + Filter layerFilter = mergeLayers(nameProperty, allowed, forbidden); + filter = and(workspaceFilter, layerFilter); + } + return filter; + } + + private Filter mergeLayers(String nameProperty, Set allowed, Set forbidden) { + Filter allowFilter = equalOrIn(nameProperty, allowed, INCLUDE); + Filter hideFilter = notEqualOrIn(nameProperty, forbidden, EXCLUDE); + if (INCLUDE.equals(hideFilter)) { + return allowFilter; + } + if (INCLUDE.equals(allowFilter)) { + return hideFilter; + } + // neither is include, conflates to the allow filter + return allowFilter; + } + + /** + * @return {@link Filter#INCLUDE} if {@code names} is empty, {@code not(equalOrIn(nameProperty, + * names)} otherwise + */ + private Filter notEqualOrIn(String nameProperty, Set names, Filter defaultIfAny) { + if (names.isEmpty()) return INCLUDE; + if (names.contains(ANY)) return defaultIfAny; + + if (names.size() == 1) { + return notEqual(nameProperty, names.iterator().next()); + } + return not(equalOrIn(nameProperty, names, /* has no effect */ defaultIfAny)); + } + + /** + * @return {@link Filter#INCLUDE} if {@code names} is empty, {@code defaultIfAny} if {@code + * names} contains {@code *}, a "{@code name = names.get(0)}" filter if {@code names} has a + * single element, a "{@code name IN(names)}" filter if {@code names} has multiple elements. + */ + private Filter equalOrIn(String nameProperty, Set names, Filter defaultIfAny) { + if (names.isEmpty()) return INCLUDE; + if (names.contains(ANY)) return defaultIfAny; + if (names.size() == 1) { + return equal(nameProperty, names.iterator().next()); + } + return in(nameProperty, List.copyOf(names)); + } + + private Set getVisibleWorkspaces() { + return viewables.visibleWorkspaces(); + } + + private Filter workspaceNameFilter(String workspaceProperty) { + Set visibleWorkspaces = getVisibleWorkspaces(); + return workspaceNameFilter(workspaceProperty, visibleWorkspaces); + } + + private Filter workspaceNameFilter(String workspaceProperty, Set visibleWorkspaces) { + if (visibleWorkspaces.contains(ANY)) { + return acceptAll(); + } + Filter filter = acceptAll(); + if (visibleWorkspaces.contains(NO_WORKSPACE)) { + filter = isNull(workspaceProperty); + visibleWorkspaces = new HashSet<>(visibleWorkspaces); + visibleWorkspaces.remove(NO_WORKSPACE); + } + if (!visibleWorkspaces.isEmpty()) { + List workspaces = List.copyOf(visibleWorkspaces); + Filter namesFilter; + if (workspaces.size() == 1) { + namesFilter = equal(workspaceProperty, workspaces.get(0)); + } else { + namesFilter = in(workspaceProperty, workspaces); + } + if (INCLUDE.equals(filter)) { + filter = namesFilter; + } else { + filter = or(filter, namesFilter); + } + } + return filter; + } +} diff --git a/src/plugin/accessmanager/src/test/java/org/geoserver/acl/plugin/accessmanager/ACLResourceAccessManagerTest.java b/src/plugin/accessmanager/src/test/java/org/geoserver/acl/plugin/accessmanager/ACLResourceAccessManagerTest.java index e056fcc..dcfef2c 100644 --- a/src/plugin/accessmanager/src/test/java/org/geoserver/acl/plugin/accessmanager/ACLResourceAccessManagerTest.java +++ b/src/plugin/accessmanager/src/test/java/org/geoserver/acl/plugin/accessmanager/ACLResourceAccessManagerTest.java @@ -44,6 +44,7 @@ import org.locationtech.jts.io.WKTReader; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; public class ACLResourceAccessManagerTest extends AclBaseTest { @@ -181,6 +182,7 @@ public void testCiteLayerAccess() { @Test public void testWmsLimited() { Authentication user = getUser("wmsuser", "wmsuser", "ROLE_AUTHENTICATED"); + SecurityContextHolder.getContext().setAuthentication(user); // check layer in the sf workspace with a wfs request Request request = new Request(); diff --git a/src/plugin/accessmanager/src/test/java/org/geoserver/acl/plugin/accessmanager/CatalogSecurityFilterBuilderTest.java b/src/plugin/accessmanager/src/test/java/org/geoserver/acl/plugin/accessmanager/CatalogSecurityFilterBuilderTest.java new file mode 100644 index 0000000..82d33d1 --- /dev/null +++ b/src/plugin/accessmanager/src/test/java/org/geoserver/acl/plugin/accessmanager/CatalogSecurityFilterBuilderTest.java @@ -0,0 +1,533 @@ +/* (c) 2024 Open Source Geospatial Foundation - all rights reserved + * This code is licensed under the GPL 2.0 license, available at the root + * application directory. + * + * Original from GeoServer 2.24-SNAPSHOT under GPL 2.0 license + */ +package org.geoserver.acl.plugin.accessmanager; + +import static org.geoserver.acl.authorization.AccessSummary.of; +import static org.geoserver.acl.plugin.accessmanager.CatalogSecurityFilterBuilder.buildSecurityFilter; +import static org.geoserver.catalog.Predicates.*; +import static org.geoserver.catalog.Predicates.and; +import static org.geoserver.catalog.Predicates.in; +import static org.geoserver.catalog.Predicates.isInstanceOf; +import static org.geoserver.catalog.Predicates.isNull; +import static org.geoserver.catalog.Predicates.not; +import static org.geoserver.catalog.Predicates.or; +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import org.geoserver.acl.authorization.AccessSummary; +import org.geoserver.acl.authorization.WorkspaceAccessSummary; +import org.geoserver.acl.domain.adminrules.AdminGrantType; +import org.geoserver.catalog.CatalogInfo; +import org.geoserver.catalog.CoverageInfo; +import org.geoserver.catalog.CoverageStoreInfo; +import org.geoserver.catalog.DataStoreInfo; +import org.geoserver.catalog.FeatureTypeInfo; +import org.geoserver.catalog.LayerGroupInfo; +import org.geoserver.catalog.LayerInfo; +import org.geoserver.catalog.NamespaceInfo; +import org.geoserver.catalog.PublishedInfo; +import org.geoserver.catalog.ResourceInfo; +import org.geoserver.catalog.StoreInfo; +import org.geoserver.catalog.StyleInfo; +import org.geoserver.catalog.WMSLayerInfo; +import org.geoserver.catalog.WMSStoreInfo; +import org.geoserver.catalog.WMTSLayerInfo; +import org.geoserver.catalog.WMTSStoreInfo; +import org.geoserver.catalog.WorkspaceInfo; +import org.geotools.api.filter.Filter; +import org.junit.Test; + +import java.util.List; +import java.util.Set; + +public class CatalogSecurityFilterBuilderTest { + + @Test + public void unknownCatalogInfoArgument() { + AccessSummary accessSummary = of(workspace("cite")); + UnsupportedOperationException ex = + assertThrows( + UnsupportedOperationException.class, + () -> buildSecurityFilter(accessSummary, CatalogInfo.class)); + assertThat(ex.getMessage(), containsString("Unknown CatalogInfo type")); + } + + @Test + public void emptyAccessSummary() { + AccessSummary accessSummary = AccessSummary.of(List.of()); + Filter expected = Filter.EXCLUDE; + List.of( + WorkspaceInfo.class, + NamespaceInfo.class, + StoreInfo.class, + ResourceInfo.class, + PublishedInfo.class, + LayerInfo.class, + LayerGroupInfo.class, + StyleInfo.class) + .forEach(type -> assertEquals(expected, buildSecurityFilter(accessSummary, type))); + } + + @Test + public void workspaceInfoFilter() { + AccessSummary accessSummary = of(workspace("cite")); + Filter filter = buildSecurityFilter(accessSummary, WorkspaceInfo.class); + Filter expected = equal("name", "cite"); + assertEquals(expected, filter); + } + + @Test + public void workspaceInfoFilterMany() { + AccessSummary accessSummary = of(workspace("cite"), workspace("ne"), workspace("topp")); + Filter filter = buildSecurityFilter(accessSummary, WorkspaceInfo.class); + Filter expected = in("name", List.of("cite", "ne", "topp")); + assertEquals(expected, filter); + } + + @Test + public void workspaceInfoFilterManyContainsWildcard() { + AccessSummary accessSummary = + of(workspace("*"), builder("ne").adminAccess(AdminGrantType.ADMIN).build()); + Filter filter = buildSecurityFilter(accessSummary, WorkspaceInfo.class); + Filter expected = Filter.INCLUDE; + assertEquals(expected, filter); + } + + @Test + public void namespaceInfoFilter() { + AccessSummary accessSummary = of(workspace("cite")); + Filter filter = buildSecurityFilter(accessSummary, NamespaceInfo.class); + Filter expected = equal("prefix", "cite"); + assertEquals(expected, filter); + } + + @Test + public void namespaceInfoFilterMany() { + AccessSummary accessSummary = of(workspace("cite"), workspace("ne"), workspace("topp")); + Filter filter = buildSecurityFilter(accessSummary, NamespaceInfo.class); + Filter expected = in("prefix", List.of("cite", "ne", "topp")); + assertEquals(expected, filter); + } + + @Test + public void storeInfoFilter() { + AccessSummary accessSummary = of(workspace("cite")); + Filter expected = equal("workspace.name", "cite"); + List.of( + StoreInfo.class, + DataStoreInfo.class, + CoverageStoreInfo.class, + WMSStoreInfo.class, + WMTSStoreInfo.class) + .forEach(type -> assertEquals(expected, buildSecurityFilter(accessSummary, type))); + } + + @Test + public void storeInfoFilterMany() { + AccessSummary accessSummary = of(workspace("cite"), workspace("ne"), workspace("topp")); + Filter expected = in("workspace.name", List.of("cite", "ne", "topp")); + List.of( + StoreInfo.class, + DataStoreInfo.class, + CoverageStoreInfo.class, + WMSStoreInfo.class, + WMTSStoreInfo.class) + .forEach(type -> assertEquals(expected, buildSecurityFilter(accessSummary, type))); + } + + @Test + public void resourceInfoFilterWhenWorkspaceSummaryHasNoLayers() { + AccessSummary accessSummary = of(workspace("cite")); + Filter expected = equal("store.workspace.name", "cite"); + List.of( + ResourceInfo.class, + FeatureTypeInfo.class, + CoverageInfo.class, + WMSLayerInfo.class, + WMTSLayerInfo.class) + .forEach(type -> assertEquals(expected, buildSecurityFilter(accessSummary, type))); + } + + @Test + public void resourceInfoFilterManyWhenWorkspaceSummaryHasNoLayers() { + AccessSummary accessSummary = of(workspace("cite"), workspace("ne"), workspace("topp")); + Filter expected = + or( + equal("store.workspace.name", "cite"), + equal("store.workspace.name", "ne"), + equal("store.workspace.name", "topp")); + List.of( + ResourceInfo.class, + FeatureTypeInfo.class, + CoverageInfo.class, + WMSLayerInfo.class, + WMTSLayerInfo.class) + .forEach(type -> assertEquals(expected, buildSecurityFilter(accessSummary, type))); + } + + @Test + public void resourceInfoFilterSingleLayer() { + AccessSummary accessSummary = of(workspace("cite", "layer1")); + Filter expected = and(equal("store.workspace.name", "cite"), equal("name", "layer1")); + List.of( + ResourceInfo.class, + FeatureTypeInfo.class, + CoverageInfo.class, + WMSLayerInfo.class, + WMTSLayerInfo.class) + .forEach(type -> assertEquals(expected, buildSecurityFilter(accessSummary, type))); + } + + @Test + public void resourceInfoFilterMultipleLayers() { + AccessSummary accessSummary = of(workspace("cite", "layer1", "layer2")); + List layers = List.copyOf(accessSummary.workspace("cite").getAllowed()); + Filter expected = and(equal("store.workspace.name", "cite"), in("name", layers)); + assertEquals(expected, buildSecurityFilter(accessSummary, ResourceInfo.class)); + } + + @Test + public void resourceInfoFilterMultipleWorkspacesMultipleLayers() { + AccessSummary accessSummary = + of( // + workspace("cite", "layer1", "layer2"), // + workspace("ne", "layer3", "layer4"), // + workspace("topp") // + ); + + List citelayers = List.copyOf(accessSummary.workspace("cite").getAllowed()); + Filter citefilter = and(equal("store.workspace.name", "cite"), in("name", citelayers)); + + List nelayers = List.copyOf(accessSummary.workspace("ne").getAllowed()); + Filter nefilter = and(equal("store.workspace.name", "ne"), in("name", nelayers)); + + Filter toppfilter = equal("store.workspace.name", "topp"); + + Filter expected = or(citefilter, nefilter, toppfilter); + assertEquals(expected, buildSecurityFilter(accessSummary, ResourceInfo.class)); + } + + @Test + public void resourceInfoFilterMultipleLayersAndWildcard() { + AccessSummary accessSummary = of(workspace("cite", "layer1", "layer2", "*")); + Filter expected = equal("store.workspace.name", "cite"); + assertEquals(expected, buildSecurityFilter(accessSummary, ResourceInfo.class)); + } + + @Test + public void layerInfoFilter() { + AccessSummary accessSummary = of(workspace("cite", "layer1", "layer2")); + List layers = List.copyOf(accessSummary.workspace("cite").getAllowed()); + Filter expected = and(equal("resource.store.workspace.name", "cite"), in("name", layers)); + assertEquals(expected, buildSecurityFilter(accessSummary, LayerInfo.class)); + } + + @Test + public void layerInfoFilterAllVisibleAndSomeHiddenLayers() { + Set visible = Set.of("*"); + Set hidden = Set.of("hidden1", "hidden2"); + AccessSummary accessSummary = of(workspace("cite", visible, hidden)); + + List layers = List.copyOf(accessSummary.workspace("cite").getForbidden()); + assertEquals(hidden, Set.copyOf(layers)); + + // conflates to negating the hidden ones only + Filter expected = + and(equal("resource.store.workspace.name", "cite"), not(in("name", layers))); + + Filter actual = buildSecurityFilter(accessSummary, LayerInfo.class); + + assertEquals(expected, actual); + } + + @Test + public void layerInfoFilterAllHiddenAndSomeVisibleLayers() { + Set visible = Set.of("visible1", "visible2"); + Set hidden = Set.of("*"); + AccessSummary accessSummary = of(workspace("cite", visible, hidden)); + + List visibleNames = List.copyOf(accessSummary.workspace("cite").getAllowed()); + assertEquals(visible, Set.copyOf(visibleNames)); + + // conflates to visibles only + Filter expected = + and(equal("resource.store.workspace.name", "cite"), in("name", visibleNames)); + Filter actual = buildSecurityFilter(accessSummary, LayerInfo.class); + assertEquals(expected, actual); + } + + @Test + public void layerInfoFilterSomeVisibleAndSomeHiddenLayers() { + Set visible = Set.of("visible1", "visible2"); + Set hidden = Set.of("hidden1", "hidden2"); + AccessSummary accessSummary = of(workspace("cite", visible, hidden)); + + List visibleNames = List.copyOf(accessSummary.workspace("cite").getAllowed()); + assertEquals(visible, Set.copyOf(visibleNames)); + + // conflates to visibles only + Filter expected = + and(equal("resource.store.workspace.name", "cite"), in("name", visibleNames)); + + Filter actual = buildSecurityFilter(accessSummary, LayerInfo.class); + + assertEquals(expected, actual); + } + + @Test + public void layerInfoFilterAllowAllButOneWorkspace() { + AccessSummary accessSummary = + of( + // allow all + workspace("*", "*"), + // but hide all from cite + workspace("cite", Set.of(), Set.of("*"))); + + // conflates to hidding the cite workspace + Filter expected = notEqual("resource.store.workspace.name", "cite"); + + Filter actual = buildSecurityFilter(accessSummary, LayerInfo.class); + assertEquals(expected, actual); + + // same thing if the order is reversed + accessSummary = + of( + // hide all from cite + workspace("cite", Set.of(), Set.of("*")), + // allow everything else + workspace("*", "*")); + + actual = buildSecurityFilter(accessSummary, LayerInfo.class); + assertEquals(expected, actual); + } + + @Test + public void layerInfoFilterAllowAllButSomeWorkspaces() { + AccessSummary accessSummary = + of( + workspace("*", "*"), // allow all + // but hide all from cite and ne workspaces + workspace("cite", Set.of(), Set.of("*")), + workspace("ne", Set.of(), Set.of("*"))); + + // conflates to hidding the cite and ne workspaces + Filter expected = not(in("resource.store.workspace.name", List.of("cite", "ne"))); + + Filter actual = buildSecurityFilter(accessSummary, LayerInfo.class); + assertEquals(expected, actual); + } + + @Test + public void layerInfoFilterMultipleWorkspacesMultipleLayers() { + AccessSummary accessSummary = + of( // + workspace("cite", "layer1", "layer2"), // + workspace("ne", "layer3", "layer4"), // + workspace("topp") // + ); + + List citelayers = List.copyOf(accessSummary.workspace("cite").getAllowed()); + Filter citefilter = + and(equal("resource.store.workspace.name", "cite"), in("name", citelayers)); + + List nelayers = List.copyOf(accessSummary.workspace("ne").getAllowed()); + Filter nefilter = and(equal("resource.store.workspace.name", "ne"), in("name", nelayers)); + + Filter toppfilter = equal("resource.store.workspace.name", "topp"); + + Filter expected = or(citefilter, nefilter, toppfilter); + Filter actual = buildSecurityFilter(accessSummary, LayerInfo.class); + assertEquals(expected, actual); + } + + @Test + public void layerGroupInfoFilterNoWorkspace() { + AccessSummary accessSummary = of(workspace("", "lg1")); + Filter workspaceFilter = isNull("workspace.name"); + Filter nameFilter = equal("name", "lg1"); + Filter expected = and(workspaceFilter, nameFilter); + assertEquals(expected, buildSecurityFilter(accessSummary, LayerGroupInfo.class)); + } + + @Test + public void layerGroupInfoFilterNoWorkspaceAll() { + AccessSummary accessSummary = of(workspace("", "*")); + Filter workspaceFilter = isNull("workspace.name"); + assertEquals(workspaceFilter, buildSecurityFilter(accessSummary, LayerGroupInfo.class)); + } + + @Test + public void layerGroupInfoFilterNoWorkspaceMany() { + AccessSummary accessSummary = of(workspace("", "lg1", "lg2")); + List layers = List.copyOf(accessSummary.workspace("").getAllowed()); + Filter workspaceFilter = isNull("workspace.name"); + Filter nameFilter = in("name", layers); + Filter expected = and(workspaceFilter, nameFilter); + assertEquals(expected, buildSecurityFilter(accessSummary, LayerGroupInfo.class)); + } + + @Test + public void publishedInfoInfoFilterOnlyRootLayerGroups() { + AccessSummary accessSummary = of(workspace("", "lg1", "lg2")); + + List rootLgs = List.copyOf(accessSummary.workspace("").getAllowed()); + Filter rootLgFilters = and(isNull("workspace.name"), in("name", rootLgs)); + + Filter expected = and(isInstanceOf(LayerGroupInfo.class), rootLgFilters); + Filter actual = buildSecurityFilter(accessSummary, PublishedInfo.class); + + // workaround for IsInstanceOf function not implementing equals() + assertEquals(expected.toString(), actual.toString()); + } + + /** + * + * + *

    +     * 
    +     * [
    +     * 		[
    +     * 			[ IsInstanceOf(interface org.geoserver.catalog.LayerInfo) = true ]
    +     * 			AND [ resource.store.workspace.name = ne ] AND [ in([name], [populated_places], [world]) = true ]
    +     *      ]
    +     * 		OR
    +     *      [
    +     *      	[ IsInstanceOf(interface org.geoserver.catalog.LayerGroupInfo) = true ]
    +     *          AND [
    +     *          		[[ workspace.name IS NULL ] AND [ in([name], [lg2], [lg1]) = true ]]
    +     *          		OR
    +     *          		[[ workspace.name = ne ] AND [ in([name], [populated_places], [world]) = true ]]
    +     *          	]
    +     *      ]
    +     * ]
    +     * 
    +     * 
    + */ + @Test + public void publishedInfoInfoFilterMixingRootLayerGroupsAndWorkspaceLayerNames() { + AccessSummary accessSummary = + of(workspace("", "lg1", "lg2"), workspace("ne", "world", "populated_places")); + + List nelayers = List.copyOf(accessSummary.workspace("ne").getAllowed()); + + Filter layerInfoFilter = + and( + isInstanceOf(LayerInfo.class), + and(equal("resource.store.workspace.name", "ne"), in("name", nelayers))); + + List rootLgs = List.copyOf(accessSummary.workspace("").getAllowed()); + Filter rootLgFilters = and(isNull("workspace.name"), in("name", rootLgs)); + Filter workspaceLgFilters = and(equal("workspace.name", "ne"), in("name", nelayers)); + + Filter layerGroupInfoFilter = + and(isInstanceOf(LayerGroupInfo.class), or(rootLgFilters, workspaceLgFilters)); + + Filter expected = or(layerInfoFilter, layerGroupInfoFilter); + Filter actual = buildSecurityFilter(accessSummary, PublishedInfo.class); + // workaround for IsInstanceOf function not implementing equals() + assertEquals(expected.toString(), actual.toString()); + } + + @Test + public void publishedInfoInfoFilterMultipleWorkspaces() { + AccessSummary accessSummary = + of(workspace("ne", "world"), workspace("cite", "test"), workspace("topp", "*")); + + Filter layerFilter = + and( + isInstanceOf(LayerInfo.class), + or( + and( + equal("resource.store.workspace.name", "ne"), + equal("name", "world")), + and( + equal("resource.store.workspace.name", "cite"), + equal("name", "test")), + equal("resource.store.workspace.name", "topp"))); + + Filter layerGroupFilter = + and( + isInstanceOf(LayerGroupInfo.class), + or( + and(equal("workspace.name", "ne"), equal("name", "world")), + and(equal("workspace.name", "cite"), equal("name", "test")), + equal("workspace.name", "topp"))); + + Filter expected = or(layerFilter, layerGroupFilter); + Filter actual = buildSecurityFilter(accessSummary, PublishedInfo.class); + // workaround for IsInstanceOf function not implementing equals() + assertEquals(expected.toString(), actual.toString()); + } + + @Test + public void styleWorkspaceNullFilter() { + AccessSummary accessSummary = of(workspace("", "rootlg")); + + Filter expected = isNull("workspace.name"); + Filter actual = buildSecurityFilter(accessSummary, StyleInfo.class); + assertEquals(expected, actual); + } + + @Test + public void styleWorkspaceFilter() { + AccessSummary accessSummary = of(workspace("ne", "world")); + + Filter expected = equal("workspace.name", "ne"); + Filter actual = buildSecurityFilter(accessSummary, StyleInfo.class); + assertEquals(expected, actual); + } + + @Test + public void styleMultipleWorkspaceFilter() { + AccessSummary accessSummary = of(workspace("ne", "world"), workspace("cite", "*")); + + Filter expected = in("workspace.name", List.of("ne", "cite")); + Filter actual = buildSecurityFilter(accessSummary, StyleInfo.class); + assertEquals(expected, actual); + } + + @Test + public void styleHiddenWorkspaceFilter() { + + Set visible = Set.of("*"); + Set hidden = Set.of("hidden1", "hidden2"); + AccessSummary accessSummary = of(workspace("cite", visible, hidden)); + + Filter expected = equal("workspace.name", "cite"); + Filter actual = buildSecurityFilter(accessSummary, StyleInfo.class); + assertEquals( + "hidden layers do not affect workspace visibility for styles", expected, actual); + } + + @Test + public void styleMultipleWorkspaceAndNoWorkspaceFilter() { + AccessSummary accessSummary = + of(workspace("ne", "world"), workspace("cite", "*"), workspace("", "rootlg")); + + Filter expected = or(isNull("workspace.name"), in("workspace.name", List.of("ne", "cite"))); + Filter actual = buildSecurityFilter(accessSummary, StyleInfo.class); + assertEquals(expected, actual); + } + + private WorkspaceAccessSummary workspace(String workspace, String... visibleLayers) { + Set allowed = null == visibleLayers ? Set.of() : Set.of(visibleLayers); + return workspace(workspace, allowed, Set.of()); + } + + private WorkspaceAccessSummary workspace( + String workspace, Set visibleLayers, Set hiddenLayers) { + return builder(workspace).allowed(visibleLayers).forbidden(hiddenLayers).build(); + } + + private WorkspaceAccessSummary.Builder builder(String workspace) { + // adminGrant is irrelevant to build the filter + AdminGrantType adminGrant = AdminGrantType.USER; + return WorkspaceAccessSummary.builder().workspace(workspace).adminAccess(adminGrant); + } +}