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
    findByIdOrThrow is 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’ findAccessibleByUserOrThrow admits 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’s DeckController carries @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 @PreAuthorize expression 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 switch in 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.
  • 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’
    findByIdAndUserIdOrThrow lookups; dracomania DeckController and
    DeckService.findByIdAndOwnerIdOrThrow.