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 index 67e7e608..363f8b94 100644 --- 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 @@ -107,8 +107,15 @@ public ApiResponse rejectIfNotResolved( if (policy.requiresAuthenticatedScope()) { return definition.missingRequiredCredentialResponse(); } - return dataScopeSelectionApplier.apply( - context, ThingifierApiDataScopeSelection.defaultDataScope()); + return applyAnonymousScope( + policy, + definition, + verb, + path, + context, + resolvedRoute, + matchingRule, + safeQueryParams(queryParams)); } if (definition.authenticator() == null) { @@ -151,11 +158,59 @@ public ApiResponse rejectIfNotResolved( context, resolvedRoute, matchingRule.get()), - result); + result.principal()); } return null; } + private ApiResponse applyAnonymousScope( + final ThingifierApiScopedSessionPolicy policy, + final ThingifierApiScopedSessionDefinition definition, + final RoutingVerb verb, + final String path, + final ThingifierRequestContext context, + final ThingRoute route, + final Optional matchingRule, + final QueryFilterParams queryParams) { + final ThingifierApiDataScopeSelection selection = + anonymousDataScopeSelection( + policy, definition, verb, path, context, route, queryParams); + if (selection == null) { + return ApiResponse.error( + 500, + "No anonymous data scope selected for scoped-session " + definition.name()); + } + + final ApiResponse dataScopeResponse = dataScopeSelectionApplier.apply(context, selection); + if (dataScopeResponse != null) { + return dataScopeResponse; + } + + if (matchingRule.isPresent() && !matchingRule.get().hasAuthEnforcement()) { + return rejectIfUnauthorizedByAuthorizer( + matchingRule.get(), + scopedSessionAuthContext( + definition, "", verb, path, context, route, matchingRule.get()), + null); + } + return null; + } + + private ThingifierApiDataScopeSelection anonymousDataScopeSelection( + final ThingifierApiScopedSessionPolicy policy, + final ThingifierApiScopedSessionDefinition definition, + final RoutingVerb verb, + final String path, + final ThingifierRequestContext context, + final ThingRoute route, + final QueryFilterParams queryParams) { + if (policy.allowsAnonymousDefaultScope()) { + return ThingifierApiDataScopeSelection.defaultDataScope(); + } + return definition.anonymousDataScopeSelection( + scopedSessionContext(definition, "", verb, path, context, route, queryParams)); + } + private ApiResponse scopedSessionRejected( final ThingifierApiScopedSessionDefinition definition, final ThingifierApiScopedSessionResult result) { @@ -238,12 +293,12 @@ private ThingifierApiAuthenticationContext scopedSessionAuthContext( private ApiResponse rejectIfUnauthorizedByAuthorizer( final ThingifierApiRouteRule rule, final ThingifierApiAuthenticationContext authenticationContext, - final ThingifierApiScopedSessionResult authentication) { + final Object principal) { for (ThingifierApiAuthorizer authorizer : rule.authorizers()) { final ThingifierApiAuthorizationResult authorization = authorizer.authorize( new ThingifierApiAuthorizationContext( - authenticationContext, authentication.principal())); + authenticationContext, principal)); if (authorization == null || !authorization.isAuthorized()) { return authorizationRejected(authorization); } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiAnonymousDataScopeResolver.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiAnonymousDataScopeResolver.java new file mode 100644 index 00000000..4aa9db8f --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiAnonymousDataScopeResolver.java @@ -0,0 +1,20 @@ +package uk.co.compendiumdev.thingifier.api.security; + +/** + * Chooses the data scope for a missing scoped-session credential. + * + *

This callback is trusted server-side configuration, not a request-controlled database mapper. + * Thingifier calls it only after route matching has determined that anonymous read access is + * allowed, and before validators, authorizers, hooks, handlers, and response rendering run. + */ +@FunctionalInterface +public interface ThingifierApiAnonymousDataScopeResolver { + + /** + * Selects the data scope to use for an anonymous read request. + * + * @param context immutable route and request context for the missing credential request + * @return trusted data-scope selection, or null to signal a configuration error + */ + ThingifierApiDataScopeSelection selectDataScope(ThingifierApiScopedSessionContext context); +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiDataScopeSelection.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiDataScopeSelection.java index bff68424..944607b0 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiDataScopeSelection.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/security/ThingifierApiDataScopeSelection.java @@ -40,6 +40,31 @@ public static ThingifierApiDataScopeSelection named( requireValidDataScopeName(dataScopeName), creationPolicy, true); } + /** + * Creates an explicit selection for the named data scope using the safest missing-scope policy. + * + *

This reads naturally in callback code where the application is choosing the active scope + * after validating or resolving request state. + * + * @param dataScopeName data scope chosen by trusted application code + * @return immutable data-scope selection + */ + public static ThingifierApiDataScopeSelection useDataScope(final String dataScopeName) { + return named(dataScopeName, DataScopeCreationPolicy.USE_EXISTING_ONLY); + } + + /** + * Creates an explicit selection for the named data scope. + * + * @param dataScopeName data scope chosen by trusted application code + * @param creationPolicy missing-scope handling policy + * @return immutable data-scope selection + */ + public static ThingifierApiDataScopeSelection useDataScope( + final String dataScopeName, final DataScopeCreationPolicy creationPolicy) { + return named(dataScopeName, creationPolicy); + } + /** * Creates an explicit selection for the model's default data scope. * @@ -54,6 +79,15 @@ public static ThingifierApiDataScopeSelection defaultDataScope() { true); } + /** + * Creates an explicit selection for the model's default data scope. + * + * @return immutable default data-scope selection + */ + public static ThingifierApiDataScopeSelection useDefaultDataScope() { + return defaultDataScope(); + } + /** * Returns the selected data scope name. * 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 index 9846e087..58e10fd9 100644 --- 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 @@ -1,5 +1,6 @@ package uk.co.compendiumdev.thingifier.api.security; +import java.util.Optional; import uk.co.compendiumdev.thingifier.api.response.ApiResponse; /** @@ -15,7 +16,9 @@ public final class ThingifierApiScopedSessionDefinition { private ThingifierApiScopedSessionCredentialSourceType credentialSourceType; private String credentialSourceName; private ThingifierApiScopedSessionAuthenticator authenticator; + private boolean anonymousScopeForReads; private boolean anonymousDefaultScopeForReads; + private ThingifierApiAnonymousDataScopeResolver anonymousDataScopeResolver; private boolean authenticatedScopeForWrites; private int missingCredentialStatusCode; private String missingCredentialMessage; @@ -99,7 +102,61 @@ public ThingifierApiScopedSessionDefinition authenticateWith( * @return this definition for fluent configuration */ public ThingifierApiScopedSessionDefinition allowAnonymousDefaultScopeForReads() { + this.anonymousScopeForReads = true; this.anonymousDefaultScopeForReads = true; + this.anonymousDataScopeResolver = + context -> ThingifierApiDataScopeSelection.defaultDataScope(); + return this; + } + + /** + * Allows read-style generated routes to use a named data scope when no credential is supplied. + * + *

The data scope is selected by trusted application configuration, not by the incoming + * request. If a credential is supplied, Thingifier still validates it and invalid credentials + * reject in v1. + * + * @param dataScopeName anonymous read data scope + * @return this definition for fluent configuration + */ + public ThingifierApiScopedSessionDefinition allowAnonymousReadsUsingDataScope( + final String dataScopeName) { + return allowAnonymousReadsUsingDataScope( + dataScopeName, DataScopeCreationPolicy.USE_EXISTING_ONLY); + } + + /** + * Allows read-style generated routes to use a named data scope when no credential is supplied. + * + * @param dataScopeName anonymous read data scope + * @param creationPolicy policy used when the anonymous scope does not exist + * @return this definition for fluent configuration + */ + public ThingifierApiScopedSessionDefinition allowAnonymousReadsUsingDataScope( + final String dataScopeName, final DataScopeCreationPolicy creationPolicy) { + final ThingifierApiDataScopeSelection selection = + ThingifierApiDataScopeSelection.useDataScope(dataScopeName, creationPolicy); + return allowAnonymousReadsUsingDataScope(context -> selection); + } + + /** + * Allows read-style generated routes to resolve the anonymous data scope dynamically. + * + *

The resolver runs only when the scoped-session credential is missing and anonymous read + * access is allowed for the route. It is intended for application-owned decisions such as + * choosing a public tenant, demo workspace, or single-player data scope from server-side state. + * + * @param resolver trusted anonymous data-scope resolver + * @return this definition for fluent configuration + */ + public ThingifierApiScopedSessionDefinition allowAnonymousReadsUsingDataScope( + final ThingifierApiAnonymousDataScopeResolver resolver) { + if (resolver == null) { + throw new IllegalArgumentException("anonymous data-scope resolver is required"); + } + this.anonymousScopeForReads = true; + this.anonymousDefaultScopeForReads = false; + this.anonymousDataScopeResolver = resolver; return this; } @@ -171,12 +228,42 @@ public ThingifierApiScopedSessionAuthenticator authenticator() { } /** - * @return true when read-style routes may fall back to the default scope + * @return true when read-style routes may fall back to an anonymous scope + */ + public boolean allowsAnonymousScopeForReads() { + return anonymousScopeForReads; + } + + /** + * @return true when read-style routes may fall back specifically to the default scope */ public boolean allowsAnonymousDefaultScopeForReads() { return anonymousDefaultScopeForReads; } + /** + * Returns the trusted resolver used for missing-credential anonymous reads. + * + * @return resolver when anonymous reads are configured + */ + public Optional anonymousDataScopeResolver() { + return Optional.ofNullable(anonymousDataScopeResolver); + } + + /** + * Selects the anonymous data scope for a missing-credential read request. + * + * @param context route and request context + * @return selected data scope, or null if the resolver is absent or returns null + */ + public ThingifierApiDataScopeSelection anonymousDataScopeSelection( + final ThingifierApiScopedSessionContext context) { + if (anonymousDataScopeResolver == null) { + return null; + } + return anonymousDataScopeResolver.selectDataScope(context); + } + /** * @return true when write-style routes require a valid scoped session */ 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 index 8ae23541..d8cbf59d 100644 --- 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 @@ -6,8 +6,8 @@ * 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. + * This object gives runtime handling one simple decision: optional anonymous scope or required + * authenticated scoped session, plus the definition that should resolve credentials. */ public final class ThingifierApiScopedSessionPolicy { @@ -16,6 +16,11 @@ public enum Mode { /** Missing credentials use the default scope, while invalid supplied credentials reject. */ ALLOW_ANONYMOUS_DEFAULT_SCOPE, + /** + * Missing credentials use the anonymous data-scope resolver configured on the definition. + */ + ALLOW_ANONYMOUS_CONFIGURED_SCOPE, + /** Missing or invalid credentials reject before validators and handlers run. */ REQUIRE_AUTHENTICATED_SCOPE } @@ -95,6 +100,20 @@ public boolean allowsAnonymousDefaultScope() { return mode == Mode.ALLOW_ANONYMOUS_DEFAULT_SCOPE; } + /** + * @return true when missing credentials should use the definition's anonymous scope resolver + */ + public boolean allowsAnonymousConfiguredScope() { + return mode == Mode.ALLOW_ANONYMOUS_CONFIGURED_SCOPE; + } + + /** + * @return true when missing credentials should continue anonymously + */ + public boolean allowsAnonymousScope() { + return allowsAnonymousDefaultScope() || allowsAnonymousConfiguredScope(); + } + /** * @return true when missing credentials should reject the request */ 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 cd323c69..9f76be00 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 @@ -653,14 +653,12 @@ private Optional contractDefaultScopedSessionP final RoutingVerb verb) { if (isReadVerb(verb)) { return scopedSessions.values().stream() - .filter( - ThingifierApiScopedSessionDefinition - ::allowsAnonymousDefaultScopeForReads) + .filter(ThingifierApiScopedSessionDefinition::allowsAnonymousScopeForReads) .findFirst() .map( definition -> ThingifierApiScopedSessionPolicy.configured( - definition, Mode.ALLOW_ANONYMOUS_DEFAULT_SCOPE)); + definition, Mode.ALLOW_ANONYMOUS_CONFIGURED_SCOPE)); } if (isWriteVerb(verb)) { return scopedSessions.values().stream() 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 index 0fe83fd5..b98eab53 100644 --- 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 @@ -55,6 +55,93 @@ void missingCredentialOnAnonymousReadUsesDefaultScope() { Assertions.assertEquals(0, resolverCalls.get()); } + @Test + void missingCredentialOnAnonymousReadUsesConfiguredScope() { + final Thingifier thingifier = todoModel(); + createTodo(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME, "default todo"); + createTodo(thingifier, "anonymous-scope", "anonymous todo"); + scopedSession(thingifier) + .authenticateWith(this::validScopedSession) + .allowAnonymousReadsUsingDataScope("anonymous-scope"); + + final ApiResponse response = + thingifier + .api() + .get("todos", new QueryFilterParams(), headersWithSession("tenant-one")); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals("anonymous todo", firstReturnedTodoTitle(response)); + } + + @Test + void anonymousReadResolverChoosesDataScope() { + final Thingifier thingifier = todoModel(); + createTodo(thingifier, "anonymous-scope", "anonymous todo"); + final AtomicReference seenPath = new AtomicReference<>(); + scopedSession(thingifier) + .authenticateWith(this::validScopedSession) + .allowAnonymousReadsUsingDataScope( + context -> { + seenPath.set(context.path()); + return ThingifierApiDataScopeSelection.useDataScope("anonymous-scope"); + }); + + final ApiResponse response = + thingifier.api().get("todos", new QueryFilterParams(), new HttpHeadersBlock()); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals("todos", seenPath.get()); + Assertions.assertEquals("anonymous todo", firstReturnedTodoTitle(response)); + } + + @Test + void anonymousReadEnsuresConfiguredScopeExists() { + final Thingifier thingifier = todoModel(); + scopedSession(thingifier) + .authenticateWith(this::validScopedSession) + .allowAnonymousReadsUsingDataScope("anonymous-scope", ENSURE_EXISTS); + + final ApiResponse response = + thingifier.api().get("todos", new QueryFilterParams(), new HttpHeadersBlock()); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertNotNull(thingifier.getStore("anonymous-scope")); + Assertions.assertEquals(0, todoCount(thingifier, "anonymous-scope")); + } + + @Test + void anonymousReadMissingConfiguredScopeReturns404() { + final Thingifier thingifier = todoModel(); + scopedSession(thingifier) + .authenticateWith(this::validScopedSession) + .allowAnonymousReadsUsingDataScope("anonymous-scope"); + + final ApiResponse response = + thingifier.api().get("todos", new QueryFilterParams(), new HttpHeadersBlock()); + + Assertions.assertEquals(404, response.getStatusCode()); + Assertions.assertEquals( + "Could not find data scope anonymous-scope", + response.getErrorMessages().iterator().next()); + } + + @Test + void routeLevelDefaultScopeOverrideIgnoresConfiguredAnonymousScope() { + final Thingifier thingifier = todoModel(); + createTodo(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME, "default todo"); + createTodo(thingifier, "anonymous-scope", "anonymous todo"); + scopedSession(thingifier) + .authenticateWith(this::validScopedSession) + .allowAnonymousReadsUsingDataScope("anonymous-scope"); + thingifier.apiSpec().route(RoutingVerb.GET, "/todos").allowAnonymousUsingDefaultScope(); + + final ApiResponse response = + thingifier.api().get("todos", new QueryFilterParams(), new HttpHeadersBlock()); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals("default todo", firstReturnedTodoTitle(response)); + } + @Test void validHeaderCredentialOnReadUsesSelectedDataScope() { final Thingifier thingifier = todoModel(); @@ -289,6 +376,55 @@ void authorizerReceivesScopedSessionSelectedScopeAndPrincipal() { Assertions.assertEquals("scoped-principal", seenPrincipal.get()); } + @Test + void authorizerReceivesAnonymousDataScope() { + final Thingifier thingifier = todoModel(); + final AtomicReference seenDataScope = new AtomicReference<>(); + final AtomicReference seenPrincipal = new AtomicReference<>(); + scopedSession(thingifier) + .authenticateWith(this::validScopedSession) + .allowAnonymousReadsUsingDataScope("anonymous-scope", ENSURE_EXISTS); + thingifier + .apiSpec() + .route(RoutingVerb.GET, "/todos") + .authorizeWith( + context -> { + seenDataScope.set(context.dataScopeName()); + seenPrincipal.set(context.principal()); + return ThingifierApiAuthorizationResult.authorized(); + }); + + final ApiResponse response = + thingifier.api().get("todos", new QueryFilterParams(), new HttpHeadersBlock()); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals("anonymous-scope", seenDataScope.get()); + Assertions.assertNull(seenPrincipal.get()); + } + + @Test + void fixedRouteUsesAnonymousDataScope() { + final Thingifier thingifier = todoModel(); + createTodo(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME, "default todo"); + createTodo(thingifier, "anonymous-scope", "anonymous fixed todo"); + scopedSession(thingifier) + .authenticateWith(this::validScopedSession) + .allowAnonymousReadsUsingDataScope("anonymous-scope"); + thingifier + .apiSpec() + .route(RoutingVerb.GET, "/fixed/todo") + .mapsToEntity("todo") + .withFixedIdentifier("1"); + + final HttpApiResponse response = + new ThingifierHttpApi(thingifier).get(new HttpApiRequest("/fixed/todo")); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals( + "anonymous fixed todo", + response.apiResponse().getReturnedInstance().getFieldValue("title").asString()); + } + @Test void explicitRouteAuthDataScopeOverridesScopedSessionDataScope() { final Thingifier thingifier = todoModel();