Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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<ThingifierApiRouteRule> 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) {
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package uk.co.compendiumdev.thingifier.api.security;

/**
* Chooses the data scope for a missing scoped-session credential.
*
* <p>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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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.
*
Expand All @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package uk.co.compendiumdev.thingifier.api.security;

import java.util.Optional;
import uk.co.compendiumdev.thingifier.api.response.ApiResponse;

/**
Expand All @@ -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;
Expand Down Expand Up @@ -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.
*
* <p>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.
*
* <p>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;
}

Expand Down Expand Up @@ -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<ThingifierApiAnonymousDataScopeResolver> 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
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
* Resolved scoped-session policy for one request route.
*
* <p>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 {

Expand All @@ -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
}
Expand Down Expand Up @@ -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
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -653,14 +653,12 @@ private Optional<ThingifierApiScopedSessionPolicy> 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()
Expand Down
Loading
Loading