ADR-0018: Who may touch a user’s resource — session-scoped or actor-declared, chosen at project start
- Status: Accepted
- Date: 2026-09-10
- Scope: core (any backend whose users own resources)
Context
Every backend with accounts answers one question on each request that names an item:
may this caller touch it? The handbook’s projects answer it in two ways, and neither was
written down as a rule.
Expenses has no social surface. Its user routes carry no user id (/expenses,
/budgets, /users/me). The caller comes from the token
(SecurityHelper.getAuthenticatedUserId()), and services load items through ownership
lookups (findByIdAndUserIdOrThrow, findOwnedByUserOrThrow) that throw
EntityNotFoundException when the item is not the caller’s. The only routes that name
another user are in the admin area (/admin/users/{userId}/notes,
/admin/users/{userId}/plan, …), and that area is gated by role as a whole: /admin/**
in the security filter chain, with finer role boundaries per endpoint in @PreAuthorize.
No rule anywhere depends on the specific resource.
Dracomania is social. Players will see friends’ collections, and later spectate and
trade, so some non-admin routes must let one player address another’s things. Its decks
first shipped half under /players/{id}/decks and half at a top-level /decks/{id}, so
any caller could delete any player’s deck by its id. Nesting the routes and adding an
ownership lookup fixed the addressing. It did not decide which callers may act there.
Two forces make the choice between these a project-start decision:
- The route shape is contract. Once clients exist, re-addressing
/decks/{id}as
/players/{playerId}/decks/{id}(or back) is a coordinated release of every client. - The access rules (who may read a collection, who may edit a deck) follow the
product, and must stay cheap to change.
Decision
Choose the shape at project start, by one question
Does any non-admin route let one user address another user’s resources?
- Never: use the session-scoped shape. It fits a product with no social surface
exactly, and needs no per-resource authorization. - Yes (friends, sharing, spectating, trading, teams): use the actor-declared shape.
The project records its answer in its own ADR before its first user-owned endpoint ships.
Admin routes do not count toward the answer; both shapes gate them by role.
What both shapes share
- Every item loads through an ownership lookup in its service, named for the rule
(0002-clean-architecture-and-use-cases):findByIdAndUserIdOrThrow,
findByIdAndOwnerIdOrThrow. A user route that loads a user-owned item with a bare
findByIdOrThrowis a bug. - Another user’s item is a 404, never a 403. It is indistinguishable from an item that
does not exist, so ids cannot be probed. - Visibility that depends on the resource’s state lives in the lookup, not in the route
or an annotation. Expenses’findAccessibleByUserOrThrowadmits a CSV template that is
the caller’s or a system template; a deck its owner has shared would be a
findVisibleOrThrow. Whatever the caller may not see is a 404. - An admin area is gated by role as a whole, by path in the security filter chain.
- A missing or invalid token is a 401 from the filter chain’s entry point. A refusal
raised after dispatch, by method security, is a 403 that the global exception handler
must map explicitly (0002-clean-architecture-and-use-cases, 2026-09-10 amendment).
Shape A: session-scoped
- User routes carry no user id. The caller comes from the token through one helper
(Expenses:SecurityHelper), and the web service passes the id down. - Nesting under a parent resource is fine (
/incomes/{incomeId}/hours): the parent
loads through its own ownership lookup. Nesting under a user is what this shape never
does. - The ownership lookup is the whole authorization. Endpoints declare nothing, because
every non-admin endpoint would declare the same thing: the caller.
Shape B: actor-declared
Addressing:
- A resource that exists only as a user’s is addressed under its owner:
/<owners>/{ownerId}/<resource>[/{id}]. There is one address per resource, and no
/me/<resource>alias, which would be a second place to get the check right. A/me
endpoint returns the caller’s own id, so a client can build its URLs. - The controller stays with its feature. The URL hierarchy is not the class hierarchy:
dracomania’sDeckControllercarries@RequestMapping("/players/{playerId}/decks"). - The path parameter that names the owner has one name on every method, because the
access check reads it by that name. - The target is usually the owning user, but it can be any resource with members, such as
a match with its seated players.
Access:
- Actors are the vocabulary. An actor is who the caller is relative to the target: its
owner, a relation (friend, teammate), a role (admin), or any authenticated user. The set
is small and closed (an enum), while resources keep growing, so each endpoint lists the
actors it admits. - Actors are declared with a typed annotation, not SpEL. A typo in
@Allow(Actor.OWNER)
fails to compile. A@PreAuthorizeexpression is only checked when its endpoint is
called, so a typo in it is a runtime failure wherever no test calls that endpoint. - Deny by default. Every owner-scoped controller carries a class-level annotation that
admits the owner only, and a method-level annotation replaces it. An endpoint added
without one is owner-only, never open. - Each actor’s meaning is one case of an exhaustive
switchin an application-layer
policy. The policy asks a current-user port, whose security adapter reads the token. A
new actor does not compile until its meaning is written. - Fail closed. A relative actor on a method with no owner parameter is denied.
- An actor refusal is a 403; a lookup refusal is a 404. The shape presumes owner ids
are public, as they are in a social product that shows profiles, so the 403 reveals
nothing. The actor decides who may ask; the lookup decides what they find. - Changing a rule means editing an annotation list, with no route or contract change.
A list needed on more than one endpoint gets a named meta-annotation.
Consequences
- The shape is a one-way door once clients ship; the rules inside it are not. Moving
from session-scoped to actor-declared later means re-addressing every user-owned route. - Session-scoped is the cheaper shape: no annotation, no interceptor, no policy. It is
the right choice whenever the honest answer to the question is “never”. - Accepted: actor-declared means owning method-security infrastructure (the
annotation, its interceptor, the policy) instead of using@PreAuthorize, in exchange
for compile-time safety. - Accepted: a capability’s rule lives on its endpoint. With one endpoint per
capability, that is no more editing than a central policy method would be. - Accepted: under actor-declared, request-shape validation (400) runs before the actor
check. Spring MVC binds and validates the body before it invokes the secured method,
so an unauthorised caller with a malformed body sees a 400. It reveals nothing about the
target. - Both shapes can be reviewed mechanically. Session-scoped: no user id in a non-admin
path, and every item read goes through an ownership lookup. Actor-declared: every
owner-scoped controller has the class-level default, and the ownership lookup is still
there.
Related
- 0002-clean-architecture-and-use-cases — ownership lookups are service methods named
for the rule; its 2026-09-10 amendment maps the 403 - 0007-accounts-sessions-and-access-by-actor (dracomania) — the actor-declared shape
applied: its actors, its starting rules, and matches as a second target - 0013-sessions-and-refresh-tokens — where the caller’s identity comes from, in both
projects (promoted to core 2026-09-10) - Evidence: Expenses
SecurityConfig,SecurityHelper, and the services’
findByIdAndUserIdOrThrowlookups; dracomaniaDeckControllerand
DeckService.findByIdAndOwnerIdOrThrow.