Data Access Control
Wow carries four kinds of data-access context through the write and read paths:
- Tenant — aggregate storage and route scope;
- Owner — optional ownership metadata and route scope;
- Space — optional namespace metadata supplied by a request header;
- ABAC tags — resource tags plus an application-supplied principal filter.
On WebFlux query routes, the Handler uses QueryRequestScope to capture tenant, owner, and space scope in Reactor Context; the Gateway merges it after preparation. Both Snapshot and EventStream Gateways then evaluate QueryPolicy rules through the shared policy stage; AbacQueryPolicy supplies resource-tag conditions for Snapshot queries and for datasets that bind tag columns.
Scope is not authentication
A tenant or owner path, Wow-Space-Id header, or ABAC tag is data used by routing and filtering. It does not prove who sent the request or whether that principal may choose the value. Authenticate first, bind allowed scopes on the server, authorize command and query routes, and keep raw query factories out of untrusted request paths.
RESTful URL Pattern
Generated aggregate routes follow this shape where each optional segment is enabled by aggregate metadata:
[tenant/{tenantId}/][owner/{ownerId}/]{resourceName}[/{resourceId}]/{action}| Scope | Wire value | Route condition |
|---|---|---|
| Tenant | tenant/{tenantId} | Aggregate has no static tenant ID |
| Owner | owner/{ownerId} | Effective owner policy (@AggregateOwner) is not NEVER |
| Space | Wow-Space-Id header | Aggregate is spaced (@Spaced) |
AGGREGATE_ID ownership removes the separate resource-ID segment when owner ID identifies the aggregate. Command-level @CommandRoute settings may override aggregate route defaults. Snapshot query contributors always publish a base aggregate route and add tenant/owner variants when applicable; an unscoped query route therefore needs an explicit security policy. Use the running OpenAPI document for the exact route; a bounded-context alias is not automatically a URL prefix.
Query-schema routes (/{aggregate}/snapshot/schema, /{aggregate}/event/schema) intentionally have no tenant, owner, or aggregate-ID path variants: they describe the aggregate's query models, not data for one caller. The common aggregate contract may still declare Wow-Space-Id for a spaced aggregate.
Tenant
Tenant is part of AggregateId and persisted message/snapshot metadata. The aggregate ID remains globally unique within a named aggregate; tenant does not make the same aggregate ID reusable.
Annotation-based Tenant ID
Mark the command property that carries a dynamic tenant identifier:
data class CreateOrder(
@AggregateId val orderId: String,
@TenantId val tenantId: String,
val items: List<OrderItem>,
)KSP metadata records how the tenant is resolved. Runtime WebFlux routes extract the tenant path value and carry it into the command or query context. The annotation itself does not verify membership in that tenant.
Static Tenant ID
Use @StaticTenantId when every instance of an aggregate belongs to one fixed tenant:
@AggregateRoot
@StaticTenantId("system")
class SystemConfiguration(private val state: SystemConfigurationState)The generated route omits the dynamic tenant prefix. This is a routing/storage choice, not a declaration that the resource is public.
Default Tenant
TenantId.DEFAULT_TENANT_ID is (0). Static single-tenant aggregates commonly use this value. Do not use the default as an authorization fallback when a dynamic tenant is expected.
Owner
Owner is snapshot and message metadata within an aggregate. It can constrain generated routes and snapshot queries, but the caller identity must still be bound by application security.
Annotation-based Owner ID
Mark the command field that supplies ownership metadata:
data class CreateCart(
@AggregateId val cartId: String,
@OwnerId val userId: String,
)Do not trust a client-supplied userId merely because it carries @OwnerId; compare or replace it using the authenticated principal at the application boundary. Since 9.3.0, on a route that states {ownerId}, a body owner that differs from the path is rejected with 400 (see Request Identity); on other routes the body value is still the owner.
Ownership Routing Policy
@AggregateRoot
@AggregateRoute(resourceName = "orders")
@AggregateOwner(OwnerPolicy.ALWAYS)
class Order(private val state: OrderState)Since 9.3.0 the policy is declared with @AggregateOwner; the deprecated @AggregateRoute(owner = AggregateRoute.Owner.…) is still read when it is absent (see Aggregate Policies).
| Policy | Owner path | Resource ID | Meaning |
|---|---|---|---|
NEVER | absent | normal | no owner routing metadata |
ALWAYS | required | normal | owner and aggregate ID are distinct |
AGGREGATE_ID | required | omitted | owner ID also identifies the aggregate |
The generated route and OwnerAggregatePrecondition can enforce that a loaded aggregate's owner metadata matches the route value. They do not establish that the authenticated caller owns that route value.
Ownership Transfer
An event implementing OwnerTransferred changes the state aggregate's owner metadata while sourcing:
data class OrderAssigned(
override val toOwnerId: String,
) : OwnerTransferredThe domain decides when transfer is allowed. Authorize the transfer command before emitting the event; the marker interface only applies the new metadata.
Space
Space is a string namespace stored with messages and snapshots. It is independent from tenant and owner and defaults to the empty string.
Enabling Space
@AggregateRoot
@AggregateRoute(resourceName = "sales-order")
@Spaced
class Order(private val state: OrderState)Since 9.3.0 an aggregate is declared spaced with @Spaced; the deprecated @AggregateRoute(spaced = true) is still read when it is absent (see Aggregate Policies).
For a spaced aggregate, WebFlux reads Wow-Space-Id: commands are written into that space, and query routes (snapshot, event stream, state and tracing reads) get a SPACE_ID filter. The header does not become a URL segment and does not authenticate access to the space.
An aggregate that is not spaced ignores the header on every route: its commands carry the default space ("") and its queries get no space filter, whatever the client sends. The same holds for commands from any other source: a saga reacting to an event of a spaced aggregate does not pass that space on to a non-spaced one, and a non-spaced aggregate never checks or records a command's space. Clients such as fetcher-cosec send a space header on every request, so a deployment that relied on the header to isolate a non-spaced aggregate must declare it spaced.
Upgrading from 9.1
Wow 9.1 took the space from the request for every aggregate; 9.2 takes it only for a spaced one. This is an approved exception to the v9 compatibility freeze, listed under "Breaking" (server behaviour) in the 9.2.0 release notes.
- Before upgrading, declare
spaced = true(since 9.3.0,@Spaced) on every aggregate whose data a space header was meant to keep apart. Build and roll out that declaration as part of the 9.2 upgrade. - During a rolling upgrade, 9.1 and 9.2 nodes disagree on a non-spaced aggregate: a 9.1 node stamps the header's space into a command and filters a query by it, and a 9.2 node writes the default space and does not filter. The same query can therefore return different rows depending on the node that answers. Upgrade the gateways, the aggregate nodes and the query nodes together, and keep the mixed window short.
- After upgrading, data a 9.1 node already wrote with a space keeps it. Its later commands neither check nor change that space, and its queries are no longer filtered by the request's space.
- Rolling back to 9.1: records 9.2 wrote in the default space are rejected (commands) or hidden (queries) for clients that send a space header.
Space Transfer
An event implementing SpaceTransferred changes the state aggregate's space metadata:
data class OrderArchived(
override val toSpaceId: SpaceId,
) : SpaceTransferredAs with ownership transfer, the event applies a state transition; the command-side policy decides whether the transition is permitted.
ABAC (Attribute-Based Access Control)
Wow stores resource tags. The Snapshot Gateway combines query constraints through QueryPolicy; AbacQueryPolicy implements it for resource tags. Non-ABAC policies can return conditions directly without principal-tag lookup. The application supplies principal tags from authenticated context and decides whether missing context is public or denied.
Core Concepts
AbacTags is Map<String, List<String>>:
val principalTags = mapOf(
"department" to listOf("engineering", "product"),
"role" to listOf("reader"),
)
val resourceTags = mapOf(
"department" to listOf("engineering"),
)For each principal tag key, regular values match a resource when that key is absent/empty or intersects the principal values. listOf("*") maps to an EXISTS condition for that key. Conditions for multiple principal keys are combined with AND.
An untagged resource is therefore public to the built-in tag-matching expression. If that is not the business rule, override the policy rather than treating tags as a complete authorization system.
Applying Resource Tags
DefaultApplyResourceTags is a built-in command with PUT, action tags, and an ID path. When an aggregate does not register its own ApplyResourceTags handler, Wow can use the default command function and source DefaultResourceTagsApplied into state-aggregate metadata.
val command = DefaultApplyResourceTags(
tags = mapOf("department" to listOf("engineering")),
)Protect this command like any other authorization-changing operation. Publishing a generated route does not grant only administrators access.
Tag Merging
merge combines values for the same key:
val effective = mapOf("department" to listOf("engineering"))
.merge(mapOf("department" to listOf("product"), "role" to listOf("reader")))Choose replacement versus merge deliberately. DefaultResourceTagsApplied replaces the state aggregate's stored resource tags; it does not silently merge them.
Dynamic Tag Extraction with StateAggregateTagsExtractor
Implement StateAggregateTagsExtractor<S> on state when tags must be derived from the materialized aggregate:
class OrderState(
val department: String,
) : StateAggregateTagsExtractor<OrderState> {
override fun extract(source: ReadOnlyStateAggregate<OrderState>): AbacTags =
mapOf("department" to listOf(department)).merge(source.tags)
}The extractor computes resource metadata during state materialization. It does not resolve principal identity.
ABAC Query Policy
Subclass AbacQueryPolicy and fail closed for protected queries:
@Component
class MemberAbacQueryPolicy(
private val memberships: MembershipRepository,
options: AbacQueryOptions,
) : AbacQueryPolicy(options) {
override fun getPrincipalTags(
contextView: ContextView,
context: QueryContext<*>,
): Mono<AbacTags> = contextView.getOrEmpty<Principal>(Principal::class.java)
.map { principal -> memberships.tags(principal.name, context) }
.orElseGet { Mono.error(AccessDeniedException("Missing principal")) }
}This example represents an application policy; adapt it to the actual security context. The framework default for empty tags or Mono.empty() is MatchAllFilter, so protected applications must reject missing identity/tags explicitly.
The starter publishes an AbacQueryOptions bean from wow.query.abac.*; take it in the constructor, as above, to apply two optional tightenings:
require-principal-tags=true: anHTTPsnapshot query whose principal has no tags (empty orMono.empty()) is rejected with403 IllegalAccessQueryScopeinstead of matching everything. In-process queries are trusted and not rejected.match-missing-tag-key=false: a resource that lacks one of the principal's non-wildcard tag keys, or has it empty, no longer matches as public. It must carry the key with a value the principal holds.
Both default to the permissive behavior above.
Tags From CoSec Principal Attributes
An application authenticated by CoSec need not write getPrincipalTags: with wow.cosec.abac.enabled=true the starter registers CoSecAbacQueryPolicy (it needs the cosec-support capability and CoSec's WebFlux module), which reads the tags from the principal CoSec puts in the Reactor context. An attribute whose key starts with wow.cosec.abac.attribute-prefix (abac. by default) is a tag, its value a comma-separated string or a collection: abac.region = "east,north" is the tag region with east and north. An anonymous principal has no tags. It gives way to an AbacQueryPolicy the application declares. It takes AbacQueryOptions too, so a service people read data through should also turn on wow.query.abac.require-principal-tags=true, refusing a principal without tags instead of letting it read everything.
Query Entry Points and Policy Enforcement
Spring-registered Snapshot and EventStream Gateways both run the admission steps: the entry budget, QueryFilter rewrites, trusted scope, and QueryPolicy. Each policy uses QueryContext to determine applicability and returns MatchAllFilter when it does not apply. AbacQueryPolicy reads principal tags and generates conditions only for Snapshot; it does not provide tag authorization for EventStream. The Registrar passes each Gateway its routed Backend and the schema provider the QuerySchemaCatalog compiles. JVM calls do not run HTTP scope extraction; use contextWrite { it.withQueryScope(scope) } for trusted scope. Public validation follows the rewrites, scope, policies, and defaults. See Field Masking for managed query and aggregate-state load behavior.
SnapshotQueryBackendFactory and EventStreamQueryBackendFactory return a QueryBackendBinding; trusted raw execution explicitly unwraps factory.create(namedAggregate).backend. It bypasses the QueryGateway policy chain and is infrastructure access that must be protected. Custom Backends never implement a Provider: their Factory pairs the Backend with a QueryStorageAdapter that reports native facts, and the QuerySchemaCatalog compiles the schema. Schema unavailability fails every managed query closed before Backend subscription.
Aggregation in both models runs the same admission steps, scope, and QueryPolicy. Public validation allows ordinary filter/search/sort on masked fields but rejects groups, field metrics, or expressions that reference protected values; count is unchanged. Do not expose sensitive aggregation merely because ordinary snapshot queries pass through ABAC.
State Point Reads
The state routes (load by id, by version, by time, and tracing) replay events and never pass through the Gateway, so by default the admission steps, scope, QueryPolicy and masking do not apply to them. The owner precondition still applies to loads by id, version or time on routes with an owner path segment; the tracing path has no owner, so tracing does not check it (see Operations Routes). With wow.webflux.state.point-read-admission=true, each state read is turned into its snapshot-shaped record, and then:
- the gateways' entry policy accepts the
HTTPentry; withwow.query.require-authenticated-scopeon, a caller whose authenticated scope does not pin the tenant is rejected (403), as on a query route. That switch is enforced on these routes only under point-read admission, so turning it on withoutpoint-read-admissionfails at startup; - the query admission (
QueryAdmission.admitRecord) runs the scope, policy and default-scope steps of a query on it in memory: the caller scope fromQueryRequestScope, everyQueryPolicyrestriction, and the snapshot default that hides deleted states unless the read states a deletion scope (tracing reads all versions, deleted included). The policies are the same beans, in the same order, as the gateways use, and they see aSINGLEquery by id from anHTTPentry. A state outside them reads as absent:404for a load,[]for tracing. The restriction is first put in canonical form, as a query's is (field aliases replaced,EQ/NEofnulllowered), and evaluated with the semantics every backend follows (numbers compare by value; a field set tonullexists). The in-memory evaluation supports what scopes and ABAC policies produce: id, tenant, owner, space and deletion filters,AND/OR/NOR, andEQ/NE/IN/NOT_IN/IS_NULL/IS_NOT_NULL/EXISTS/NOT_EXISTS/IS_EMPTYon a field. Any other node fails closed; - the response is masked by the aggregate's snapshot query schema, and a tracing response also by its event-stream schema. When the schema cannot load, the read fails (
503) instead of returning unmasked data; - tracing emits at most
wow.webflux.state.tracing-max-versionsversions (default1000,0disables the cap). A larger range is rejected with400before the response starts; narrow it withheadVersion,tailVersionorlimit. The trace is emitted only when every traced state is admitted.
The switch is off by default, which keeps the existing behavior.
Operations Routes
These routes have only the tenant and aggregate ID in their path, no owner path segment. They are not isolated by owner and are meant for operators, not end users:
| Route | Owner handling |
|---|---|
| State tracing | No owner precondition; under point-read admission the caller scope and QueryPolicy decide |
| Event stream load (by version range) | Admitted by the EventStream Gateway; the only owner filter is the declared Command-Owner-Id header |
| Snapshot regenerate (single and batch), event compensate, batch state event resend | Owner not read |
Command-Owner-Id is a value the client declares, not a security boundary: a caller who knows an owner ID can send it. Do not expose these routes to end users directly:
- let only operator callers through, by path or role, at the gateway;
- when tracing must be read per caller, enable
wow.webflux.state.point-read-admissiontogether withwow.query.require-authenticated-scopeand a fail-closedQueryPolicy. The event stream load passes through the Gateway and follows both as well.
For data end users read by owner, use the load and query routes with an owner path segment.
Required Security Closure
- Authenticate the request before it enters generated routes.
- Derive allowed tenant, owner, and space scopes from trusted server-side membership data.
- Authorize commands, especially owner/space transfer and resource-tag changes.
- Register a fail-closed query policy for protected data.
- Keep raw factories and unwrapped custom services outside untrusted request paths.
- Test anonymous, forged-scope, cross-tenant, missing-tag, aggregation, and raw-entry negative cases.
Layered Isolation Summary
| Layer | Stored/query metadata | HTTP representation | What Wow supplies | What the application must supply |
|---|---|---|---|---|
| Tenant | tenantId | path prefix when dynamic | propagation and query scoping | authenticated scope binding |
| Owner | ownerId | optional path prefix | route shape, precondition, query scoping | caller-to-owner authorization |
| Space | spaceId | Wow-Space-Id header | propagation and query scoping | caller-to-space authorization |
| ABAC | tags | internal query filter | tag storage and extension point | principal resolution and fail-closed policy |
These mechanisms form a security boundary only when the application completes identity binding and authorization. Route shape, metadata propagation, query-schema validation, and HTTP cost guards are not substitutes for that policy.