ApiKeyScope.java
package net.hostsharing.hsadminng.config;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpMethod;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
/**
* Named endpoint-scopes for API-keys, the single source of truth mapping each scope name
* to its allowlist of HTTP-method+path patterns.
*
* The scope names are stored per API-key in rbac.api_key_scope and enforced fail-closed by
* the ApiKeyScopeEnforcementFilter: a scoped API-key may only call endpoints matched by at
* least one of its scopes. An API-key without scopes is unrestricted, limited only by the
* roles granted to its subject.
*
* Each enum constant must have a counterpart with the same wire-name in the generated
* ApiKeyScopeResource enum, i.e. as an enum value in the OpenAPI YAML. The constant names
* may differ, e.g. for `*:read` the generator strips the `*`.
*/
public enum ApiKeyScope {
RBAC_SUBJECT_SYNC(
"rbac.subjects:sync",
endpoint(HttpMethod.GET, "/api/rbac/subjects"),
endpoint(HttpMethod.GET, "/api/rbac/subjects/{uuid}"),
// deliberately without .../{uuid}/permissions, which is not needed by the sync
endpoint(HttpMethod.PUT, "/api/rbac/subjects/{uuid}"),
// without POST /api/rbac/subjects - as it would allow creation of unrestricted API-Keys
// without DELETE /api/rbac/subjects/{uuid} - the sync deactivates via PUT with deactivated=true;
// together with the PUT rejection of type API_KEY, a sync-key cannot touch API-keys at all
// lets the sync post its end-of-run report (GET .../last-sync is public, hence not listed)
endpoint(HttpMethod.POST, "/api/rbac/subjects/sync-report")
),
// read-only API-keys: any GET endpoint, but nothing which changes data
ANY_READ(
"*:read",
endpoint(HttpMethod.GET, "/api/**"));
private record Endpoint(HttpMethod method, String pathPattern, RequestMatcher matcher) {
String display() {
return method + " " + pathPattern;
}
}
private final String wireName;
private final List<Endpoint> endpoints;
ApiKeyScope(final String wireName, final Endpoint... endpoints) {
this.wireName = wireName;
this.endpoints = List.of(endpoints);
}
public String wireName() {
return wireName;
}
public boolean allows(final HttpServletRequest request) {
return endpoints.stream().anyMatch(endpoint -> endpoint.matcher().matches(request));
}
/** The allowed endpoints as human-readable HTTP-method and path pattern, e.g. for GET /api/rbac/scopes. */
public List<String> allowedEndpoints() {
return endpoints.stream().map(Endpoint::display).toList();
}
// unknown wire-names, e.g. from a DB row of a removed scope, resolve to empty and thus never match
public static Optional<ApiKeyScope> forWireName(final String wireName) {
return Arrays.stream(values()).filter(scope -> scope.wireName.equals(wireName)).findFirst();
}
private static Endpoint endpoint(final HttpMethod method, final String pathPattern) {
return new Endpoint(method, pathPattern, PathPatternRequestMatcher.withDefaults().matcher(method, pathPattern));
}
}