App reference

ADR-0009: Service-layer authorization with resource ownership

Metadata

  • Status: Accepted
  • Date: 2026-07-25
  • Deciders: EagraΓ­ Clainne Team
  • Converted from: RFC 003 (Service-Layer Authorization), docs/rfcs/003-service-authorization/ β€” now removed
  • Related: ADR-0007 (basic authentication, formerly RFC 001); ADR-0008 (RBAC, formerly RFC 002); ADR-0002 (centralised RPC authorization behind auth.Allow); ADR-0012 (profile self-service, formerly RFC 006)
  • Amended: 2026-07-25 β€” ADR-0014 supersedes the owner-or-admin rule for items: ADMIN and MEMBER are job managers (read/edit/reassign/delete any item), CHILD keeps owner-scoped access plus take/put-back on the unassigned pool. Event and profile rules here are unchanged.

Context

The interceptor-level RBAC from ADR-0008, centralised behind auth.Allow (ADR-0002), answers only "may this role call this method?". It admits a MEMBER to UpdateItem but cannot tell whether the item belongs to that member. Without a second check, any admitted role could read or modify any resource β€” other users' items, events they were never invited to, other people's rewards.

We needed per-resource checks inside the service handlers: owner-only access for owned resources, association-based access for shared resources, self-access for profiles, and an ADMIN override throughout β€” without scattering ad-hoc if statements across every handler, and without opening a time-of-check/time-of-use (TOCTOU) window between reading a resource and guarding it.

Decision

Authorization is a two-layer defence. Layer 1 is the interceptor role matrix (ADR-0008 via ADR-0002): "can this role call this method?". Layer 2 is an in-handler resource guard: "can this specific user act on this specific resource?". Both must pass.

  1. Guards are shared helpers on the established handler seam. svc.RequireOwner and svc.RequireEventMember live in internal/services/svc/authz.go, alongside the svc.Mutate/svc.Delete seam the handlers already use; the membership predicate domain.IsMember lives in internal/domain. Each helper returns nil or a CodePermissionDenied Connect error carrying a caller-supplied denial message.

  2. Events are membership-based, not owner-based. The stored model types every member as RELATION_TYPE_OWNER and records no distinct creator, so "owner" cannot be distinguished from "member". Membership is therefore the authorization boundary for both reads and writes: any member may read and modify an event they belong to; ADMIN may do anything. So a fresh event is immediately manageable, a non-admin creator is auto-added to the member list at Create; admins are not, so creating on someone's behalf leaves the admin off the guest list.

  3. Items are owner-or-admin, including Read β€” but unowned items are open. Items are created without an owner and assigned later, so an empty owner passes RequireOwner for any role the interceptor admitted; this is what lets a MEMBER pick up and assign an unowned item. Once owned, every operation β€” Read included β€” is restricted to the owner or ADMIN. A CHILD completes items assigned to them through the dedicated CompleteItem RPC; the interceptor keeps them off the general Update path.

  4. Reward claims are self-only for non-admins. A non-admin may only claim a reward for themselves, and a reward earmarked for a user (its user field set) cannot be claimed out from under them by anyone else non-admin. Read/List/GetUserRewards remain open to all non-GUEST roles with no in-handler guard β€” the interceptor matrix is the whole decision there.

  5. List endpoints filter to what the requester may Read. ListEvents returns only member events; ListItems returns owned-plus-unowned items; ADMIN sees everything. The SQL limit applies before the filter, so a non-admin page may come back short of the requested size.

  6. Guards run inside the locked read. Mutation-path guards execute within svc.Mutate apply callbacks, and svc.Delete guards receive the already-locked entity, so the authorization check and the write see the same row state. This closes the TOCTOU window (an ownership change cannot slip between check and write) without hand-rolled transactions in handlers.

  7. ADMIN overrides everywhere, with one carve-out. Every guard short-cuts for requester.IsAdmin(). The single restriction that binds admins too: no user, admin included, may delete their own account (CodeFailedPrecondition), preventing accidental lock-out.

Profile self-access ("own profile or ADMIN" on User.Update, with a per-role field-mask allowlist) shipped alongside this work and is recorded in ADR-0012.

Consequences

Positive

  • Defence in depth. A bug in either layer alone does not expose data: the matrix stops wrong roles at the edge, the guards stop right-role/wrong-user at the resource.
  • Consistency. Two helpers plus one domain predicate cover every guarded handler; the denial path, error code, and ADMIN override cannot drift between services.
  • No TOCTOU races by construction. Handlers cannot accidentally guard a stale read, because the seam hands the guard the locked entity.
  • Family-shaped semantics. Membership-based events match how the product is used β€” a shared event is jointly editable β€” instead of forcing a creator/participant hierarchy the data model never stored.

Negative / trade-offs

  • Any event member can modify or delete the event. With no stored creator, there is no "owner can update, members can only read" tier; restoring one would require a data-model change.
  • Unowned items are writable by any admitted role. That openness is what makes assignment work, but it means an unassigned item has no protection beyond the role matrix.
  • Short pages. Because the SQL limit precedes the visibility filter, non-admin list pages can return fewer results than requested even when more visible rows exist.
  • Authorization is spread across two layers. Answering "who can do X?" requires reading both the matrix and the handler guard; the helpers keep the second half small, but it is not a single policy artefact.

Alternatives considered

  • Policy engine (e.g. OPA). Declarative policies and external management are attractive, but it adds a dependency and a learning curve, and is overkill for a handful of ownership rules. Rejected: simple helper functions are sufficient at this scale.
  • Authorization middleware between interceptor and services. Centralised, but resource-specific rules need the resource itself β€” the middleware would have to fetch (and lock) entities it knows nothing about, and would be less flexible than checks that already sit next to the locked read. Rejected: service-layer guards provide the needed flexibility.
  • A standalone auth_helpers.go package of boolean predicates (the shape the original RFC sketched). By implementation time the svc seam existed; putting the guards there, returning Connect errors directly and running inside the locked read, fit the codebase better than free-standing CanAccess* booleans each handler would have to wire into its own transaction handling.