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.
-
Guards are shared helpers on the established handler seam.
svc.RequireOwnerandsvc.RequireEventMemberlive ininternal/services/svc/authz.go, alongside thesvc.Mutate/svc.Deleteseam the handlers already use; the membership predicatedomain.IsMemberlives ininternal/domain. Each helper returnsnilor aCodePermissionDeniedConnect error carrying a caller-supplied denial message. -
Events are membership-based, not owner-based. The stored model types every member as
RELATION_TYPE_OWNERand 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 atCreate; admins are not, so creating on someone's behalf leaves the admin off the guest list. -
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
RequireOwnerfor 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 dedicatedCompleteItemRPC; the interceptor keeps them off the generalUpdatepath. -
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
userfield set) cannot be claimed out from under them by anyone else non-admin.Read/List/GetUserRewardsremain open to all non-GUEST roles with no in-handler guard β the interceptor matrix is the whole decision there. -
List endpoints filter to what the requester may Read.
ListEventsreturns only member events;ListItemsreturns 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. -
Guards run inside the locked read. Mutation-path guards execute within
svc.Mutateapply callbacks, andsvc.Deleteguards 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. -
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.gopackage of boolean predicates (the shape the original RFC sketched). By implementation time thesvcseam existed; putting the guards there, returning Connect errors directly and running inside the locked read, fit the codebase better than free-standingCanAccess*booleans each handler would have to wire into its own transaction handling.