Back to Blog

Language: English

Modeling Authorization as action × subject × check

Instead of scattering authorization conditions across routes and services, this design expresses them as a typed catalog of action × subject × check. Unions and required-context types are derived from a single ACCESS_SPECS constant, so missing contexts surface as compile-time errors.

Authorization conditions were scattered throughout routes and services.

if (user.role === "admin" || project.ownerId === user.id) {
  // ...
}

What’s painful about this shape: add one route, and the only way to know whether it’s covered by an existing authorization rule is to grep. The more conditions pile up, the more “what lives where” becomes something people carry in their heads.

Dividing labor between routes and the catalog

After the migration, a route states nothing but intent.

@Authorize("project:archive")

What the target resource is and how access gets decided live on the typed-catalog side.

actionsubjectcheck
project:archiveproject(projectId)archive_project capability

With route, resource, and decision method each typed independently, we can enumerate every route and audit the diffs. The full picture of the authorization rules became something we can handle as data.

ACCESS_SPECS and derived types

The thing we don’t want to hand-write here is the union of actions. Keep the runtime catalog and the type-level action list separately, and sooner or later only one of them gets updated.

const ACCESS_SPECS = {
  "project:archive": {
    kind: "route",
    subject: { kind: "project", param: "projectId" },
    check: { kind: "project-capability",
             capability: "archive_project" },
  },
} as const satisfies AccessSpecRegistry

type AccessAction = keyof typeof ACCESS_SPECS
type RouteAction = ActionOfKind<"route">

as const preserves literals like project:archive and route, while satisfies validates the structure of the catalog as a whole. keyof typeof produces the action union, and mapped types plus conditional types derive subsets such as route, scope, and self.

Since the canonical source of both value and type sits in one place, adding an action to the catalog updates the strings decorators accept, the branches executors switch on, and what the type tests exercise—all together.

The authorize calling contract

A call to authorize binds actor, action, and subject together.

authorize(actor, "task:delete", {
  type: "task",
  taskId,
}, policy)

For each action, the context needed for the decision is defined as a type. So a call that omits required information halts at compile time.

authorize(actor, "task:delete", {
  type: "task",
  projectId,
  // taskId がない
}, policy)
TS2345: Argument of type
'{ type: "task"; projectId: string; }'
is not assignable to parameter of type
'{ type: "task"; taskId: string; projectId?: string; }'.

Property 'taskId' is missing.

The types already declare that task:delete’s subject requires taskId, so it fails before execution. You learn at write time, which means a missing context is never discovered after authorization processing has already proceeded.

The same type parameter threads through to AccessDecision, so the decision result still carries the same action and subject. The job of these types is to carry the context an authorization judgment needs from the call site to the decision result without losing any piece of it.

Outside the types’ jurisdiction

What we delegate to the compiler extends only to consistency of usage. Whether a policy’s contents are correct is a separate question, one types cannot prove. Deciding who should hold the archive_project capability is a human judgment. Whether DB queries use scopes correctly is likewise outside the type system.

What we do force onto it are usage accidents: an action and subject that don’t match, missing context, a decision result meant for one action being reused for another. Accidents of this kind are hard to catch in review, and reaching production unnoticed they become holes in authorization. That slice, and only that slice, we push onto the compiler.


Based on material presented at the results presentation on July 31, 2026.