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

questionowneranswered
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-admins
export 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: approveRun
const 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.

search pages

go to any page