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 selectedStore = + runtime.storeForDataScope(selection.dataScopeName(), selection.creationPolicy()); + if (selectedStore.isEmpty()) { + return ApiResponse.error404("Could not find data scope " + selection.dataScopeName()); + } + + if (requiresEmptyScopeAfterHeaderCreation(context, selection)) { + selectedStore.get().administration().clearAllData(); + } + context.useDataScope(selection.dataScopeName(), selectedStore.get()); + return null; + } + + private boolean requiresPreExistingScope( + final ThingifierRequestContext context, + final ThingifierApiDataScopeSelection selection) { + return selection.creationPolicy() == DataScopeCreationPolicy.USE_EXISTING_ONLY + && selection.dataScopeName().equals(context.dataScopeName()) + && context.wasDataScopeCreatedWhenContextCreated(); + } + + private boolean requiresEmptyScopeAfterHeaderCreation( + final ThingifierRequestContext context, + final ThingifierApiDataScopeSelection selection) { + return selection.creationPolicy() == DataScopeCreationPolicy.ENSURE_EXISTS + && selection.dataScopeName().equals(context.dataScopeName()) + && context.wasDataScopeCreatedWhenContextCreated(); + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/RouteAuthPolicy.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/RouteAuthPolicy.java index 83b06550..1afac2aa 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/RouteAuthPolicy.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/RouteAuthPolicy.java @@ -13,14 +13,12 @@ import uk.co.compendiumdev.thingifier.api.http.headers.headerparser.BasicAuthHeaderParser; import uk.co.compendiumdev.thingifier.api.http.headers.headerparser.BearerAuthHeaderParser; import uk.co.compendiumdev.thingifier.api.response.ApiResponse; -import uk.co.compendiumdev.thingifier.api.security.DataScopeCreationPolicy; import uk.co.compendiumdev.thingifier.api.security.ThingifierApiAuthenticationContext; import uk.co.compendiumdev.thingifier.api.security.ThingifierApiAuthenticationResult; import uk.co.compendiumdev.thingifier.api.security.ThingifierApiAuthenticator; import uk.co.compendiumdev.thingifier.api.security.ThingifierApiAuthorizationContext; import uk.co.compendiumdev.thingifier.api.security.ThingifierApiAuthorizationResult; import uk.co.compendiumdev.thingifier.api.security.ThingifierApiAuthorizer; -import uk.co.compendiumdev.thingifier.api.security.ThingifierApiDataScopeSelection; import uk.co.compendiumdev.thingifier.api.security.ThingifierApiRouteAuthDetails; import uk.co.compendiumdev.thingifier.api.security.ThingifierApiSecuritySchemeType; import uk.co.compendiumdev.thingifier.api.spec.ApiRoutePathMatcher; @@ -28,7 +26,6 @@ import uk.co.compendiumdev.thingifier.application.schema.EntityTypeRef; import uk.co.compendiumdev.thingifier.application.schema.RelationshipSpec; import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; -import uk.co.compendiumdev.thingifier.core.repository.ThingStore; /** * Enforces route-level authentication and authorization configured in the API spec. @@ -384,39 +381,8 @@ private ApiResponse applyDataScopeSelection( if (authentication.dataScopeSelection().isEmpty()) { return null; } - - final ThingifierApiDataScopeSelection selection = authentication.dataScopeSelection().get(); - if (requiresPreExistingScope(context, selection)) { - return ApiResponse.error404("Could not find data scope " + selection.dataScopeName()); - } - - final Optional selectedStore = - runtime.storeForDataScope(selection.dataScopeName(), selection.creationPolicy()); - if (selectedStore.isEmpty()) { - return ApiResponse.error404("Could not find data scope " + selection.dataScopeName()); - } - - if (requiresEmptyScopeAfterHeaderCreation(context, selection)) { - selectedStore.get().administration().clearAllData(); - } - context.useDataScope(selection.dataScopeName(), selectedStore.get()); - return null; - } - - private boolean requiresPreExistingScope( - final ThingifierRequestContext context, - final ThingifierApiDataScopeSelection selection) { - return selection.creationPolicy() == DataScopeCreationPolicy.USE_EXISTING_ONLY - && selection.dataScopeName().equals(context.dataScopeName()) - && context.wasDataScopeCreatedWhenContextCreated(); - } - - private boolean requiresEmptyScopeAfterHeaderCreation( - final ThingifierRequestContext context, - final ThingifierApiDataScopeSelection selection) { - return selection.creationPolicy() == DataScopeCreationPolicy.ENSURE_EXISTS - && selection.dataScopeName().equals(context.dataScopeName()) - && context.wasDataScopeCreatedWhenContextCreated(); + return new DataScopeSelectionApplier(runtime) + .apply(context, authentication.dataScopeSelection().get()); } /** diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/ScopedSessionPolicyApplier.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/ScopedSessionPolicyApplier.java new file mode 100644 index 00000000..67e7e608 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/ScopedSessionPolicyApplier.java @@ -0,0 +1,443 @@ +package uk.co.compendiumdev.thingifier.adapter.http.apihandlers; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.CollectionRoute; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.InstanceRoute; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.RelationshipCollectionRoute; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.RelationshipInstanceRoute; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.ThingRoute; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.UnmatchedRoute; +import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb; +import uk.co.compendiumdev.thingifier.api.http.ThingifierRequestContext; +import uk.co.compendiumdev.thingifier.api.response.ApiResponse; +import uk.co.compendiumdev.thingifier.api.security.ThingifierApiAuthenticationContext; +import uk.co.compendiumdev.thingifier.api.security.ThingifierApiAuthorizationContext; +import uk.co.compendiumdev.thingifier.api.security.ThingifierApiAuthorizationResult; +import uk.co.compendiumdev.thingifier.api.security.ThingifierApiAuthorizer; +import uk.co.compendiumdev.thingifier.api.security.ThingifierApiDataScopeSelection; +import uk.co.compendiumdev.thingifier.api.security.ThingifierApiRouteAuthDetails; +import uk.co.compendiumdev.thingifier.api.security.ThingifierApiScopedSessionContext; +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.ThingifierApiScopedSessionResult; +import uk.co.compendiumdev.thingifier.api.spec.ApiRoutePathMatcher; +import uk.co.compendiumdev.thingifier.api.spec.ThingifierApiRouteRule; +import uk.co.compendiumdev.thingifier.application.schema.EntityTypeRef; +import uk.co.compendiumdev.thingifier.application.schema.RelationshipSpec; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.query.FilterBy; +import uk.co.compendiumdev.thingifier.core.query.QueryFilterParams; + +/** + * Resolves configured scoped-session credentials before route auth, validators, and handlers run. + * + *

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 matchingRule = + runtime.apiSpec().ruleFor(verb, path, apiPathPrefix); + if (resolvedRoute instanceof UnmatchedRoute && matchingRule.isEmpty()) { + return null; + } + + final Optional scopedSessionPolicy = + runtime.apiSpec().scopedSessionPolicyFor(verb, path, apiPathPrefix); + if (scopedSessionPolicy.isEmpty()) { + return null; + } + + final ThingifierApiScopedSessionPolicy policy = scopedSessionPolicy.get(); + if (policy.definition().isEmpty()) { + return ApiResponse.error( + 500, "No scoped-session definition configured for " + policy.sessionName()); + } + + final ThingifierApiScopedSessionDefinition definition = policy.definition().get(); + if (!definition.hasCredentialSource()) { + return ApiResponse.error( + 500, "No credential source configured for scoped-session " + definition.name()); + } + + final CredentialValue credential = + credentialFrom(definition, context, safeQueryParams(queryParams)); + if (!credential.present()) { + if (policy.requiresAuthenticatedScope()) { + return definition.missingRequiredCredentialResponse(); + } + return dataScopeSelectionApplier.apply( + context, ThingifierApiDataScopeSelection.defaultDataScope()); + } + + if (definition.authenticator() == null) { + return ApiResponse.error( + 500, "No scoped-session authenticator configured for " + definition.name()); + } + + final ThingifierApiScopedSessionResult result = + definition + .authenticator() + .authenticate( + scopedSessionContext( + definition, + credential.value(), + verb, + path, + context, + resolvedRoute, + safeQueryParams(queryParams))); + if (result == null || !result.isAuthenticated()) { + return scopedSessionRejected(definition, result); + } + + context.setAuthenticatedPrincipal(definition.name(), result.principal()); + if (result.dataScopeSelection().isPresent()) { + final ApiResponse dataScopeResponse = + dataScopeSelectionApplier.apply(context, result.dataScopeSelection().get()); + if (dataScopeResponse != null) { + return dataScopeResponse; + } + } + if (matchingRule.isPresent() && !matchingRule.get().hasAuthEnforcement()) { + return rejectIfUnauthorizedByAuthorizer( + matchingRule.get(), + scopedSessionAuthContext( + definition, + credential.value(), + verb, + path, + context, + resolvedRoute, + matchingRule.get()), + result); + } + return null; + } + + private ApiResponse scopedSessionRejected( + final ThingifierApiScopedSessionDefinition definition, + final ThingifierApiScopedSessionResult result) { + if (result == null || result.rejectionResponse() == null) { + return definition.invalidCredentialResponse(); + } + return result.rejectionResponse(); + } + + private ThingifierApiScopedSessionContext scopedSessionContext( + final ThingifierApiScopedSessionDefinition definition, + final String credential, + final RoutingVerb verb, + final String path, + final ThingifierRequestContext context, + final ThingRoute route, + final QueryFilterParams queryParams) { + final Optional rule = + runtime.apiSpec().ruleFor(verb, path, runtime.apiConfig().getApiEndPointPrefix()); + return ThingifierApiScopedSessionContext.builder() + .sessionName(definition.name()) + .credential(credential) + .credentialSourceType(definition.credentialSourceType()) + .credentialSourceName(definition.credentialSourceName()) + .verb(verb) + .path(path) + .pathParameters( + rule.map( + value -> + ApiRoutePathMatcher.pathParameters( + value.pathPattern(), + path, + runtime.apiConfig().getApiEndPointPrefix())) + .orElseGet(LinkedHashMap::new)) + .route(route) + .headers(context.headers()) + .queryParams(queryParams) + .requestContext(context) + .targetEntity(targetEntity(route)) + .targetIdentifier(targetIdentifier(route)) + .parentEntity(parentEntity(route)) + .parentIdentifier(parentIdentifier(route)) + .relationshipName(relationshipName(route)) + .childIdentifier(childIdentifier(route)) + .build(); + } + + private ThingifierApiAuthenticationContext scopedSessionAuthContext( + final ThingifierApiScopedSessionDefinition definition, + final String credential, + final RoutingVerb verb, + final String path, + final ThingifierRequestContext context, + final ThingRoute route, + final ThingifierApiRouteRule rule) { + return ThingifierApiAuthenticationContext.apiKey( + ThingifierApiRouteAuthDetails.builder() + .schemeName(definition.name()) + .verb(verb) + .path(path) + .pathParameters( + ApiRoutePathMatcher.pathParameters( + rule.pathPattern(), + path, + runtime.apiConfig().getApiEndPointPrefix())) + .route(route) + .headers(context.headers()) + .requestContext(context) + .targetEntity(targetEntity(route)) + .targetIdentifier(targetIdentifier(route)) + .parentEntity(parentEntity(route)) + .parentIdentifier(parentIdentifier(route)) + .relationshipName(relationshipName(route)) + .childIdentifier(childIdentifier(route)) + .build(), + credential, + definition.credentialSourceName()); + } + + private ApiResponse rejectIfUnauthorizedByAuthorizer( + final ThingifierApiRouteRule rule, + final ThingifierApiAuthenticationContext authenticationContext, + final ThingifierApiScopedSessionResult authentication) { + for (ThingifierApiAuthorizer authorizer : rule.authorizers()) { + final ThingifierApiAuthorizationResult authorization = + authorizer.authorize( + new ThingifierApiAuthorizationContext( + authenticationContext, authentication.principal())); + if (authorization == null || !authorization.isAuthorized()) { + return authorizationRejected(authorization); + } + } + return null; + } + + private ApiResponse authorizationRejected( + final ThingifierApiAuthorizationResult authorization) { + if (authorization == null || authorization.rejectionResponse() == null) { + return ApiResponse.error(403, "Forbidden"); + } + return authorization.rejectionResponse(); + } + + private CredentialValue credentialFrom( + final ThingifierApiScopedSessionDefinition definition, + final ThingifierRequestContext context, + final QueryFilterParams queryParams) { + final ThingifierApiScopedSessionCredentialSourceType sourceType = + definition.credentialSourceType(); + switch (sourceType) { + case HEADER: + return credentialFromHeader(context, definition.credentialSourceName()); + case QUERY_PARAM: + return credentialFromQueryParam(queryParams, definition.credentialSourceName()); + case COOKIE: + return credentialFromCookie(context, definition.credentialSourceName()); + default: + return CredentialValue.missing(); + } + } + + private CredentialValue credentialFromHeader( + final ThingifierRequestContext context, final String headerName) { + if (!context.headers().headerExists(headerName)) { + return CredentialValue.missing(); + } + return CredentialValue.present(context.headers().get(headerName)); + } + + private CredentialValue credentialFromQueryParam( + final QueryFilterParams queryParams, final String queryParamName) { + for (FilterBy filterBy : safeQueryParams(queryParams).toList()) { + if (queryParamName.equals(filterBy.fieldName)) { + return CredentialValue.present(filterBy.fieldValue); + } + } + return CredentialValue.missing(); + } + + private CredentialValue credentialFromCookie( + final ThingifierRequestContext context, final String cookieName) { + if (!context.headers().headerExists(COOKIE_HEADER)) { + return CredentialValue.missing(); + } + Map cookies = cookiesFrom(context.headers().get(COOKIE_HEADER)); + if (!cookies.containsKey(cookieName)) { + return CredentialValue.missing(); + } + return CredentialValue.present(cookies.get(cookieName)); + } + + private Map cookiesFrom(final String cookieHeader) { + Map cookies = new LinkedHashMap<>(); + if (cookieHeader == null || cookieHeader.trim().isEmpty()) { + return cookies; + } + String[] cookieParts = cookieHeader.split(";"); + for (String cookiePart : cookieParts) { + int separator = cookiePart.indexOf('='); + if (separator < 0) { + continue; + } + String name = cookiePart.substring(0, separator).trim(); + String value = cookiePart.substring(separator + 1).trim(); + if (!name.isEmpty()) { + cookies.put(name, value); + } + } + return cookies; + } + + private QueryFilterParams safeQueryParams(final QueryFilterParams queryParams) { + return queryParams == null ? new QueryFilterParams() : queryParams; + } + + private EntityDefinition targetEntity(final ThingRoute route) { + if (route instanceof CollectionRoute) { + return entityNamed(((CollectionRoute) route).entity().name()); + } + if (route instanceof InstanceRoute) { + return entityNamed(((InstanceRoute) route).entity().name()); + } + if (route instanceof RelationshipCollectionRoute) { + final RelationshipCollectionRoute relationship = (RelationshipCollectionRoute) route; + return targetEntityForRelationship( + relationship.parentEntity(), relationship.relationshipName()); + } + if (route instanceof RelationshipInstanceRoute) { + final RelationshipInstanceRoute relationship = (RelationshipInstanceRoute) route; + return targetEntityForRelationship( + relationship.parentEntity(), relationship.relationshipName()); + } + return null; + } + + private EntityDefinition parentEntity(final ThingRoute route) { + if (route instanceof RelationshipCollectionRoute) { + return entityNamed(((RelationshipCollectionRoute) route).parentEntity().name()); + } + if (route instanceof RelationshipInstanceRoute) { + return entityNamed(((RelationshipInstanceRoute) route).parentEntity().name()); + } + return null; + } + + private String targetIdentifier(final ThingRoute route) { + if (route instanceof InstanceRoute) { + return ((InstanceRoute) route).identifier(); + } + if (route instanceof RelationshipInstanceRoute) { + return ((RelationshipInstanceRoute) route).childIdentifier(); + } + return null; + } + + private String parentIdentifier(final ThingRoute route) { + if (route instanceof RelationshipCollectionRoute) { + return ((RelationshipCollectionRoute) route).parentIdentifier(); + } + if (route instanceof RelationshipInstanceRoute) { + return ((RelationshipInstanceRoute) route).parentIdentifier(); + } + return null; + } + + private String relationshipName(final ThingRoute route) { + if (route instanceof RelationshipCollectionRoute) { + return ((RelationshipCollectionRoute) route).relationshipName(); + } + if (route instanceof RelationshipInstanceRoute) { + return ((RelationshipInstanceRoute) route).relationshipName(); + } + return null; + } + + private String childIdentifier(final ThingRoute route) { + if (route instanceof RelationshipInstanceRoute) { + return ((RelationshipInstanceRoute) route).childIdentifier(); + } + return null; + } + + private EntityDefinition targetEntityForRelationship( + final EntityTypeRef parentEntity, final String relationshipName) { + if (parentEntity == null) { + return null; + } + for (RelationshipSpec spec : parentEntity.relationships()) { + if (spec.name().equals(relationshipName)) { + return entityNamed(spec.toEntityName()); + } + } + return null; + } + + private EntityDefinition entityNamed(final String entityName) { + return runtime.schema().definitionWithSingularOrPluralNamed(entityName); + } + + private static final class CredentialValue { + private final boolean present; + private final String value; + + private CredentialValue(final boolean present, final String value) { + this.present = present; + this.value = value == null ? "" : value; + } + + static CredentialValue missing() { + return new CredentialValue(false, ""); + } + + static CredentialValue present(final String value) { + return new CredentialValue(true, value); + } + + boolean present() { + return present; + } + + String value() { + return value; + } + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/ThingifierRestAPIHandler.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/ThingifierRestAPIHandler.java index 470eed56..76801915 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/ThingifierRestAPIHandler.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/ThingifierRestAPIHandler.java @@ -7,12 +7,14 @@ import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.RouteApiResponsePolicyApplier; import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.RouteAuthPolicy; import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.RouteOperationCallbackApplier; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.ScopedSessionPolicyApplier; import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.ThingifierApiRuntime; import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.ThingRoute; import uk.co.compendiumdev.thingifier.adapter.http.lifecycle.ThingifierApiLifecycleContext; import uk.co.compendiumdev.thingifier.adapter.http.lifecycle.ThingifierApiLifecycleHookRegistry; import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb; import uk.co.compendiumdev.thingifier.api.http.ApiRequestEnvelope; +import uk.co.compendiumdev.thingifier.api.http.ThingifierHttpApi; import uk.co.compendiumdev.thingifier.api.http.ThingifierRequestContext; import uk.co.compendiumdev.thingifier.api.http.bodyparser.ApiBodyFields; import uk.co.compendiumdev.thingifier.api.http.bodyparser.BodyParser; @@ -39,6 +41,7 @@ public class ThingifierRestAPIHandler { private final RestApiGetHandler get; private final RestApiQueryHandler query; private final RouteAuthPolicy authPolicy; + private final ScopedSessionPolicyApplier scopedSessionPolicy; /** * Creates a direct API facade for a Thingifier model. @@ -81,6 +84,7 @@ public ThingifierRestAPIHandler( this.patch = new RestApiPatchHandler(runtime, hooks); this.query = new RestApiQueryHandler(runtime, hooks); this.authPolicy = new RouteAuthPolicy(runtime); + this.scopedSessionPolicy = new ScopedSessionPolicyApplier(runtime); } // TODO: we should be able to accept xml with correct content type @@ -107,7 +111,12 @@ public ApiResponse get( final String url, final QueryFilterParams queryParams, HttpHeadersBlock headers) { ThingifierRequestContext context = contextFrom(headers); return withAuthorizedResponsePolicy( - RoutingVerb.GET, url, context, null, () -> get.handle(url, queryParams, context)); + RoutingVerb.GET, + url, + context, + null, + queryParams, + () -> get.handle(url, queryParams, context)); } /** @@ -156,6 +165,7 @@ public ApiResponse head( url, context, null, + queryParams, () -> { final ApiResponse response = get.handle(RoutingVerb.HEAD, url, queryParams, context); @@ -566,7 +576,30 @@ private ApiResponse withAuthorizedResponsePolicy( final ThingifierRequestContext context, final ThingifierApiLifecycleContext lifecycle, final Supplier action) { - return withAuthorizedResponsePolicy(verb, url, context, lifecycle, null, action); + return withAuthorizedResponsePolicy( + verb, url, context, lifecycle, null, new QueryFilterParams(), action); + } + + /** + * Applies scoped sessions and route auth for older direct-call helpers with query parameters. + * + * @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 queryParams query parameters available to scoped-session credential resolution + * @param action handler action to run when auth allows the request + * @return response after auth and normal response policy have been applied + */ + private ApiResponse withAuthorizedResponsePolicy( + final RoutingVerb verb, + final String url, + final ThingifierRequestContext context, + final ThingifierApiLifecycleContext lifecycle, + final QueryFilterParams queryParams, + final Supplier action) { + return withAuthorizedResponsePolicy( + verb, url, context, lifecycle, null, queryParams, action); } /** @@ -590,13 +623,59 @@ private ApiResponse withAuthorizedResponsePolicy( final ThingifierApiLifecycleContext lifecycle, final ApiRequestEnvelope request, final Supplier action) { + return withAuthorizedResponsePolicy( + verb, + url, + context, + lifecycle, + request, + request == null ? new QueryFilterParams() : request.queryParams(), + action); + } + + /** + * Applies scoped sessions, auth, fixed-resource preparation, response policy, and callbacks. + * + *

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 action) { + final ThingRoute route = + lifecycle == null ? runtime.routeFor(verb, url) : lifecycle.route(); + final ApiResponse scopedSessionResponse = + lifecycle == null + ? scopedSessionPolicy.rejectIfNotResolved( + verb, url, context, route, queryParams) + : null; + if (scopedSessionResponse != null) { + return withResponsePolicy( + verb, url, scopedSessionResponse, context, lifecycle, request); + } + final ApiResponse authResponse = - lifecycle == null ? authPolicy.rejectIfNotAuthorized(verb, url, context) : null; + lifecycle == null + ? authPolicy.rejectIfNotAuthorized(verb, url, context, route) + : null; if (authResponse != null) { return withResponsePolicy(verb, url, authResponse, context, lifecycle, request); } - final ThingRoute route = - lifecycle == null ? runtime.routeFor(verb, url) : lifecycle.route(); final ApiResponse fixedResourceResponse = new FixedRouteResourcePreparer(runtime).prepare(verb, url, route, context); if (fixedResourceResponse != null) { diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ThingifierHttpApi.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ThingifierHttpApi.java index cea5786a..f24679a6 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ThingifierHttpApi.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ThingifierHttpApi.java @@ -13,6 +13,7 @@ import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.DefaultThingifierApiRuntime; import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.RouteApiResponsePolicyApplier; import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.RouteAuthPolicy; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.ScopedSessionPolicyApplier; import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.ThingifierApiRuntime; import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.CollectionRoute; import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.InstanceRoute; @@ -233,6 +234,20 @@ private HttpApiResponse handleRequest(final HttpApiRequest request, HttpVerb ver httpResponse = disabledRouteResponse(request, effectiveVerb); } + if (httpResponse == null && lifecycle != null) { + final ApiResponse scopedSessionResponse = + new ScopedSessionPolicyApplier(lifecycle.runtime()) + .rejectIfNotResolved( + lifecycle.routingVerb(), + lifecycle.path(), + lifecycle.requestContext(), + lifecycle.route(), + lifecycle.queryParams()); + if (scopedSessionResponse != null) { + httpResponse = httpResponseFor(request, effectiveVerb, scopedSessionResponse); + } + } + if (httpResponse == null && lifecycle != null) { final ApiResponse authResponse = new RouteAuthPolicy(lifecycle.runtime()) diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionAuthenticator.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionAuthenticator.java new file mode 100644 index 00000000..48ee3937 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionAuthenticator.java @@ -0,0 +1,20 @@ +package uk.co.compendiumdev.thingifier.api.security; + +/** + * Validates one scoped-session credential and decides the trusted request scope. + * + *

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 pathParameters; + private final ThingRoute route; + private final HttpHeadersBlock headers; + private final QueryFilterParams queryParams; + private final ThingifierRequestContext requestContext; + private final EntityDefinition targetEntity; + private final String targetIdentifier; + private final EntityDefinition parentEntity; + private final String parentIdentifier; + private final String relationshipName; + private final String childIdentifier; + + private ThingifierApiScopedSessionContext(final Builder builder) { + this.sessionName = builder.sessionName; + this.credential = builder.credential; + this.credentialSourceType = builder.credentialSourceType; + this.credentialSourceName = builder.credentialSourceName; + this.verb = builder.verb; + this.path = builder.path; + this.pathParameters = Map.copyOf(builder.pathParameters); + this.route = builder.route; + this.headers = copyHeaders(builder.headers); + this.queryParams = copyQueryParams(builder.queryParams); + this.requestContext = builder.requestContext; + this.targetEntity = builder.targetEntity; + this.targetIdentifier = builder.targetIdentifier; + this.parentEntity = builder.parentEntity; + this.parentIdentifier = builder.parentIdentifier; + this.relationshipName = builder.relationshipName; + this.childIdentifier = builder.childIdentifier; + } + + /** + * Creates a builder for a resolver context. + * + * @return mutable builder used by Thingifier runtime policy + */ + public static Builder builder() { + return new Builder(); + } + + /** + * @return scoped-session definition name + */ + public String sessionName() { + return sessionName; + } + + /** + * @return credential value read from the configured request source + */ + public String credential() { + return credential; + } + + /** + * @return kind of request source that supplied the credential + */ + public ThingifierApiScopedSessionCredentialSourceType credentialSourceType() { + return credentialSourceType; + } + + /** + * @return configured header, query parameter, or cookie name + */ + public String credentialSourceName() { + return credentialSourceName; + } + + /** + * @return generated routing verb for this request + */ + public RoutingVerb verb() { + return verb; + } + + /** + * @return public request path after API prefix handling + */ + public String path() { + return path; + } + + /** + * @return matched path parameters, if any + */ + public Map pathParameters() { + return pathParameters; + } + + /** + * @return resolved Thingifier route + */ + public ThingRoute route() { + return route; + } + + /** + * @return copy of request headers + */ + public HttpHeadersBlock headers() { + return copyHeaders(headers); + } + + /** + * @return copy of query parameters available at session resolution time + */ + public QueryFilterParams queryParams() { + return copyQueryParams(queryParams); + } + + /** + * Returns the mutable request context for advanced application checks. + * + *

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 pathParameters = new HashMap<>(); + private ThingRoute route; + private HttpHeadersBlock headers; + private QueryFilterParams queryParams; + private ThingifierRequestContext requestContext; + private EntityDefinition targetEntity; + private String targetIdentifier; + private EntityDefinition parentEntity; + private String parentIdentifier; + private String relationshipName; + private String childIdentifier; + + /** + * @param sessionName scoped-session definition name + */ + public Builder sessionName(final String sessionName) { + this.sessionName = sessionName; + return this; + } + + /** + * @param credential credential value from the configured source + */ + public Builder credential(final String credential) { + this.credential = credential; + return this; + } + + /** + * @param credentialSourceType kind of request source that supplied the credential + */ + public Builder credentialSourceType( + final ThingifierApiScopedSessionCredentialSourceType credentialSourceType) { + this.credentialSourceType = credentialSourceType; + return this; + } + + /** + * @param credentialSourceName configured header, query parameter, or cookie name + */ + public Builder credentialSourceName(final String credentialSourceName) { + this.credentialSourceName = credentialSourceName; + return this; + } + + /** + * @param verb generated routing verb + */ + public Builder verb(final RoutingVerb verb) { + this.verb = verb; + return this; + } + + /** + * @param path public request path + */ + public Builder path(final String path) { + this.path = path; + return this; + } + + /** + * @param pathParameters matched path parameters + */ + public Builder pathParameters(final Map pathParameters) { + this.pathParameters = pathParameters == null ? new HashMap<>() : pathParameters; + return this; + } + + /** + * @param route resolved Thingifier route + */ + public Builder route(final ThingRoute route) { + this.route = route; + return this; + } + + /** + * @param headers request headers + */ + public Builder headers(final HttpHeadersBlock headers) { + this.headers = headers; + return this; + } + + /** + * @param queryParams request query parameters + */ + public Builder queryParams(final QueryFilterParams queryParams) { + this.queryParams = queryParams; + return this; + } + + /** + * @param requestContext active request context + */ + public Builder requestContext(final ThingifierRequestContext requestContext) { + this.requestContext = requestContext; + return this; + } + + /** + * @param targetEntity target entity + */ + public Builder targetEntity(final EntityDefinition targetEntity) { + this.targetEntity = targetEntity; + return this; + } + + /** + * @param targetIdentifier target identifier + */ + public Builder targetIdentifier(final String targetIdentifier) { + this.targetIdentifier = targetIdentifier; + return this; + } + + /** + * @param parentEntity relationship parent entity + */ + public Builder parentEntity(final EntityDefinition parentEntity) { + this.parentEntity = parentEntity; + return this; + } + + /** + * @param parentIdentifier relationship parent identifier + */ + public Builder parentIdentifier(final String parentIdentifier) { + this.parentIdentifier = parentIdentifier; + return this; + } + + /** + * @param relationshipName relationship name + */ + public Builder relationshipName(final String relationshipName) { + this.relationshipName = relationshipName; + return this; + } + + /** + * @param childIdentifier relationship child identifier + */ + public Builder childIdentifier(final String childIdentifier) { + this.childIdentifier = childIdentifier; + return this; + } + + /** + * @return immutable scoped-session resolver context + */ + public ThingifierApiScopedSessionContext build() { + return new ThingifierApiScopedSessionContext(this); + } + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionCredentialSourceType.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionCredentialSourceType.java new file mode 100644 index 00000000..f84259d5 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionCredentialSourceType.java @@ -0,0 +1,19 @@ +package uk.co.compendiumdev.thingifier.api.security; + +/** + * Identifies where a scoped-session credential is read from. + * + *

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 definition() { + return Optional.ofNullable(definition); + } + + /** + * @return true when missing credentials should use the default scope + */ + public boolean allowsAnonymousDefaultScope() { + return mode == Mode.ALLOW_ANONYMOUS_DEFAULT_SCOPE; + } + + /** + * @return true when missing credentials should reject the request + */ + public boolean requiresAuthenticatedScope() { + return mode == Mode.REQUIRE_AUTHENTICATED_SCOPE; + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionResult.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionResult.java new file mode 100644 index 00000000..e443ed04 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionResult.java @@ -0,0 +1,199 @@ +package uk.co.compendiumdev.thingifier.api.security; + +import java.util.Optional; +import uk.co.compendiumdev.thingifier.api.response.ApiResponse; + +/** + * Result returned by a scoped-session resolver. + * + *

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 dataScopeSelection() { + return Optional.ofNullable(dataScopeSelection); + } + + private ThingifierApiScopedSessionResult withDataScopeSelection( + final ThingifierApiDataScopeSelection selection) { + return new ThingifierApiScopedSessionResult( + authenticated, principal, rejectionResponse, customRejectionResponse, selection); + } + + private void requireAuthenticatedForDataScopeSelection() { + if (!authenticated) { + throw new IllegalStateException( + "data scopes can only be selected by authenticated scoped-session results"); + } + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiPathRule.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiPathRule.java index 8c0f4ffc..c8ab08ab 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiPathRule.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiPathRule.java @@ -41,4 +41,60 @@ public ThingifierApiPathRule methodNotAllowed(final RoutingVerb... verbs) { } return this; } + + /** + * Allows selected methods on this path to use the default data scope when the scoped-session + * credential is absent. + * + * @param verbs generated methods that should allow anonymous default-scope access + * @return this path rule so more path-level configuration can be chained + */ + public ThingifierApiPathRule allowAnonymousUsingDefaultScope(final RoutingVerb... verbs) { + requireVerbs("allowAnonymousUsingDefaultScope", verbs); + for (RoutingVerb verb : verbs) { + apiSpec.routeFor(verb, pathPattern).allowAnonymousUsingDefaultScope(); + } + return this; + } + + /** + * Requires a named scoped session for selected methods on this path. + * + * @param sessionName named scoped-session definition + * @param verbs generated methods that should require the scoped session + * @return this path rule so more path-level configuration can be chained + */ + public ThingifierApiPathRule requireScopedSession( + final String sessionName, final RoutingVerb... verbs) { + requireVerbs("requireScopedSession", verbs); + for (RoutingVerb verb : verbs) { + apiSpec.routeFor(verb, pathPattern).requireScopedSession(sessionName); + } + return this; + } + + /** + * Disables scoped-session resolution for selected methods on this path. + * + * @param verbs generated methods that should ignore scoped-session contract defaults + * @return this path rule so more path-level configuration can be chained + */ + public ThingifierApiPathRule disableScopedSession(final RoutingVerb... verbs) { + requireVerbs("disableScopedSession", verbs); + for (RoutingVerb verb : verbs) { + apiSpec.routeFor(verb, pathPattern).disableScopedSession(); + } + return this; + } + + private void requireVerbs(final String methodName, final RoutingVerb... verbs) { + if (verbs == null || verbs.length == 0) { + throw new IllegalArgumentException(methodName + " requires at least one verb"); + } + for (RoutingVerb verb : verbs) { + if (verb == null) { + throw new IllegalArgumentException(methodName + " requires non-null verbs"); + } + } + } } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiRouteRule.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiRouteRule.java index 017ffd82..9d86d6c9 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiRouteRule.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiRouteRule.java @@ -46,6 +46,8 @@ public final class ThingifierApiRouteRule { private String apiKeyAuthSchemeName; private String enforcedApiKeyAuthSchemeName; private final List authEnforcementSchemeNames; + private ScopedSessionMode scopedSessionMode; + private String scopedSessionName; private final List authorizers; private final List apiOperationValidators; private final List operationCallbacks; @@ -82,6 +84,8 @@ public final class ThingifierApiRouteRule { this.apiKeyAuthSchemeName = SecuritySchemeNames.DEFAULT_API_KEY_AUTH_SCHEME; this.enforcedApiKeyAuthSchemeName = null; this.authEnforcementSchemeNames = new java.util.ArrayList<>(); + this.scopedSessionMode = ScopedSessionMode.INHERIT; + this.scopedSessionName = null; this.authorizers = new java.util.ArrayList<>(); this.apiOperationValidators = new java.util.ArrayList<>(); this.operationCallbacks = new java.util.ArrayList<>(); @@ -103,6 +107,27 @@ public final class ThingifierApiRouteRule { this.relationshipWriteOperations = null; } + /** + * Route-level scoped-session behaviour. + * + *

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 relationshipWritePolicyRules; private final ThingifierApiSecuritySpec securitySpec; private final Map authenticators; + private final Map scopedSessions; public ThingifierApiSpec() { routeRules = new ArrayList<>(); @@ -49,6 +55,7 @@ public ThingifierApiSpec() { relationshipWritePolicyRules = new ArrayList<>(); securitySpec = new ThingifierApiSecuritySpec(); authenticators = new HashMap<>(); + scopedSessions = new LinkedHashMap<>(); } /** @@ -130,6 +137,22 @@ public ThingifierApiSpec authenticator( return this; } + /** + * Returns a named scoped-session definition, creating it when needed. + * + *

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 authenticatorFor(final String scheme authenticators.get(SecuritySchemeNames.requireValid(schemeName))); } + /** + * Finds a scoped-session definition by name. + * + * @param sessionName scoped-session definition name + * @return configured definition when present + */ + public Optional scopedSessionNamed( + final String sessionName) { + return Optional.ofNullable( + scopedSessions.get(SecuritySchemeNames.requireValid(sessionName))); + } + + /** + * Resolves the scoped-session policy for one route. + * + *

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 scopedSessionPolicyFor( + final RoutingVerb verb, final String path, final String apiPathPrefix) { + if (verb == null) { + return Optional.empty(); + } + + final Optional matchingRule = ruleFor(verb, path, apiPathPrefix); + if (matchingRule.isPresent()) { + final ThingifierApiRouteRule rule = matchingRule.get(); + if (rule.isDisabled() || rule.isMethodNotAllowed()) { + return Optional.empty(); + } + switch (rule.scopedSessionMode()) { + case DISABLED: + return Optional.empty(); + case ALLOW_ANONYMOUS_DEFAULT_SCOPE: + return explicitScopedSessionPolicy( + rule.scopedSessionName(), Mode.ALLOW_ANONYMOUS_DEFAULT_SCOPE); + case REQUIRE_AUTHENTICATED_SCOPE: + return explicitScopedSessionPolicy( + rule.scopedSessionName(), Mode.REQUIRE_AUTHENTICATED_SCOPE); + case INHERIT: + default: + break; + } + } + + return contractDefaultScopedSessionPolicyFor(verb); + } + /** * Resolves the request entity view for a route and entity. * @@ -547,6 +625,72 @@ ThingifierApiRouteRule routeFor(final RoutingVerb verb, final String pathPattern .orElseGet(() -> route(verb, pathPattern)); } + private Optional explicitScopedSessionPolicy( + final String sessionName, final Mode mode) { + if (sessionName != null) { + return Optional.of( + scopedSessionNamed(sessionName) + .map( + definition -> + ThingifierApiScopedSessionPolicy.configured( + definition, mode)) + .orElseGet( + () -> + ThingifierApiScopedSessionPolicy.unresolved( + sessionName, mode))); + } + + final Optional defaultDefinition = + defaultScopedSessionDefinition(); + if (defaultDefinition.isPresent()) { + return Optional.of( + ThingifierApiScopedSessionPolicy.configured(defaultDefinition.get(), mode)); + } + return Optional.of(ThingifierApiScopedSessionPolicy.unresolved("scopedSession", mode)); + } + + private Optional contractDefaultScopedSessionPolicyFor( + final RoutingVerb verb) { + if (isReadVerb(verb)) { + return scopedSessions.values().stream() + .filter( + ThingifierApiScopedSessionDefinition + ::allowsAnonymousDefaultScopeForReads) + .findFirst() + .map( + definition -> + ThingifierApiScopedSessionPolicy.configured( + definition, Mode.ALLOW_ANONYMOUS_DEFAULT_SCOPE)); + } + if (isWriteVerb(verb)) { + return scopedSessions.values().stream() + .filter( + ThingifierApiScopedSessionDefinition + ::requiresAuthenticatedScopeForWrites) + .findFirst() + .map( + definition -> + ThingifierApiScopedSessionPolicy.configured( + definition, Mode.REQUIRE_AUTHENTICATED_SCOPE)); + } + return Optional.empty(); + } + + private Optional defaultScopedSessionDefinition() { + return scopedSessions.values().stream().findFirst(); + } + + private boolean isReadVerb(final RoutingVerb verb) { + return verb == RoutingVerb.GET || verb == RoutingVerb.HEAD || verb == RoutingVerb.QUERY; + } + + private boolean isWriteVerb(final RoutingVerb verb) { + return verb == RoutingVerb.POST + || verb == RoutingVerb.PUT + || verb == RoutingVerb.PATCH + || verb == RoutingVerb.DELETE; + } + /** * Resolves entity write operation policy for a route. * @@ -767,6 +911,38 @@ private void applyEntityDefaultsTo( } } + /** + * Documents required header-based scoped sessions as API-key security schemes. + * + *

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 seenDataScope = new AtomicReference<>(); + final AtomicReference seenPrincipal = new AtomicReference<>(); + scopedSession(thingifier).authenticateWith(this::validScopedSession); + thingifier + .apiSpec() + .route(RoutingVerb.POST, "/todos") + .requireScopedSession("challenger") + .authorizeWith( + context -> { + seenDataScope.set(context.dataScopeName()); + seenPrincipal.set(context.principal()); + return ThingifierApiAuthorizationResult.authorized(); + }); + + final ApiResponse response = + thingifier + .api() + .post( + "todos", + parser(thingifier, "{\"title\":\"tenant todo\"}"), + headersWithScopedCredential("valid-session")); + + Assertions.assertEquals(201, response.getStatusCode()); + Assertions.assertEquals("tenant-one", seenDataScope.get()); + Assertions.assertEquals("scoped-principal", seenPrincipal.get()); + } + + @Test + void explicitRouteAuthDataScopeOverridesScopedSessionDataScope() { + final Thingifier thingifier = todoModel(); + scopedSession(thingifier).authenticateWith(this::validScopedSession); + thingifier + .apiSpec() + .authenticator( + "tenantToken", + context -> + ThingifierApiAuthenticationResult.authenticated("route-principal") + .useDataScope("route-tenant", ENSURE_EXISTS)); + thingifier + .apiSpec() + .route(RoutingVerb.POST, "/todos") + .requireScopedSession("challenger") + .secureWithBearerAuth("tenantToken"); + + final ApiResponse response = + thingifier + .api() + .post( + "todos", + parser(thingifier, "{\"title\":\"route tenant todo\"}"), + headersWithScopedAndBearer("valid-session", "valid-token")); + + Assertions.assertEquals(201, response.getStatusCode()); + Assertions.assertEquals(0, todoCount(thingifier, "tenant-one")); + Assertions.assertEquals(1, todoCount(thingifier, "route-tenant")); + } + + @Test + void explicitRouteAuthFailureStopsAfterScopedSessionSucceeds() { + final Thingifier thingifier = todoModel(); + scopedSession(thingifier).authenticateWith(this::validScopedSession); + thingifier + .apiSpec() + .authenticator( + "tenantToken", + context -> ThingifierApiAuthenticationResult.rejected(403, "Forbidden")); + thingifier + .apiSpec() + .route(RoutingVerb.POST, "/todos") + .requireScopedSession("challenger") + .secureWithBearerAuth("tenantToken"); + + final ApiResponse response = + thingifier + .api() + .post( + "todos", + parser(thingifier, "{\"title\":\"blocked\"}"), + headersWithScopedAndBearer("valid-session", "valid-token")); + + Assertions.assertEquals(403, response.getStatusCode()); + Assertions.assertEquals(0, todoCount(thingifier, "tenant-one")); + } + + @Test + void validatorReceivesScopedSessionSelectedStore() { + final Thingifier thingifier = todoModel(); + final AtomicReference seenStore = new AtomicReference<>(); + thingifier + .getDefinitionNamed("todo") + .withDomainValidation( + context -> { + seenStore.set(context.store()); + return new ValidationReport(); + }); + scopedSession(thingifier).authenticateWith(this::validScopedSession); + thingifier.apiSpec().route(RoutingVerb.POST, "/todos").requireScopedSession("challenger"); + + final ApiResponse response = + thingifier + .api() + .post( + "todos", + parser(thingifier, "{\"title\":\"tenant todo\"}"), + headersWithScopedCredential("valid-session")); + + Assertions.assertEquals(201, response.getStatusCode()); + Assertions.assertSame(thingifier.getStore("tenant-one"), seenStore.get()); + } + + @Test + void operationCallbackReceivesScopedSessionPrincipalAndScope() { + final Thingifier thingifier = todoModel(); + final AtomicReference seenDataScope = new AtomicReference<>(); + final AtomicReference seenPrincipal = new AtomicReference<>(); + scopedSession(thingifier).authenticateWith(this::validScopedSession); + thingifier + .apiSpec() + .route(RoutingVerb.POST, "/todos") + .requireScopedSession("challenger") + .afterSuccessfulOperation( + (context, result) -> { + seenDataScope.set(context.dataScopeName()); + seenPrincipal.set(context.authenticatedPrincipal("challenger")); + }); + + final ApiResponse response = + thingifier + .api() + .post( + "todos", + parser(thingifier, "{\"title\":\"tenant todo\"}"), + headersWithScopedCredential("valid-session")); + + Assertions.assertEquals(201, response.getStatusCode()); + Assertions.assertEquals("tenant-one", seenDataScope.get()); + Assertions.assertEquals("scoped-principal", seenPrincipal.get()); + } + + @Test + void httpBodyParsedHookReceivesScopedSessionSelectedScope() { + final Thingifier thingifier = todoModel(); + thingifier.apiConfig().setFrom(new ThingifierApiConfig("/api")); + final AtomicReference seenDataScope = new AtomicReference<>(); + final ThingifierApiLifecycleHookRegistry hooks = new ThingifierApiLifecycleHookRegistry(); + hooks.registerBodyParsedHook(context -> seenDataScope.set(context.dataScopeName())); + scopedSession(thingifier).authenticateWith(this::validScopedSession); + thingifier + .apiSpec() + .route(RoutingVerb.POST, "/api/todos") + .requireScopedSession("challenger"); + + final HttpApiResponse response = + ThingifierHttpApi.withHookRegistries(thingifier, null, hooks) + .post( + jsonPost("/api/todos", "{\"title\":\"tenant todo\"}") + .addHeader("X-CHALLENGER", "valid-session")); + + Assertions.assertEquals(201, response.getStatusCode()); + Assertions.assertEquals("tenant-one", seenDataScope.get()); + } + + @Test + void validCredentialWithoutDataScopeSelectionPreservesSessionHeaderScope() { + final Thingifier thingifier = todoModel(); + scopedSession(thingifier) + .authenticateWith( + context -> ThingifierApiScopedSessionResult.authenticated("principal")) + .requireAuthenticatedScopeForWrites(); + + final ApiResponse response = + thingifier + .api() + .post( + "todos", + parser(thingifier, "{\"title\":\"tenant todo\"}"), + headersWithSessionAndScoped("tenant-one", "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 requiredScopedSessionHeaderIsDocumentedAsApiKeySecurity() { + final Thingifier thingifier = todoModel(); + scopedSession(thingifier) + .authenticateWith(this::validScopedSession) + .requireAuthenticatedScopeForWrites(); + final ThingifierApiDocumentationDefn apiDefn = new ThingifierApiDocumentationDefn(); + apiDefn.setThingifier(thingifier); + + final OpenAPI openApi = + new uk.co.compendiumdev.thingifier.swaggerizer.Swaggerizer(apiDefn).swagger(); + + final SecurityScheme scheme = + openApi.getComponents().getSecuritySchemes().get("challenger"); + Assertions.assertNotNull(scheme); + Assertions.assertEquals(SecurityScheme.Type.APIKEY, scheme.getType()); + Assertions.assertEquals(SecurityScheme.In.HEADER, scheme.getIn()); + Assertions.assertEquals("X-CHALLENGER", scheme.getName()); + Assertions.assertEquals( + "challenger", + openApi.getPaths() + .get("/todos") + .getPost() + .getSecurity() + .get(0) + .keySet() + .iterator() + .next()); + } + + private ThingifierApiScopedSessionDefinition scopedSession(final Thingifier thingifier) { + return thingifier.apiSpec().scopedSession("challenger").fromHeader("X-CHALLENGER"); + } + + private ThingifierApiScopedSessionResult validScopedSession( + final ThingifierApiScopedSessionContext context) { + if ("valid-session".equals(context.credential())) { + return ThingifierApiScopedSessionResult.authenticated("scoped-principal") + .useDataScope("tenant-one", ENSURE_EXISTS); + } + return ThingifierApiScopedSessionResult.unauthenticated(); + } + + private Thingifier todoModel() { + final Thingifier thingifier = new Thingifier(); + final EntityDefinition todo = thingifier.defineThing("todo", "todos", 5); + todo.addAsPrimaryKeyField(Field.is("id", FieldType.AUTO_INCREMENT)); + todo.addField(Field.is("title", FieldType.STRING).makeMandatory()); + return thingifier; + } + + private EntityInstance createTodo( + final Thingifier thingifier, final String dataScopeName, final String title) { + thingifier.getERmodel().createInstanceDatabaseIfNotExisting(dataScopeName); + final EntityDefinition todo = thingifier.getDefinitionNamed("todo"); + return thingifier + .getStore(dataScopeName) + .entities() + .create(EntityInstanceDraft.forEntity(todo).withField("title", title)); + } + + private int todoCount(final Thingifier thingifier, final String dataScopeName) { + return thingifier.listThingInstancesNamed("todos", dataScopeName).size(); + } + + private String firstReturnedTodoTitle(final ApiResponse response) { + return response.getReturnedInstanceCollection().get(0).getFieldValue("title").asString(); + } + + private BodyParser parser(final Thingifier thingifier, final String body) { + return new BodyParser( + new HttpApiRequest("/request") + .addHeader("Content-Type", "application/json") + .setBody(body), + thingifier.getThingNames()); + } + + private HttpApiRequest jsonPost(final String path, final String body) { + return new HttpApiRequest(path).addHeader("Content-Type", "application/json").setBody(body); + } + + private HttpHeadersBlock headersWithScopedCredential(final String credential) { + HttpHeadersBlock headers = new HttpHeadersBlock(); + headers.put("X-CHALLENGER", credential); + return headers; + } + + private HttpHeadersBlock headersWithSession(final String dataScopeName) { + HttpHeadersBlock headers = new HttpHeadersBlock(); + headers.put(ThingifierHttpApi.HTTP_SESSION_HEADER_NAME, dataScopeName); + return headers; + } + + private HttpHeadersBlock headersWithSessionAndScoped( + final String dataScopeName, final String credential) { + HttpHeadersBlock headers = headersWithSession(dataScopeName); + headers.put("X-CHALLENGER", credential); + return headers; + } + + private HttpHeadersBlock headersWithScopedAndBearer( + final String scopedCredential, final String bearerToken) { + HttpHeadersBlock headers = headersWithScopedCredential(scopedCredential); + headers.put("Authorization", "Bearer " + bearerToken); + return headers; + } + + private HttpHeadersBlock headersWithCookie(final String cookieHeader) { + HttpHeadersBlock headers = new HttpHeadersBlock(); + headers.put("Cookie", cookieHeader); + return headers; + } +} diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionResultTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionResultTest.java new file mode 100644 index 00000000..6d856105 --- /dev/null +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiScopedSessionResultTest.java @@ -0,0 +1,59 @@ +package uk.co.compendiumdev.thingifier.api.security; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import uk.co.compendiumdev.thingifier.core.EntityRelModel; + +class ThingifierApiScopedSessionResultTest { + + @Test + void authenticatedResultHasNoDataScopeSelectionByDefault() { + final ThingifierApiScopedSessionResult result = + ThingifierApiScopedSessionResult.authenticated("principal"); + + Assertions.assertTrue(result.isAuthenticated()); + Assertions.assertEquals("principal", result.principal()); + Assertions.assertTrue(result.dataScopeSelection().isEmpty()); + } + + @Test + void authenticatedResultCanSelectNamedDataScope() { + final ThingifierApiScopedSessionResult result = + ThingifierApiScopedSessionResult.authenticated("principal") + .useDataScope("tenant-one", DataScopeCreationPolicy.ENSURE_EXISTS); + + final ThingifierApiDataScopeSelection selection = result.dataScopeSelection().orElseThrow(); + Assertions.assertEquals("tenant-one", selection.dataScopeName()); + Assertions.assertEquals(DataScopeCreationPolicy.ENSURE_EXISTS, selection.creationPolicy()); + } + + @Test + void authenticatedResultCanSelectDefaultDataScope() { + final ThingifierApiScopedSessionResult result = + ThingifierApiScopedSessionResult.authenticated("principal").useDefaultDataScope(); + + final ThingifierApiDataScopeSelection selection = result.dataScopeSelection().orElseThrow(); + Assertions.assertEquals(EntityRelModel.DEFAULT_DATABASE_NAME, selection.dataScopeName()); + Assertions.assertTrue(selection.isExplicit()); + } + + @ParameterizedTest + @ValueSource(strings = {"", " ", "../tenant", "tenant/one", "tenant one"}) + void namedDataScopeRejectsUnsafeNames(final String dataScopeName) { + final ThingifierApiScopedSessionResult result = + ThingifierApiScopedSessionResult.authenticated("principal"); + + Assertions.assertThrows( + IllegalArgumentException.class, () -> result.useDataScope(dataScopeName)); + } + + @Test + void unauthenticatedResultCannotSelectDataScope() { + final ThingifierApiScopedSessionResult result = + ThingifierApiScopedSessionResult.unauthenticated(); + + Assertions.assertThrows(IllegalStateException.class, result::useDefaultDataScope); + } +}