Skip to content

Commit

Permalink
Fast resolution of adminable workspaces and build improved security f…
Browse files Browse the repository at this point in the history
…ilter

Introduce a new API to obtain a quick summary of adminable workspaces and visible layers.

The workspace and layers visibility summary serves two purposes:

* Allows the `ResourceAccessManager.isWorkspaceAdmin(Authentication, Catalog)`
  implementation to quickly resolve whether the user is a workspace admin.
* Allows the `ResourceAccessManager.getSecurityFilter(Authentication, Class<? extends CatalogInfo>)`
  implementation to build a Filter that can be translated to the Catalog
  and eventually to the catalog backend (e.g. a database)
  • Loading branch information
groldan committed Aug 3, 2024
1 parent c0b4568 commit 52d4555
Show file tree
Hide file tree
Showing 35 changed files with 3,084 additions and 114 deletions.
468 changes: 468 additions & 0 deletions docs/api/index.html

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions src/application/authorization-api/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,10 @@
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -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<String, WorkspaceAccessSummary> workspaceSummaries;

private AccessSummary(Map<String, WorkspaceAccessSummary> workspaceSummaries) {
this.workspaceSummaries = workspaceSummaries;
}

public static AccessSummary of(WorkspaceAccessSummary... workspaces) {
return AccessSummary.of(Arrays.asList(workspaces));
}

public static AccessSummary of(List<WorkspaceAccessSummary> workspaces) {
Map<String, WorkspaceAccessSummary> summaries = new LinkedHashMap<>();
workspaces.forEach(ws -> summaries.put(ws.getWorkspace(), ws));
return new AccessSummary(summaries);
}

public List<WorkspaceAccessSummary> 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<String> visibleWorkspaces() {
return workspaceSummaries.keySet();
}

public Set<String> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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<String> roles;

public static class Builder {
// Ignore squid:S1068, private field required for the lombok-generated build() method
@SuppressWarnings({"unused", "squid:S1068"})
private Set<String> roles = Set.of();

public Builder roles(String... roleNames) {
if (null == roleNames) return roles(Set.of());
return roles(Set.of(roleNames));
}

public Builder roles(Set<String> 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -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.
*
* <p>Use {@link #getAccessInfo(AccessRequest)} and {@link
* #getAdminAuthorization(AdminAccessRequest)} if you need the resulting coalesced access info.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<WorkspaceAccessSummary> {
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<String> 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.
*
* <p>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<String> 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<String> allowed = new TreeSet<>();
private Set<String> forbidden = new TreeSet<>();

public Builder allowed(@NonNull Set<String> layers) {
this.allowed = new TreeSet<>(layers);
forbidden.removeAll(layers);
return this;
}

public Builder forbidden(@NonNull Set<String> 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<String> allowedLayers = conflate(allowed);
Set<String> forbiddenLayers = conflate(forbidden);

return new WorkspaceAccessSummary(
workspace, adminAccess, allowedLayers, forbiddenLayers);
}

private static Set<String> conflate(Set<String> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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"));
}
}
Loading

0 comments on commit 52d4555

Please sign in to comment.