ADR-0002: Centralised RPC authorization behind auth.Allow
Metadata
- Status: Accepted
- Date: 2026-07-22
- Deciders: EagraΓ Clainne Team
- Context: Shipped in
refactor(auth): centralise RPC authorization behind auth.Allow - Related: ADR-0009 (service-layer authorization, formerly RFC 003); the domain-behaviour architecture review (candidate D)
Context
The permission matrix and role helpers live in pkg/roles. internal/auth
carries a thin re-export shim over that package β auth.Role,
auth.RoleAdmin/Member/Child/Guest, auth.AllRoles, auth.ValidateRoles,
auth.Permission, auth.PermissionMatrix, and auth.CheckPermission all
forward to pkg/roles. Service code and the test suites import the auth
aliases rather than pkg/roles directly.
Until now the interceptor made the RPC-level authorization decision inline, and
did so twice β once in WrapUnary, once in WrapStreamingHandler β each path
re-typing the same four steps: public-endpoint bypass, authenticate,
parseProcedure + CheckPermission, and WithUser. The authorization decision
was therefore split across parseProcedure (Connect procedure format) and
CheckPermission (matrix lookup), with no single seam that answered "may this
caller invoke this procedure?".
The domain-behaviour review (candidate D) proposed two things: collapse the re-export shim, and deepen authorization into one seam. This ADR records what we did with each.
Decision
-
RPC-level authorization is one seam:
auth.Allow(procedure, roles) bool.Allowowns both halves of the decision β decomposing the Connect procedure into service/method (parseProcedure), and consulting the matrix (roles.CheckPermission). A procedure that does not parse, or that no matrix entry covers, is denied. The interceptor no longer decomposes procedures or walks the matrix; it asksAllow. -
The auth+authz sequence is shared, not duplicated. A private
(*Interceptor).authorize(ctx, procedure, headers)runs the public-endpoint bypass,authenticate,Allow, andWithUser, returning the user-scoped context or a Connect error. BothWrapUnaryandWrapStreamingHandlercall it, so the flow lives in one place. -
The
internal/authre-export shim is deliberately retained. We did not collapse it. Deleting it would rewrite the import in every service call site (create.go,assignroles.go, the integration auth context) and roughly two hundred lines of role/permission tests, for no behavioural change. The shim compiles, is stable, and is not the source of any friction today.
Consequences
Positive
- The interface is the test surface. Authorization is asserted against
procedure strings in
authorize_test.goβ no JWT, no interceptor, no request flow.TestAllowcovers allow/deny, unparseable procedures, missing methods and unknown services. - Locality. "May this call proceed?" is answered in one function. The unary
and streaming paths can no longer drift apart, because they share
authorize. - Leverage.
parseProcedureis now an implementation detail ofAllow; the interceptor is blind to the procedure format.
Negative / trade-offs
- Two ways to reach the matrix remain. New code should call
roles.CheckPermission(orauth.Allow) directly; theauth.CheckPermissionre-export persists for the existing callers and tests. This is the price of not doing the wide sweep, and is revisited below. Allowcouples theauthpackage to the Connect procedure format. That is the right home for it βauthis the transport-facing authorization module β butpkg/rolesdeliberately stays format-agnostic.
Alternatives considered
- Collapse the re-export shim now. Rejected for the reason in Decision (3):
a large mechanical change to services and ~200 lines of tests with no payoff
while the shim causes no friction. If a future change makes the split
load-bearing β a second consumer of the matrix, a divergence between the
authandrolesrole sets, or a move of authorization off the RPC edge β reopen this ADR and do the sweep then. Recorded here so the architecture review does not re-suggest the deletion in the meantime. - Put
Allowinpkg/roles. Rejected: it would teach the pure roles/matrix package about the Connect procedure string format. The matrix stays keyed by service/method; the format knowledge stays inauth.