authorization
why
a hidden button is not access control, nor is an obscure URL: any caller that skips the UI reaches the operation directly. access exists only when the server boundary itself decides it.
rule
a boundary's contract states who may call it. authorization is an explicit, named decision at the boundary — checked before domain behavior runs, derived from the authenticated principal — never inferred from record visibility, URL obscurity, or client behavior.
a UI hiding a button is not access control: an API caller bypasses the frontend and performs the operation anyway. a hidden URL path is not either: a direct HTTP call reaches it regardless of obscurity.
decision placement
| question | owner | answered |
|---|---|---|
| may this principal call this operation at all? | route boundary, before domain work. | operation-level check. |
| may this principal touch this specific record? | domain service, with the record loaded. | instance-level check. |
| which fields may this principal see? | the projection/mapper that shapes the response. | field-level policy. |
operation-level checks: one guard per route. instance- and field-level policy lives in the domain service and mapper, where the record's ownership facts are in hand. field-level hiding belongs in the mapper — the privacy and content ownership classification decides what is concealable at all.
policy shape
- name permissions as domain facts —
read,manageMembers,approveRun— scoped to the resource they guard. - express the check as a function returning a verdict, not a thrown generic error; the route maps the verdict to a typed diagnostic.
- a denied decision is a first-class outcome: distinct from "not found" when existence itself is sensitive, distinct from failure in every response.
do not
- rely on the UI hiding an action as the authorization decision.
- scatter
if (role === ...)literals across services; one policy owner per resource. - return 404 for a denied record when the caller may know it exists — and never leak a record's existence through an error payload.
- make authorization an after-the-fact filter on an already-loaded list.
verification
for each protected operation, probe as a principal with no permission, partial permission, and full permission. record that the response differs exactly as the policy states — denied, partial projection, full — and that no denied branch leaks record fields. search routes for operations missing an explicit permission declaration.
good and bad
bad
// route: no permission declared; UI hides the button for non-adminsexport async function POST(req) {const record = await db.record.findUnique({ id: req.body.id });if (req.user.role === "admin") {await grantAccess(record);return ok();}// no explicit denial or typed response — just falls through}
the hidden button is the only guard and the role literal is scattered policy.
an API caller crafts POST /grant-access { id: "..." } directly and performs
the operation. no typed permission contract, no audit trail of a denial,
check inferred from visibility. another route might check
user.tier === "premium" for the same record — conflicting authorities.
good
// route contract declares permission: approveRunconst verdict = policy.can(principal, "approveRun", record);if (!verdict.ok) return deniedDiagnostic(verdict);
one named policy owner decides before domain work runs; denial is a typed first-class outcome. every protected operation is auditable by its declared permission, permission changes land in one policy owner, and probing with partial principals proves the boundary — not the UI — decides access.