-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Fast resolution of adminable workspaces and build improved security f…
…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
Showing
35 changed files
with
3,084 additions
and
114 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
105 changes: 105 additions & 0 deletions
105
...cation/authorization-api/src/main/java/org/geoserver/acl/authorization/AccessSummary.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
64 changes: 64 additions & 0 deletions
64
...authorization-api/src/main/java/org/geoserver/acl/authorization/AccessSummaryRequest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
128 changes: 128 additions & 0 deletions
128
...thorization-api/src/main/java/org/geoserver/acl/authorization/WorkspaceAccessSummary.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
46 changes: 46 additions & 0 deletions
46
...orization-api/src/test/java/org/geoserver/acl/authorization/AccessSummaryRequestTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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")); | ||
} | ||
} |
Oops, something went wrong.