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.
+ * Thingifier calls it only after route matching has determined that anonymous access is allowed for
+ * the current operation, 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.
+ * Selects the data scope to use for an anonymous 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
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 58e10fd9..63430e66 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
@@ -18,7 +18,9 @@ public final class ThingifierApiScopedSessionDefinition {
private ThingifierApiScopedSessionAuthenticator authenticator;
private boolean anonymousScopeForReads;
private boolean anonymousDefaultScopeForReads;
- private ThingifierApiAnonymousDataScopeResolver anonymousDataScopeResolver;
+ private ThingifierApiAnonymousDataScopeResolver anonymousReadDataScopeResolver;
+ private boolean anonymousScopeForWrites;
+ private ThingifierApiAnonymousDataScopeResolver anonymousWriteDataScopeResolver;
private boolean authenticatedScopeForWrites;
private int missingCredentialStatusCode;
private String missingCredentialMessage;
@@ -104,7 +106,7 @@ public ThingifierApiScopedSessionDefinition authenticateWith(
public ThingifierApiScopedSessionDefinition allowAnonymousDefaultScopeForReads() {
this.anonymousScopeForReads = true;
this.anonymousDefaultScopeForReads = true;
- this.anonymousDataScopeResolver =
+ this.anonymousReadDataScopeResolver =
context -> ThingifierApiDataScopeSelection.defaultDataScope();
return this;
}
@@ -156,17 +158,73 @@ public ThingifierApiScopedSessionDefinition allowAnonymousReadsUsingDataScope(
}
this.anonymousScopeForReads = true;
this.anonymousDefaultScopeForReads = false;
- this.anonymousDataScopeResolver = resolver;
+ this.anonymousReadDataScopeResolver = resolver;
+ return this;
+ }
+
+ /**
+ * Allows write-style generated routes to use a named data scope when no credential is supplied.
+ *
+ *
This is intentionally opt-in for applications that offer public, demo, or single-user
+ * mutable state. If a credential is supplied, Thingifier still validates it and invalid
+ * credentials reject rather than falling back to the anonymous write scope.
+ *
+ * @param dataScopeName anonymous write data scope
+ * @return this definition for fluent configuration
+ */
+ public ThingifierApiScopedSessionDefinition allowAnonymousWritesUsingDataScope(
+ final String dataScopeName) {
+ return allowAnonymousWritesUsingDataScope(
+ dataScopeName, DataScopeCreationPolicy.USE_EXISTING_ONLY);
+ }
+
+ /**
+ * Allows write-style generated routes to use a named data scope when no credential is supplied.
+ *
+ * @param dataScopeName anonymous write data scope
+ * @param creationPolicy policy used when the anonymous scope does not exist
+ * @return this definition for fluent configuration
+ */
+ public ThingifierApiScopedSessionDefinition allowAnonymousWritesUsingDataScope(
+ final String dataScopeName, final DataScopeCreationPolicy creationPolicy) {
+ final ThingifierApiDataScopeSelection selection =
+ ThingifierApiDataScopeSelection.useDataScope(dataScopeName, creationPolicy);
+ return allowAnonymousWritesUsingDataScope(context -> selection);
+ }
+
+ /**
+ * Allows write-style generated routes to resolve the anonymous data scope dynamically.
+ *
+ *
The resolver runs only when the scoped-session credential is missing and anonymous write
+ * access is allowed for the route. Configuring anonymous writes clears the authenticated-write
+ * requirement so fluent write policy calls have predictable last-call-wins behaviour.
+ *
+ * @param resolver trusted anonymous data-scope resolver
+ * @return this definition for fluent configuration
+ */
+ public ThingifierApiScopedSessionDefinition allowAnonymousWritesUsingDataScope(
+ final ThingifierApiAnonymousDataScopeResolver resolver) {
+ if (resolver == null) {
+ throw new IllegalArgumentException("anonymous write data-scope resolver is required");
+ }
+ this.anonymousScopeForWrites = true;
+ this.anonymousWriteDataScopeResolver = resolver;
+ this.authenticatedScopeForWrites = false;
return this;
}
/**
* Requires write-style generated routes to have a valid scoped-session credential.
*
+ *
Configuring required writes clears any anonymous write scope so fluent write policy calls
+ * have predictable last-call-wins behaviour.
+ *
* @return this definition for fluent configuration
*/
public ThingifierApiScopedSessionDefinition requireAuthenticatedScopeForWrites() {
this.authenticatedScopeForWrites = true;
+ this.anonymousScopeForWrites = false;
+ this.anonymousWriteDataScopeResolver = null;
return this;
}
@@ -247,7 +305,19 @@ public boolean allowsAnonymousDefaultScopeForReads() {
* @return resolver when anonymous reads are configured
*/
public Optional anonymousDataScopeResolver() {
- return Optional.ofNullable(anonymousDataScopeResolver);
+ return anonymousReadDataScopeResolver();
+ }
+
+ /**
+ * Returns the trusted resolver used for missing-credential anonymous reads.
+ *
+ *
This named read accessor keeps the read and write anonymous scope policies explicit while
+ * {@link #anonymousDataScopeResolver()} remains as the original read-scope alias.
+ *
+ * @return resolver when anonymous reads are configured
+ */
+ public Optional anonymousReadDataScopeResolver() {
+ return Optional.ofNullable(anonymousReadDataScopeResolver);
}
/**
@@ -258,10 +328,51 @@ public Optional anonymousDataScopeResol
*/
public ThingifierApiDataScopeSelection anonymousDataScopeSelection(
final ThingifierApiScopedSessionContext context) {
- if (anonymousDataScopeResolver == null) {
+ return anonymousReadDataScopeSelection(context);
+ }
+
+ /**
+ * 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 anonymousReadDataScopeSelection(
+ final ThingifierApiScopedSessionContext context) {
+ if (anonymousReadDataScopeResolver == null) {
+ return null;
+ }
+ return anonymousReadDataScopeResolver.selectDataScope(context);
+ }
+
+ /**
+ * @return true when write-style routes may fall back to an anonymous scope
+ */
+ public boolean allowsAnonymousScopeForWrites() {
+ return anonymousScopeForWrites;
+ }
+
+ /**
+ * Returns the trusted resolver used for missing-credential anonymous writes.
+ *
+ * @return resolver when anonymous writes are configured
+ */
+ public Optional anonymousWriteDataScopeResolver() {
+ return Optional.ofNullable(anonymousWriteDataScopeResolver);
+ }
+
+ /**
+ * Selects the anonymous data scope for a missing-credential write request.
+ *
+ * @param context route and request context
+ * @return selected data scope, or null if the resolver is absent or returns null
+ */
+ public ThingifierApiDataScopeSelection anonymousWriteDataScopeSelection(
+ final ThingifierApiScopedSessionContext context) {
+ if (anonymousWriteDataScopeResolver == null) {
return null;
}
- return anonymousDataScopeResolver.selectDataScope(context);
+ return anonymousWriteDataScopeResolver.selectDataScope(context);
}
/**
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 d8cbf59d..a3d49a4e 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
@@ -17,10 +17,17 @@ public enum Mode {
ALLOW_ANONYMOUS_DEFAULT_SCOPE,
/**
- * Missing credentials use the anonymous data-scope resolver configured on the definition.
+ * Missing credentials use the anonymous read data-scope resolver configured on the
+ * definition.
*/
ALLOW_ANONYMOUS_CONFIGURED_SCOPE,
+ /**
+ * Missing credentials use the anonymous write data-scope resolver configured on the
+ * definition.
+ */
+ ALLOW_ANONYMOUS_CONFIGURED_WRITE_SCOPE,
+
/** Missing or invalid credentials reject before validators and handlers run. */
REQUIRE_AUTHENTICATED_SCOPE
}
@@ -107,11 +114,20 @@ public boolean allowsAnonymousConfiguredScope() {
return mode == Mode.ALLOW_ANONYMOUS_CONFIGURED_SCOPE;
}
+ /**
+ * @return true when missing credentials should use the definition's anonymous write resolver
+ */
+ public boolean allowsAnonymousConfiguredWriteScope() {
+ return mode == Mode.ALLOW_ANONYMOUS_CONFIGURED_WRITE_SCOPE;
+ }
+
/**
* @return true when missing credentials should continue anonymously
*/
public boolean allowsAnonymousScope() {
- return allowsAnonymousDefaultScope() || allowsAnonymousConfiguredScope();
+ return allowsAnonymousDefaultScope()
+ || allowsAnonymousConfiguredScope()
+ || allowsAnonymousConfiguredWriteScope();
}
/**
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 9f76be00..b09f727f 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
@@ -661,6 +661,20 @@ private Optional contractDefaultScopedSessionP
definition, Mode.ALLOW_ANONYMOUS_CONFIGURED_SCOPE));
}
if (isWriteVerb(verb)) {
+ final Optional anonymousWritePolicy =
+ scopedSessions.values().stream()
+ .filter(
+ ThingifierApiScopedSessionDefinition
+ ::allowsAnonymousScopeForWrites)
+ .findFirst()
+ .map(
+ definition ->
+ ThingifierApiScopedSessionPolicy.configured(
+ definition,
+ Mode.ALLOW_ANONYMOUS_CONFIGURED_WRITE_SCOPE));
+ if (anonymousWritePolicy.isPresent()) {
+ return anonymousWritePolicy;
+ }
return scopedSessions.values().stream()
.filter(
ThingifierApiScopedSessionDefinition
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 b98eab53..ced936fe 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
@@ -1,6 +1,7 @@
package uk.co.compendiumdev.thingifier.api.security;
import static uk.co.compendiumdev.thingifier.api.security.DataScopeCreationPolicy.ENSURE_EXISTS;
+import static uk.co.compendiumdev.thingifier.apiconfig.EntityPatchUpdateStyle.PARTIAL_JSON_UPDATE;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.security.SecurityScheme;
@@ -247,6 +248,193 @@ void validCredentialOnProtectedWriteWritesToSelectedDataScope() {
Assertions.assertEquals(1, todoCount(thingifier, "tenant-one"));
}
+ @Test
+ void missingCredentialOnAnonymousPostUsesConfiguredWriteScope() {
+ final Thingifier thingifier = todoModel();
+ scopedSession(thingifier)
+ .authenticateWith(this::validScopedSession)
+ .allowAnonymousWritesUsingDataScope("anonymous-scope", ENSURE_EXISTS);
+
+ final ApiResponse response =
+ thingifier
+ .api()
+ .post(
+ "todos",
+ parser(thingifier, "{\"title\":\"anonymous post\"}"),
+ new HttpHeadersBlock());
+
+ Assertions.assertEquals(201, response.getStatusCode());
+ Assertions.assertEquals(0, todoCount(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME));
+ Assertions.assertEquals(1, todoCount(thingifier, "anonymous-scope"));
+ }
+
+ @Test
+ void missingCredentialOnAnonymousPutUsesConfiguredWriteScope() {
+ final Thingifier thingifier = stringIdTodoModel();
+ scopedSession(thingifier)
+ .authenticateWith(this::validScopedSession)
+ .allowAnonymousWritesUsingDataScope("anonymous-scope", ENSURE_EXISTS);
+
+ final ApiResponse response =
+ thingifier
+ .api()
+ .put(
+ "todos/1",
+ parser(thingifier, "{\"title\":\"anonymous put\"}"),
+ new HttpHeadersBlock());
+
+ Assertions.assertEquals(201, response.getStatusCode());
+ Assertions.assertEquals(0, todoCount(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME));
+ Assertions.assertEquals(1, todoCount(thingifier, "anonymous-scope"));
+ Assertions.assertEquals("anonymous put", todoTitle(thingifier, "anonymous-scope", "1"));
+ }
+
+ @Test
+ void missingCredentialOnAnonymousPatchUsesConfiguredWriteScope() {
+ final Thingifier thingifier = todoModel();
+ thingifier.apiConfig().writeMethods().entities().patchCan(PARTIAL_JSON_UPDATE);
+ createTodo(thingifier, "anonymous-scope", "before patch");
+ scopedSession(thingifier)
+ .authenticateWith(this::validScopedSession)
+ .allowAnonymousWritesUsingDataScope("anonymous-scope");
+
+ final ApiResponse response =
+ thingifier
+ .api()
+ .patch("todos/1", "{\"title\":\"anonymous patch\"}", patchHeaders());
+
+ Assertions.assertEquals(200, response.getStatusCode());
+ Assertions.assertEquals(0, todoCount(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME));
+ Assertions.assertEquals("anonymous patch", todoTitle(thingifier, "anonymous-scope", "1"));
+ }
+
+ @Test
+ void missingCredentialOnAnonymousDeleteUsesConfiguredWriteScope() {
+ final Thingifier thingifier = todoModel();
+ createTodo(thingifier, "anonymous-scope", "delete me");
+ scopedSession(thingifier)
+ .authenticateWith(this::validScopedSession)
+ .allowAnonymousWritesUsingDataScope("anonymous-scope");
+
+ final ApiResponse response = thingifier.api().delete("todos/1", new HttpHeadersBlock());
+
+ Assertions.assertEquals(204, response.getStatusCode());
+ Assertions.assertEquals(0, todoCount(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME));
+ Assertions.assertEquals(0, todoCount(thingifier, "anonymous-scope"));
+ }
+
+ @Test
+ void anonymousWriteResolverChoosesDataScope() {
+ final Thingifier thingifier = todoModel();
+ final AtomicReference seenPath = new AtomicReference<>();
+ scopedSession(thingifier)
+ .authenticateWith(this::validScopedSession)
+ .allowAnonymousWritesUsingDataScope(
+ context -> {
+ seenPath.set(context.path());
+ return ThingifierApiDataScopeSelection.useDataScope(
+ "anonymous-scope", ENSURE_EXISTS);
+ });
+
+ final ApiResponse response =
+ thingifier
+ .api()
+ .post(
+ "todos",
+ parser(thingifier, "{\"title\":\"anonymous resolver\"}"),
+ new HttpHeadersBlock());
+
+ Assertions.assertEquals(201, response.getStatusCode());
+ Assertions.assertEquals("todos", seenPath.get());
+ Assertions.assertEquals(1, todoCount(thingifier, "anonymous-scope"));
+ }
+
+ @Test
+ void validCredentialOverridesAnonymousWriteScope() {
+ final Thingifier thingifier = todoModel();
+ scopedSession(thingifier)
+ .authenticateWith(this::validScopedSession)
+ .allowAnonymousWritesUsingDataScope("anonymous-scope", ENSURE_EXISTS);
+
+ final ApiResponse response =
+ thingifier
+ .api()
+ .post(
+ "todos",
+ parser(thingifier, "{\"title\":\"tenant write\"}"),
+ headersWithScopedCredential("valid-session"));
+
+ Assertions.assertEquals(201, response.getStatusCode());
+ Assertions.assertEquals(0, todoCount(thingifier, "anonymous-scope"));
+ Assertions.assertEquals(1, todoCount(thingifier, "tenant-one"));
+ }
+
+ @Test
+ void invalidCredentialOnAnonymousWriteRejects() {
+ final Thingifier thingifier = todoModel();
+ scopedSession(thingifier)
+ .authenticateWith(this::validScopedSession)
+ .allowAnonymousWritesUsingDataScope("anonymous-scope", ENSURE_EXISTS)
+ .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, "anonymous-scope"));
+ }
+
+ @Test
+ void requireAuthenticatedScopeForWritesOverridesEarlierAnonymousWriteScope() {
+ final Thingifier thingifier = todoModel();
+ scopedSession(thingifier)
+ .authenticateWith(this::validScopedSession)
+ .allowAnonymousWritesUsingDataScope("anonymous-scope", ENSURE_EXISTS)
+ .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, "anonymous-scope"));
+ }
+
+ @Test
+ void routeLevelDisableBypassesAnonymousWriteScope() {
+ final Thingifier thingifier = todoModel();
+ scopedSession(thingifier)
+ .authenticateWith(this::validScopedSession)
+ .allowAnonymousWritesUsingDataScope("anonymous-scope", ENSURE_EXISTS);
+ 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, "anonymous-scope"));
+ Assertions.assertEquals(1, todoCount(thingifier, "tenant-one"));
+ }
+
@Test
void routeLevelRequireOverridesAnonymousReadDefault() {
final Thingifier thingifier = todoModel();
@@ -402,6 +590,37 @@ void authorizerReceivesAnonymousDataScope() {
Assertions.assertNull(seenPrincipal.get());
}
+ @Test
+ void authorizerReceivesAnonymousWriteDataScope() {
+ final Thingifier thingifier = todoModel();
+ final AtomicReference seenDataScope = new AtomicReference<>();
+ final AtomicReference