diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/DataScopeSelectionApplier.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/DataScopeSelectionApplier.java new file mode 100644 index 00000000..ad90b5cd --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/DataScopeSelectionApplier.java @@ -0,0 +1,71 @@ +package uk.co.compendiumdev.thingifier.adapter.http.apihandlers; + +import java.util.Optional; +import uk.co.compendiumdev.thingifier.api.http.ThingifierRequestContext; +import uk.co.compendiumdev.thingifier.api.response.ApiResponse; +import uk.co.compendiumdev.thingifier.api.security.DataScopeCreationPolicy; +import uk.co.compendiumdev.thingifier.api.security.ThingifierApiDataScopeSelection; +import uk.co.compendiumdev.thingifier.core.repository.ThingStore; + +/** + * Applies trusted data-scope selections to the shared request context. + * + *
Route auth and scoped sessions both use this so their scope-switching rules stay identical.
+ * The selection is trusted because caller code only passes decisions returned by an authenticator
+ * or scoped-session resolver.
+ */
+final class DataScopeSelectionApplier {
+
+ private final ThingifierApiRuntime runtime;
+
+ DataScopeSelectionApplier(final ThingifierApiRuntime runtime) {
+ this.runtime = runtime;
+ }
+
+ /**
+ * Switches the request context to the selected data scope.
+ *
+ * @param context active request context to update
+ * @param selection trusted data-scope selection
+ * @return error response when the selected scope cannot be resolved, otherwise null
+ */
+ ApiResponse apply(
+ final ThingifierRequestContext context,
+ final ThingifierApiDataScopeSelection selection) {
+ if (selection == null) {
+ return null;
+ }
+
+ if (requiresPreExistingScope(context, selection)) {
+ return ApiResponse.error404("Could not find data scope " + selection.dataScopeName());
+ }
+
+ final Optional The policy exists to keep session-like request values out of low-level hooks. Thingifier reads
+ * the configured credential source, asks trusted application code to validate it, and only then
+ * applies any returned data-scope selection to the shared request context.
+ */
+public final class ScopedSessionPolicyApplier {
+
+ private static final String COOKIE_HEADER = "Cookie";
+
+ private final ThingifierApiRuntime runtime;
+ private final DataScopeSelectionApplier dataScopeSelectionApplier;
+
+ /**
+ * Creates a scoped-session policy applier.
+ *
+ * @param runtime runtime services used to resolve routes, stores, and API spec policy
+ */
+ public ScopedSessionPolicyApplier(final ThingifierApiRuntime runtime) {
+ this.runtime = runtime;
+ this.dataScopeSelectionApplier = new DataScopeSelectionApplier(runtime);
+ }
+
+ /**
+ * Applies scoped-session policy for one route.
+ *
+ * @param verb generated API verb
+ * @param path request path
+ * @param context active request context to update
+ * @param route resolved route, or null to resolve here
+ * @param queryParams request query parameters
+ * @return rejection response when processing should stop, otherwise null
+ */
+ public ApiResponse rejectIfNotResolved(
+ final RoutingVerb verb,
+ final String path,
+ final ThingifierRequestContext context,
+ final ThingRoute route,
+ final QueryFilterParams queryParams) {
+ if (verb == null || context == null) {
+ return null;
+ }
+
+ final String apiPathPrefix = runtime.apiConfig().getApiEndPointPrefix();
+ final ThingRoute resolvedRoute = route == null ? runtime.routeFor(verb, path) : route;
+ final Optional Scoped-session resolution runs before explicit route auth so route auth can override the
+ * selected data scope when both are configured. Lifecycle-backed HTTP calls skip these gates
+ * because {@link ThingifierHttpApi} has already run them before request validation.
+ *
+ * @param verb routing verb used for route-rule lookup
+ * @param url generated API path
+ * @param context request context containing the active store
+ * @param lifecycle lifecycle context when called through HTTP processing, otherwise null
+ * @param request parsed request envelope, or null for older direct-call helpers
+ * @param queryParams query parameters available to scoped-session credential resolution
+ * @param action handler action to run when auth allows the request
+ * @return response after auth, response policy, and route callbacks have been applied
+ */
+ private ApiResponse withAuthorizedResponsePolicy(
+ final RoutingVerb verb,
+ final String url,
+ final ThingifierRequestContext context,
+ final ThingifierApiLifecycleContext lifecycle,
+ final ApiRequestEnvelope request,
+ final QueryFilterParams queryParams,
+ final Supplier Thingifier calls this only when the configured credential source is present. A returned
+ * authenticated result may select a data scope; an unauthenticated result means the presented
+ * credential is invalid and must reject in v1 rather than falling back to anonymous/default data.
+ */
+@FunctionalInterface
+public interface ThingifierApiScopedSessionAuthenticator {
+
+ /**
+ * Authenticates the presented session credential.
+ *
+ * @param context immutable request and credential context
+ * @return authentication result controlling principal, rejection, and optional data scope
+ */
+ ThingifierApiScopedSessionResult authenticate(ThingifierApiScopedSessionContext context);
+}
diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionContext.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionContext.java
new file mode 100644
index 00000000..206a7a6f
--- /dev/null
+++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionContext.java
@@ -0,0 +1,385 @@
+package uk.co.compendiumdev.thingifier.api.security;
+
+import java.util.HashMap;
+import java.util.Map;
+import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.ThingRoute;
+import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb;
+import uk.co.compendiumdev.thingifier.api.http.ThingifierRequestContext;
+import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeadersBlock;
+import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition;
+import uk.co.compendiumdev.thingifier.core.query.QueryFilterParams;
+import uk.co.compendiumdev.thingifier.core.repository.ThingStore;
+
+/**
+ * Immutable request context passed to a scoped-session resolver.
+ *
+ * The context exposes request facts and route target details so application code can validate a
+ * session credential before returning a trusted principal and data-scope selection. It deliberately
+ * does not treat the credential value itself as a store name.
+ */
+public final class ThingifierApiScopedSessionContext {
+
+ private final String sessionName;
+ private final String credential;
+ private final ThingifierApiScopedSessionCredentialSourceType credentialSourceType;
+ private final String credentialSourceName;
+ private final RoutingVerb verb;
+ private final String path;
+ private final Map Resolver code should treat this as read-only. Thingifier applies the final selected scope
+ * after the resolver returns so later validators, authorizers, and handlers are aligned.
+ *
+ * @return active request context before scoped-session selection is applied
+ */
+ public ThingifierRequestContext requestContext() {
+ return requestContext;
+ }
+
+ /**
+ * @return active data-scope name before this resolver result is applied
+ */
+ public String dataScopeName() {
+ return requestContext == null ? null : requestContext.dataScopeName();
+ }
+
+ /**
+ * @return active store before this resolver result is applied
+ */
+ public ThingStore store() {
+ return requestContext == null ? null : requestContext.store();
+ }
+
+ /**
+ * @return target entity for entity and relationship routes
+ */
+ public EntityDefinition targetEntity() {
+ return targetEntity;
+ }
+
+ /**
+ * @return target identifier for instance routes, or null for collection routes
+ */
+ public String targetIdentifier() {
+ return targetIdentifier;
+ }
+
+ /**
+ * @return parent entity for relationship routes, or null for entity routes
+ */
+ public EntityDefinition parentEntity() {
+ return parentEntity;
+ }
+
+ /**
+ * @return parent identifier for relationship routes, or null for entity routes
+ */
+ public String parentIdentifier() {
+ return parentIdentifier;
+ }
+
+ /**
+ * @return relationship name for relationship routes, or null for entity routes
+ */
+ public String relationshipName() {
+ return relationshipName;
+ }
+
+ /**
+ * @return child identifier for relationship instance routes, or null when absent
+ */
+ public String childIdentifier() {
+ return childIdentifier;
+ }
+
+ private static HttpHeadersBlock copyHeaders(final HttpHeadersBlock original) {
+ HttpHeadersBlock copy = new HttpHeadersBlock();
+ if (original != null) {
+ copy.putAll(original);
+ }
+ return copy;
+ }
+
+ private static QueryFilterParams copyQueryParams(final QueryFilterParams original) {
+ QueryFilterParams copy = new QueryFilterParams();
+ copy.addAll(original);
+ return copy;
+ }
+
+ /** Builder used by runtime policy to assemble immutable resolver context. */
+ public static final class Builder {
+ private String sessionName;
+ private String credential;
+ private ThingifierApiScopedSessionCredentialSourceType credentialSourceType;
+ private String credentialSourceName;
+ private RoutingVerb verb;
+ private String path;
+ private Map Scoped sessions are deliberately separate from the legacy Thingifier data-scope header:
+ * request input is treated as a credential first, and only trusted application resolver code may
+ * turn it into an active data scope.
+ */
+public enum ThingifierApiScopedSessionCredentialSourceType {
+ /** Credential is read from an HTTP request header. */
+ HEADER,
+
+ /** Credential is read from a URL query parameter. */
+ QUERY_PARAM,
+
+ /** Credential is read from a named value in the Cookie request header. */
+ COOKIE
+}
diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionDefinition.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionDefinition.java
new file mode 100644
index 00000000..9846e087
--- /dev/null
+++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionDefinition.java
@@ -0,0 +1,218 @@
+package uk.co.compendiumdev.thingifier.api.security;
+
+import uk.co.compendiumdev.thingifier.api.response.ApiResponse;
+
+/**
+ * Defines a named scoped-session resolver for an API contract.
+ *
+ * A scoped session is for credentials such as session ids, challenge ids, tenant tokens, or
+ * other application-owned values that need validation before they may select a Thingifier data
+ * scope. It is not a direct "database from header" mapping.
+ */
+public final class ThingifierApiScopedSessionDefinition {
+
+ private final String name;
+ private ThingifierApiScopedSessionCredentialSourceType credentialSourceType;
+ private String credentialSourceName;
+ private ThingifierApiScopedSessionAuthenticator authenticator;
+ private boolean anonymousDefaultScopeForReads;
+ private boolean authenticatedScopeForWrites;
+ private int missingCredentialStatusCode;
+ private String missingCredentialMessage;
+ private int invalidCredentialStatusCode;
+ private String invalidCredentialMessage;
+
+ /**
+ * Creates a named scoped-session definition.
+ *
+ * @param name public scoped-session name used by route rules and principal lookup
+ */
+ public ThingifierApiScopedSessionDefinition(final String name) {
+ this.name = SecuritySchemeNames.requireValid(name);
+ this.missingCredentialStatusCode = 401;
+ this.missingCredentialMessage = "Unauthorized";
+ this.invalidCredentialStatusCode = 401;
+ this.invalidCredentialMessage = "Unauthorized";
+ }
+
+ /**
+ * Reads the session credential from an HTTP header.
+ *
+ * @param headerName request header carrying the scoped-session credential
+ * @return this definition for fluent configuration
+ */
+ public ThingifierApiScopedSessionDefinition fromHeader(final String headerName) {
+ this.credentialSourceType = ThingifierApiScopedSessionCredentialSourceType.HEADER;
+ this.credentialSourceName = SecuritySchemeNames.requireValidHeaderName(headerName);
+ return this;
+ }
+
+ /**
+ * Reads the session credential from a URL query parameter.
+ *
+ * @param queryParamName query parameter carrying the scoped-session credential
+ * @return this definition for fluent configuration
+ */
+ public ThingifierApiScopedSessionDefinition fromQueryParam(final String queryParamName) {
+ this.credentialSourceType = ThingifierApiScopedSessionCredentialSourceType.QUERY_PARAM;
+ this.credentialSourceName = requireName(queryParamName, "query parameter name");
+ return this;
+ }
+
+ /**
+ * Reads the session credential from a named cookie.
+ *
+ * The HTTP adapter reads cookies from the normal {@code Cookie} request header, keeping the
+ * public API independent of a particular server framework.
+ *
+ * @param cookieName cookie carrying the scoped-session credential
+ * @return this definition for fluent configuration
+ */
+ public ThingifierApiScopedSessionDefinition fromCookie(final String cookieName) {
+ this.credentialSourceType = ThingifierApiScopedSessionCredentialSourceType.COOKIE;
+ this.credentialSourceName = requireName(cookieName, "cookie name");
+ return this;
+ }
+
+ /**
+ * Registers the trusted resolver for this session credential.
+ *
+ * @param authenticator callback that validates the credential and may select a data scope
+ * @return this definition for fluent configuration
+ */
+ public ThingifierApiScopedSessionDefinition authenticateWith(
+ final ThingifierApiScopedSessionAuthenticator authenticator) {
+ if (authenticator == null) {
+ throw new IllegalArgumentException("scoped-session authenticator is required");
+ }
+ this.authenticator = authenticator;
+ return this;
+ }
+
+ /**
+ * Allows read-style generated routes to use the default data scope when no credential is
+ * supplied.
+ *
+ * If a credential is present, Thingifier validates it. Invalid credentials reject in v1 even
+ * for anonymous-readable routes.
+ *
+ * @return this definition for fluent configuration
+ */
+ public ThingifierApiScopedSessionDefinition allowAnonymousDefaultScopeForReads() {
+ this.anonymousDefaultScopeForReads = true;
+ return this;
+ }
+
+ /**
+ * Requires write-style generated routes to have a valid scoped-session credential.
+ *
+ * @return this definition for fluent configuration
+ */
+ public ThingifierApiScopedSessionDefinition requireAuthenticatedScopeForWrites() {
+ this.authenticatedScopeForWrites = true;
+ return this;
+ }
+
+ /**
+ * Configures the response used when a route requires a scoped session and no credential is
+ * supplied.
+ *
+ * @param statusCode response status code
+ * @param message response error message
+ * @return this definition for fluent configuration
+ */
+ public ThingifierApiScopedSessionDefinition onMissingRequiredCredential(
+ final int statusCode, final String message) {
+ this.missingCredentialStatusCode = statusCode;
+ this.missingCredentialMessage = message == null ? "" : message;
+ return this;
+ }
+
+ /**
+ * Configures the response used when a resolver rejects a supplied credential as invalid.
+ *
+ * @param statusCode response status code
+ * @param message response error message
+ * @return this definition for fluent configuration
+ */
+ public ThingifierApiScopedSessionDefinition onInvalidCredential(
+ final int statusCode, final String message) {
+ this.invalidCredentialStatusCode = statusCode;
+ this.invalidCredentialMessage = message == null ? "" : message;
+ return this;
+ }
+
+ /**
+ * @return scoped-session definition name
+ */
+ public String name() {
+ return name;
+ }
+
+ /**
+ * @return configured credential source type, or null when no source is configured
+ */
+ public ThingifierApiScopedSessionCredentialSourceType credentialSourceType() {
+ return credentialSourceType;
+ }
+
+ /**
+ * @return configured credential source name, or null when no source is configured
+ */
+ public String credentialSourceName() {
+ return credentialSourceName;
+ }
+
+ /**
+ * @return configured resolver, or null when none has been registered
+ */
+ public ThingifierApiScopedSessionAuthenticator authenticator() {
+ return authenticator;
+ }
+
+ /**
+ * @return true when read-style routes may fall back to the default scope
+ */
+ public boolean allowsAnonymousDefaultScopeForReads() {
+ return anonymousDefaultScopeForReads;
+ }
+
+ /**
+ * @return true when write-style routes require a valid scoped session
+ */
+ public boolean requiresAuthenticatedScopeForWrites() {
+ return authenticatedScopeForWrites;
+ }
+
+ /**
+ * @return configured missing-credential response
+ */
+ public ApiResponse missingRequiredCredentialResponse() {
+ return ApiResponse.error(missingCredentialStatusCode, missingCredentialMessage);
+ }
+
+ /**
+ * @return configured invalid-credential response
+ */
+ public ApiResponse invalidCredentialResponse() {
+ return ApiResponse.error(invalidCredentialStatusCode, invalidCredentialMessage);
+ }
+
+ /**
+ * Reports whether the definition has enough information to read a credential.
+ *
+ * @return true when a credential source type and name have been configured
+ */
+ public boolean hasCredentialSource() {
+ return credentialSourceType != null
+ && credentialSourceName != null
+ && !credentialSourceName.trim().isEmpty();
+ }
+
+ private String requireName(final String value, final String label) {
+ if (value == null || value.trim().isEmpty()) {
+ throw new IllegalArgumentException(label + " is required");
+ }
+ return value.trim();
+ }
+}
diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionPolicy.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionPolicy.java
new file mode 100644
index 00000000..8ae23541
--- /dev/null
+++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionPolicy.java
@@ -0,0 +1,104 @@
+package uk.co.compendiumdev.thingifier.api.security;
+
+import java.util.Optional;
+
+/**
+ * Resolved scoped-session policy for one request route.
+ *
+ * Route rules and contract-level read/write shortcuts are stored separately in the API spec.
+ * This object gives runtime handling one simple decision: optional anonymous default scope or
+ * required authenticated scoped session, plus the definition that should resolve credentials.
+ */
+public final class ThingifierApiScopedSessionPolicy {
+
+ /** How a matched route should treat missing scoped-session credentials. */
+ public enum Mode {
+ /** Missing credentials use the default scope, while invalid supplied credentials reject. */
+ ALLOW_ANONYMOUS_DEFAULT_SCOPE,
+
+ /** Missing or invalid credentials reject before validators and handlers run. */
+ REQUIRE_AUTHENTICATED_SCOPE
+ }
+
+ private final Mode mode;
+ private final String sessionName;
+ private final ThingifierApiScopedSessionDefinition definition;
+
+ private ThingifierApiScopedSessionPolicy(
+ final Mode mode,
+ final String sessionName,
+ final ThingifierApiScopedSessionDefinition definition) {
+ this.mode = mode;
+ this.sessionName = sessionName;
+ this.definition = definition;
+ }
+
+ /**
+ * Creates a policy backed by a configured scoped-session definition.
+ *
+ * @param definition definition used to resolve credentials
+ * @param mode missing-credential behaviour
+ * @return route scoped-session policy
+ */
+ public static ThingifierApiScopedSessionPolicy configured(
+ final ThingifierApiScopedSessionDefinition definition, final Mode mode) {
+ if (definition == null) {
+ throw new IllegalArgumentException("scoped-session definition is required");
+ }
+ if (mode == null) {
+ throw new IllegalArgumentException("scoped-session mode is required");
+ }
+ return new ThingifierApiScopedSessionPolicy(mode, definition.name(), definition);
+ }
+
+ /**
+ * Creates a policy that points at a missing definition.
+ *
+ * Runtime returns a configuration error instead of silently allowing a route that was
+ * declared to require scoped-session handling.
+ *
+ * @param sessionName requested scoped-session name
+ * @param mode missing-credential behaviour requested by the route
+ * @return unresolved route scoped-session policy
+ */
+ public static ThingifierApiScopedSessionPolicy unresolved(
+ final String sessionName, final Mode mode) {
+ return new ThingifierApiScopedSessionPolicy(
+ mode, SecuritySchemeNames.requireValid(sessionName), null);
+ }
+
+ /**
+ * @return missing-credential behaviour for the route
+ */
+ public Mode mode() {
+ return mode;
+ }
+
+ /**
+ * @return scoped-session name used for principal lookup
+ */
+ public String sessionName() {
+ return sessionName;
+ }
+
+ /**
+ * @return configured definition, or empty when the route references a missing definition
+ */
+ public Optional The result models the trust boundary for session-like credentials. A credential supplied by
+ * the client is not a data-scope name; it becomes a data-scope decision only when application code
+ * returns an authenticated result and selects a scope.
+ */
+public final class ThingifierApiScopedSessionResult {
+
+ private final boolean authenticated;
+ private final Object principal;
+ private final ApiResponse rejectionResponse;
+ private final boolean customRejectionResponse;
+ private final ThingifierApiDataScopeSelection dataScopeSelection;
+
+ private ThingifierApiScopedSessionResult(
+ final boolean authenticated,
+ final Object principal,
+ final ApiResponse rejectionResponse,
+ final boolean customRejectionResponse,
+ final ThingifierApiDataScopeSelection dataScopeSelection) {
+ this.authenticated = authenticated;
+ this.principal = principal;
+ this.rejectionResponse = rejectionResponse;
+ this.customRejectionResponse = customRejectionResponse;
+ this.dataScopeSelection = dataScopeSelection;
+ }
+
+ /**
+ * Creates a successful scoped-session result.
+ *
+ * @param principal application principal, session record, user id, or other caller-owned object
+ * @return successful scoped-session result
+ */
+ public static ThingifierApiScopedSessionResult authenticated(final Object principal) {
+ return new ThingifierApiScopedSessionResult(true, principal, null, false, null);
+ }
+
+ /**
+ * Creates a successful scoped-session result without a principal object.
+ *
+ * @return successful scoped-session result
+ */
+ public static ThingifierApiScopedSessionResult authenticated() {
+ return authenticated(null);
+ }
+
+ /**
+ * Creates an invalid-credential result.
+ *
+ * Thingifier uses the scoped-session definition's invalid-credential response for this. It
+ * is intentionally distinct from a missing credential, which Thingifier detects before calling
+ * the resolver.
+ *
+ * @return invalid scoped-session result
+ */
+ public static ThingifierApiScopedSessionResult unauthenticated() {
+ return new ThingifierApiScopedSessionResult(false, null, null, false, null);
+ }
+
+ /**
+ * Creates a scoped-session rejection with a 401 status and message.
+ *
+ * @param message message to render in the response body
+ * @return rejected scoped-session result
+ */
+ public static ThingifierApiScopedSessionResult rejected(final String message) {
+ return rejected(401, message);
+ }
+
+ /**
+ * Creates a scoped-session rejection with a status and message.
+ *
+ * @param status status code to return
+ * @param message message to render in the response body
+ * @return rejected scoped-session result
+ */
+ public static ThingifierApiScopedSessionResult rejected(
+ final int status, final String message) {
+ return new ThingifierApiScopedSessionResult(
+ false, null, ApiResponse.error(status, message), false, null);
+ }
+
+ /**
+ * Creates a scoped-session rejection with a complete response.
+ *
+ * Use this when the application needs full control over the rejection response. Thingifier
+ * returns it as supplied.
+ *
+ * @param response response to return instead of continuing request processing
+ * @return rejected scoped-session result
+ */
+ public static ThingifierApiScopedSessionResult rejected(final ApiResponse response) {
+ return new ThingifierApiScopedSessionResult(false, null, response, true, null);
+ }
+
+ /**
+ * Selects a named data scope for the authenticated request.
+ *
+ * @param dataScopeName data scope chosen by trusted scoped-session resolver code
+ * @return new result with a data-scope selection
+ * @throws IllegalStateException when called on an unauthenticated/rejected result
+ */
+ public ThingifierApiScopedSessionResult useDataScope(final String dataScopeName) {
+ return useDataScope(dataScopeName, DataScopeCreationPolicy.USE_EXISTING_ONLY);
+ }
+
+ /**
+ * Selects a named data scope and missing-scope policy for the authenticated request.
+ *
+ * @param dataScopeName data scope chosen by trusted scoped-session resolver code
+ * @param creationPolicy policy used when the scope does not exist
+ * @return new result with a data-scope selection
+ * @throws IllegalStateException when called on an unauthenticated/rejected result
+ */
+ public ThingifierApiScopedSessionResult useDataScope(
+ final String dataScopeName, final DataScopeCreationPolicy creationPolicy) {
+ requireAuthenticatedForDataScopeSelection();
+ return withDataScopeSelection(
+ ThingifierApiDataScopeSelection.named(dataScopeName, creationPolicy));
+ }
+
+ /**
+ * Explicitly selects the model's default data scope for the authenticated request.
+ *
+ * No data-scope selection preserves the current request context; this method deliberately
+ * overrides any header/session-selected scope with the default scope.
+ *
+ * @return new result selecting the default data scope
+ * @throws IllegalStateException when called on an unauthenticated/rejected result
+ */
+ public ThingifierApiScopedSessionResult useDefaultDataScope() {
+ requireAuthenticatedForDataScopeSelection();
+ return withDataScopeSelection(ThingifierApiDataScopeSelection.defaultDataScope());
+ }
+
+ /**
+ * Reports whether the session credential was accepted.
+ *
+ * @return true when the principal may be trusted
+ */
+ public boolean isAuthenticated() {
+ return authenticated;
+ }
+
+ /**
+ * Returns the application principal supplied by the resolver.
+ *
+ * @return principal object, or null when the resolver did not need one
+ */
+ public Object principal() {
+ return principal;
+ }
+
+ /**
+ * Returns the rejection response for an explicit scoped-session failure.
+ *
+ * @return API response to return, or null when default invalid handling should be used
+ */
+ public ApiResponse rejectionResponse() {
+ return rejectionResponse;
+ }
+
+ /**
+ * Reports whether the rejection response was supplied directly by application code.
+ *
+ * @return true when Thingifier should preserve the response exactly
+ */
+ public boolean hasCustomRejectionResponse() {
+ return customRejectionResponse;
+ }
+
+ /**
+ * Returns the data scope selected by trusted resolver code.
+ *
+ * @return selected data scope, or empty when existing request-context behaviour should remain
+ */
+ public Optional These values are resolved at runtime with contract-level defaults. Keeping the route
+ * setting explicit avoids treating arbitrary request headers as data-scope selectors unless the
+ * route has opted into trusted scoped-session resolution.
+ */
+ public enum ScopedSessionMode {
+ /** Use the API contract's read/write scoped-session defaults. */
+ INHERIT,
+
+ /** Missing credentials use the default scope, while invalid supplied credentials reject. */
+ ALLOW_ANONYMOUS_DEFAULT_SCOPE,
+
+ /** Missing or invalid credentials reject before validators and handlers run. */
+ REQUIRE_AUTHENTICATED_SCOPE,
+
+ /** Do not apply scoped-session resolution on this route. */
+ DISABLED
+ }
+
/**
* Returns the HTTP-style verb this rule applies to.
*
@@ -337,7 +362,8 @@ public ThingifierApiRouteRule secureWithAnyOf(final String... schemeNames) {
/**
* Adds a route-specific authorization callback.
*
- * Authorizers run only after the named authenticator accepts the route's credential.
+ * Authorizers run only after a trusted credential gate has accepted the request, either a
+ * named route authenticator or a scoped-session resolver on routes without explicit route auth.
* Multiple authorizers are evaluated in registration order and the first rejection stops the
* request.
*
@@ -353,6 +379,81 @@ public ThingifierApiRouteRule authorizeWith(final ThingifierApiAuthorizer author
return this;
}
+ /**
+ * Allows this route to use the default data scope when the scoped-session credential is absent.
+ *
+ * If the credential is present, the configured resolver still validates it and invalid
+ * credentials reject. This is intended for anonymous read-style routes where default data is
+ * safe, without weakening the rule for bad supplied credentials.
+ *
+ * @return this rule so route API configuration can be chained
+ */
+ public ThingifierApiRouteRule allowAnonymousUsingDefaultScope() {
+ scopedSessionMode = ScopedSessionMode.ALLOW_ANONYMOUS_DEFAULT_SCOPE;
+ scopedSessionName = null;
+ return this;
+ }
+
+ /**
+ * Allows this route to use the default data scope when one named scoped-session credential is
+ * absent.
+ *
+ * @param sessionName named scoped-session definition to use when a credential is supplied
+ * @return this rule so route API configuration can be chained
+ */
+ public ThingifierApiRouteRule allowAnonymousUsingDefaultScope(final String sessionName) {
+ scopedSessionMode = ScopedSessionMode.ALLOW_ANONYMOUS_DEFAULT_SCOPE;
+ scopedSessionName = SecuritySchemeNames.requireValid(sessionName);
+ return this;
+ }
+
+ /**
+ * Requires a valid scoped-session credential for this route.
+ *
+ * The resolver may select a data scope and principal. Missing credentials reject before the
+ * resolver is called; supplied credentials that resolve to unauthenticated reject as invalid.
+ *
+ * @param sessionName named scoped-session definition
+ * @return this rule so route API configuration can be chained
+ */
+ public ThingifierApiRouteRule requireScopedSession(final String sessionName) {
+ scopedSessionMode = ScopedSessionMode.REQUIRE_AUTHENTICATED_SCOPE;
+ scopedSessionName = SecuritySchemeNames.requireValid(sessionName);
+ return this;
+ }
+
+ /**
+ * Disables scoped-session resolution for this route.
+ *
+ * Use this when contract-level read/write defaults exist but a specific generated or fixed
+ * route should keep the historical request-context behaviour.
+ *
+ * @return this rule so route API configuration can be chained
+ */
+ public ThingifierApiRouteRule disableScopedSession() {
+ scopedSessionMode = ScopedSessionMode.DISABLED;
+ scopedSessionName = null;
+ return this;
+ }
+
+ /**
+ * Returns the route's scoped-session override.
+ *
+ * @return scoped-session mode, defaulting to {@link ScopedSessionMode#INHERIT}
+ */
+ public ScopedSessionMode scopedSessionMode() {
+ return scopedSessionMode;
+ }
+
+ /**
+ * Returns the scoped-session definition explicitly selected by this route.
+ *
+ * @return named scoped session, or null when the API contract default should be used
+ */
+ public String scopedSessionName() {
+ return scopedSessionName;
+ }
+
/**
* Reports whether this route is documented as bearer secured.
*
diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiSpec.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiSpec.java
index 038d2332..cd323c69 100644
--- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiSpec.java
+++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiSpec.java
@@ -4,6 +4,7 @@
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
@@ -17,6 +18,10 @@
import uk.co.compendiumdev.thingifier.api.response.ResponseHeader;
import uk.co.compendiumdev.thingifier.api.security.SecuritySchemeNames;
import uk.co.compendiumdev.thingifier.api.security.ThingifierApiAuthenticator;
+import uk.co.compendiumdev.thingifier.api.security.ThingifierApiScopedSessionCredentialSourceType;
+import uk.co.compendiumdev.thingifier.api.security.ThingifierApiScopedSessionDefinition;
+import uk.co.compendiumdev.thingifier.api.security.ThingifierApiScopedSessionPolicy;
+import uk.co.compendiumdev.thingifier.api.security.ThingifierApiScopedSessionPolicy.Mode;
import uk.co.compendiumdev.thingifier.api.security.ThingifierApiSecuritySpec;
import uk.co.compendiumdev.thingifier.apiconfig.EntityPatchUpdateStyle;
import uk.co.compendiumdev.thingifier.apiconfig.EntityWriteOperation;
@@ -40,6 +45,7 @@ public final class ThingifierApiSpec {
private final List Scoped sessions are for application credentials that may select a data scope only after a
+ * trusted resolver has validated them. This keeps headers such as {@code X-CHALLENGER} or
+ * tenant ids from being treated as direct database names.
+ *
+ * @param sessionName scoped-session definition name
+ * @return mutable scoped-session definition
+ */
+ public ThingifierApiScopedSessionDefinition scopedSession(final String sessionName) {
+ final String normalizedSessionName = SecuritySchemeNames.requireValid(sessionName);
+ return scopedSessions.computeIfAbsent(
+ normalizedSessionName, ThingifierApiScopedSessionDefinition::new);
+ }
+
/**
* Returns the reusable entity-level rule for a model entity.
*
@@ -297,6 +320,7 @@ public void applyTo(final ApiRoutingDefinition routingDefinition, final String a
for (RoutingDefinition route : routingDefinition.definitions()) {
ruleFor(route.verb(), route.url(), apiPathPrefix)
.ifPresent(rule -> rule.applyTo(route));
+ applyScopedSessionDocumentationTo(route, apiPathPrefix);
applyEntityDefaultsTo(route, routingDefinition);
}
routingDefinition.updateOptionsAllowHeaders();
@@ -442,6 +466,60 @@ public Optional Explicit route settings win over contract-level read/write shortcuts. If a route refers to
+ * a missing scoped-session definition, the unresolved policy is returned so runtime handling
+ * can fail closed with a configuration error instead of silently allowing the request.
+ *
+ * @param verb routing verb
+ * @param path request or generated route path
+ * @param apiPathPrefix configured API prefix used when matching paths
+ * @return scoped-session policy when this route opts into resolution
+ */
+ public Optional Runtime scoped sessions can be optional, route-auth can be combined with them, and
+ * non-header sources are not a natural OpenAPI security scheme in this codebase. To avoid
+ * over-documenting, this only advertises a scoped-session credential when it is the route's
+ * required security mechanism and no explicit route auth metadata already exists.
+ *
+ * @param route generated route metadata
+ * @param apiPathPrefix configured API prefix
+ */
+ private void applyScopedSessionDocumentationTo(
+ final RoutingDefinition route, final String apiPathPrefix) {
+ if (route.hasAuthSchemeNames()) {
+ return;
+ }
+ scopedSessionPolicyFor(route.verb(), route.url(), apiPathPrefix)
+ .filter(ThingifierApiScopedSessionPolicy::requiresAuthenticatedScope)
+ .flatMap(ThingifierApiScopedSessionPolicy::definition)
+ .filter(ThingifierApiScopedSessionDefinition::hasCredentialSource)
+ .filter(
+ definition ->
+ definition.credentialSourceType()
+ == ThingifierApiScopedSessionCredentialSourceType.HEADER)
+ .ifPresent(
+ definition -> {
+ securitySpec.apiKey(
+ definition.name(), definition.credentialSourceName());
+ route.secureWithApiKey(definition.name());
+ });
+ }
+
/**
* Adds route definitions for fixed-instance route rules.
*
diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionPolicyTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionPolicyTest.java
new file mode 100644
index 00000000..0fe83fd5
--- /dev/null
+++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionPolicyTest.java
@@ -0,0 +1,560 @@
+package uk.co.compendiumdev.thingifier.api.security;
+
+import static uk.co.compendiumdev.thingifier.api.security.DataScopeCreationPolicy.ENSURE_EXISTS;
+
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.security.SecurityScheme;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import uk.co.compendiumdev.thingifier.Thingifier;
+import uk.co.compendiumdev.thingifier.adapter.http.lifecycle.ThingifierApiLifecycleHookRegistry;
+import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb;
+import uk.co.compendiumdev.thingifier.api.docgen.ThingifierApiDocumentationDefn;
+import uk.co.compendiumdev.thingifier.api.http.HttpApiRequest;
+import uk.co.compendiumdev.thingifier.api.http.HttpApiResponse;
+import uk.co.compendiumdev.thingifier.api.http.ThingifierHttpApi;
+import uk.co.compendiumdev.thingifier.api.http.bodyparser.BodyParser;
+import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeadersBlock;
+import uk.co.compendiumdev.thingifier.api.response.ApiResponse;
+import uk.co.compendiumdev.thingifier.apiconfig.ThingifierApiConfig;
+import uk.co.compendiumdev.thingifier.core.EntityRelModel;
+import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition;
+import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field;
+import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType;
+import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance;
+import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstanceDraft;
+import uk.co.compendiumdev.thingifier.core.query.QueryFilterParams;
+import uk.co.compendiumdev.thingifier.core.reporting.ValidationReport;
+import uk.co.compendiumdev.thingifier.core.repository.ThingStore;
+
+class ThingifierApiScopedSessionPolicyTest {
+
+ @Test
+ void missingCredentialOnAnonymousReadUsesDefaultScope() {
+ final Thingifier thingifier = todoModel();
+ createTodo(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME, "default todo");
+ createTodo(thingifier, "tenant-one", "tenant todo");
+ final AtomicInteger resolverCalls = new AtomicInteger();
+ scopedSession(thingifier)
+ .authenticateWith(
+ context -> {
+ resolverCalls.incrementAndGet();
+ return ThingifierApiScopedSessionResult.unauthenticated();
+ })
+ .allowAnonymousDefaultScopeForReads();
+
+ final ApiResponse response =
+ thingifier
+ .api()
+ .get("todos", new QueryFilterParams(), headersWithSession("tenant-one"));
+
+ Assertions.assertEquals(200, response.getStatusCode());
+ Assertions.assertEquals("default todo", firstReturnedTodoTitle(response));
+ Assertions.assertEquals(0, resolverCalls.get());
+ }
+
+ @Test
+ void validHeaderCredentialOnReadUsesSelectedDataScope() {
+ final Thingifier thingifier = todoModel();
+ createTodo(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME, "default todo");
+ createTodo(thingifier, "tenant-one", "tenant todo");
+ scopedSession(thingifier)
+ .authenticateWith(this::validScopedSession)
+ .allowAnonymousDefaultScopeForReads();
+
+ final ApiResponse response =
+ thingifier
+ .api()
+ .get(
+ "todos",
+ new QueryFilterParams(),
+ headersWithScopedCredential("valid-session"));
+
+ Assertions.assertEquals(200, response.getStatusCode());
+ Assertions.assertEquals("tenant todo", firstReturnedTodoTitle(response));
+ }
+
+ @Test
+ void invalidCredentialOnAnonymousReadRejects() {
+ final Thingifier thingifier = todoModel();
+ createTodo(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME, "default todo");
+ scopedSession(thingifier)
+ .authenticateWith(this::validScopedSession)
+ .allowAnonymousDefaultScopeForReads();
+
+ final ApiResponse response =
+ thingifier
+ .api()
+ .get(
+ "todos",
+ new QueryFilterParams(),
+ headersWithScopedCredential("bad-session"));
+
+ Assertions.assertEquals(401, response.getStatusCode());
+ Assertions.assertEquals("Unauthorized", response.getErrorMessages().iterator().next());
+ }
+
+ @Test
+ void invalidCredentialUsesConfiguredInvalidResponse() {
+ final Thingifier thingifier = todoModel();
+ scopedSession(thingifier)
+ .authenticateWith(this::validScopedSession)
+ .requireAuthenticatedScopeForWrites()
+ .onInvalidCredential(403, "Invalid scoped session");
+
+ final ApiResponse response =
+ thingifier
+ .api()
+ .post(
+ "todos",
+ parser(thingifier, "{\"title\":\"blocked\"}"),
+ headersWithScopedCredential("bad-session"));
+
+ Assertions.assertEquals(403, response.getStatusCode());
+ Assertions.assertEquals(
+ "Invalid scoped session", response.getErrorMessages().iterator().next());
+ Assertions.assertEquals(0, todoCount(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME));
+ }
+
+ @Test
+ void missingCredentialOnProtectedWriteReturnsConfiguredResponse() {
+ final Thingifier thingifier = todoModel();
+ scopedSession(thingifier)
+ .authenticateWith(this::validScopedSession)
+ .requireAuthenticatedScopeForWrites()
+ .onMissingRequiredCredential(401, "Missing scoped session");
+
+ final ApiResponse response =
+ thingifier
+ .api()
+ .post(
+ "todos",
+ parser(thingifier, "{\"title\":\"blocked\"}"),
+ new HttpHeadersBlock());
+
+ Assertions.assertEquals(401, response.getStatusCode());
+ Assertions.assertEquals(
+ "Missing scoped session", response.getErrorMessages().iterator().next());
+ Assertions.assertEquals(0, todoCount(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME));
+ }
+
+ @Test
+ void validCredentialOnProtectedWriteWritesToSelectedDataScope() {
+ final Thingifier thingifier = todoModel();
+ scopedSession(thingifier)
+ .authenticateWith(this::validScopedSession)
+ .requireAuthenticatedScopeForWrites();
+
+ final ApiResponse response =
+ thingifier
+ .api()
+ .post(
+ "todos",
+ parser(thingifier, "{\"title\":\"tenant todo\"}"),
+ headersWithScopedCredential("valid-session"));
+
+ Assertions.assertEquals(201, response.getStatusCode());
+ Assertions.assertEquals(0, todoCount(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME));
+ Assertions.assertEquals(1, todoCount(thingifier, "tenant-one"));
+ }
+
+ @Test
+ void routeLevelRequireOverridesAnonymousReadDefault() {
+ final Thingifier thingifier = todoModel();
+ createTodo(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME, "default todo");
+ scopedSession(thingifier)
+ .authenticateWith(this::validScopedSession)
+ .allowAnonymousDefaultScopeForReads();
+ thingifier.apiSpec().route(RoutingVerb.GET, "/todos").requireScopedSession("challenger");
+
+ final ApiResponse response =
+ thingifier.api().get("todos", new QueryFilterParams(), new HttpHeadersBlock());
+
+ Assertions.assertEquals(401, response.getStatusCode());
+ }
+
+ @Test
+ void routeLevelAnonymousDefaultScopeOverridesProtectedWriteDefault() {
+ final Thingifier thingifier = todoModel();
+ scopedSession(thingifier)
+ .authenticateWith(this::validScopedSession)
+ .requireAuthenticatedScopeForWrites();
+ thingifier.apiSpec().route(RoutingVerb.POST, "/todos").allowAnonymousUsingDefaultScope();
+
+ final ApiResponse response =
+ thingifier
+ .api()
+ .post(
+ "todos",
+ parser(thingifier, "{\"title\":\"default write\"}"),
+ new HttpHeadersBlock());
+
+ Assertions.assertEquals(201, response.getStatusCode());
+ Assertions.assertEquals(1, todoCount(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME));
+ }
+
+ @Test
+ void routeLevelDisablePreservesLegacySessionHeaderScope() {
+ final Thingifier thingifier = todoModel();
+ scopedSession(thingifier)
+ .authenticateWith(this::validScopedSession)
+ .requireAuthenticatedScopeForWrites();
+ thingifier.apiSpec().route(RoutingVerb.POST, "/todos").disableScopedSession();
+
+ final ApiResponse response =
+ thingifier
+ .api()
+ .post(
+ "todos",
+ parser(thingifier, "{\"title\":\"legacy session write\"}"),
+ headersWithSession("tenant-one"));
+
+ Assertions.assertEquals(201, response.getStatusCode());
+ Assertions.assertEquals(0, todoCount(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME));
+ Assertions.assertEquals(1, todoCount(thingifier, "tenant-one"));
+ }
+
+ @Test
+ void queryParameterCredentialCanSelectDataScope() {
+ final Thingifier thingifier = todoModel();
+ createTodo(thingifier, "tenant-one", "tenant todo");
+ thingifier
+ .apiSpec()
+ .scopedSession("querySession")
+ .fromQueryParam("challenger")
+ .authenticateWith(this::validScopedSession);
+ thingifier.apiSpec().route(RoutingVerb.GET, "/todos").requireScopedSession("querySession");
+ final QueryFilterParams queryParams = new QueryFilterParams();
+ queryParams.put("challenger", "valid-session");
+
+ final ApiResponse response =
+ thingifier.api().get("todos", queryParams, new HttpHeadersBlock());
+
+ Assertions.assertEquals(200, response.getStatusCode());
+ Assertions.assertEquals("tenant todo", firstReturnedTodoTitle(response));
+ }
+
+ @Test
+ void cookieCredentialCanSelectDataScope() {
+ final Thingifier thingifier = todoModel();
+ createTodo(thingifier, "tenant-one", "tenant todo");
+ thingifier
+ .apiSpec()
+ .scopedSession("cookieSession")
+ .fromCookie("CHALLENGER")
+ .authenticateWith(this::validScopedSession);
+ thingifier.apiSpec().route(RoutingVerb.GET, "/todos").requireScopedSession("cookieSession");
+
+ final ApiResponse response =
+ thingifier
+ .api()
+ .get(
+ "todos",
+ new QueryFilterParams(),
+ headersWithCookie("other=x; CHALLENGER=valid-session"));
+
+ Assertions.assertEquals(200, response.getStatusCode());
+ Assertions.assertEquals("tenant todo", firstReturnedTodoTitle(response));
+ }
+
+ @Test
+ void authorizerReceivesScopedSessionSelectedScopeAndPrincipal() {
+ final Thingifier thingifier = todoModel();
+ final AtomicReference