Skip to content

Authorization axes

Authorization in the wrapper is not one check — it is five independent axes, each answering a different question and each enforced by its own middleware. A request is allowed only when it passes every axis that applies to it. Separating the axes keeps each guard small, testable, and impossible to accidentally widen.

# Axis Question it answers Guard(s)
1 project_id Which tenant does this credential belong to? project-auth
2 scopes Is this operation (read/write/delete/…) permitted? scope-guard
3 actor-type Is the kind of caller allowed here at all? require-owner, require-provisioner, require-space-manager
4 subject Is the caller pinned to the user it is targeting? subject-guard
5 space Is the caller pinned to the space (“Ruta”) it is targeting? space filter + subject-guard (owner pin)

Every request first passes through project-auth, which authenticates the credential and builds a single Principal — the object every downstream guard reads. The Principal carries all five axes at once:

type Principal = {
projectId: string // axis 1 — always set
subjectUserId: string | null // axis 4 — null = project-wide
spaceId?: string | null // axis 5 — null = not space-bound
scopes: string[] // axis 2 — ['*'] = full authority
actorType: 'project' | 'integration' | 'user' | 'space' // axis 3
actorKeyId: string | null
policy?: IntegrationPolicy // Fase 11 rate-limit + CORS
}

Source: apps/api/src/lib/principal.ts.

project-auth resolves the credential in order: a JWT session (cookie-first, then Bearer) of role project / user / space, then a PROJECT_API_KEY, then an INTEGRATION_API_KEY. Whatever matches, it injects project_id and builds the Principal. A wrong-role JWT (e.g. an admin token on a project route) is 403; no credential is 401. This axis is unconditional — nothing bypasses the tenant boundary.

Scopes gate the operation. The vocabulary lives in apps/api/src/lib/scopes.ts: users:read, users:write, files:read, files:write, files:delete, plans:read, plans:assign, stats:read, spaces:login.

PROJECT_API_KEYs and project sessions carry the ['*'] wildcard and always pass. Integration keys and user/space tokens carry only their declared scopes. The guard has three modes, set by SCOPE_ENFORCEMENT:

  • off — never checks (legacy).
  • warn — logs [scope] missing scope and lets the request through.
  • enforce — returns 403 on a missing scope. This is the default.

requireScope('files:write') gates a whole router; requireScopeByMethod({ GET: 'files:read', POST: 'files:write', DELETE: 'files:delete' }) picks the scope by HTTP method.

Axis 3 — actor-type (owner / provisioner / space-manager)

Section titled “Axis 3 — actor-type (owner / provisioner / space-manager)”

Some actions are forbidden to a kind of caller no matter what scopes it holds. These are hard boundaries, independent of SCOPE_ENFORCEMENT:

  • require-owner — only actorType === 'project' passes. Reserved for managing API keys, integrations, and plan definitions. An integration must never see or rotate the PROJECT_API_KEY, regardless of scopes.
  • require-provisioner — only project or integration passes; subject-bound tokens (user / space) are rejected. Guards user-admin mutations, so a user token cannot self-assign a bigger plan and a space token cannot delete its owner.
  • require-space-manager — only project or user passes; integrations and space tokens are rejected. Only the project owner (e.g. Sensei) or the owning Master may create and configure that Master’s Rutas.

When the Principal is bound to a single user (subjectUserId != null), the subject guard pins it to exactly that user. Because it is mounted with use('*') — and a wildcard middleware does not receive named route params — it parses the target user out of the path (/project/users/<id>/… or /project/users/ext/<extId>/…) and compares it to subjectUserId. A collection endpoint (no user segment) is blocked outright: a single-user token has no business reaching project-wide data. Subject binding is a hard boundary, never warn-only. Path segments are decoded exactly the way Hono decodes route params so a %2F-encoded external id cannot desync the guard from the handler.

Space isolation is enforced two ways at once. First, the owner pin: a space token’s subjectUserId is its owner Master, so the subject guard already keeps it on the owner’s path. Second, the visibility filter: file list/read/delete are keyed on principal.spaceId, so a space token only ever sees rows with its own space_id, and its uploads are stamped with that space_id automatically. A Master token sees its own files (space_id IS NULL) plus all its spaces.

Authorization pipeline: a real request runs project-auth, then integration-policy, then count-requests, then the scope/actor/subject/space guards, then the handler; project-auth, integration-policy and the guards can each reject with a status code (401/403/429), while count-requests only meters and never blocks.

project-preflight answers OPTIONS permissively (so the browser’s CORS preflight succeeds without credentials); the real request is still enforced per-integration by integration-policy. See Redis, migrations & env and the integration guide for the policy details.

This is the subtlest part of the wiring, and worth internalizing.

Five routers — users, files, images, user-tokens, and spaces — all mount under the same prefix, /project/users. In Hono, a use('*') middleware on any router mounted under a shared prefix fires for every sibling’s requests too, because * matches the whole shared prefix. A router-specific guard would therefore “bleed” onto its siblings.

Under SCOPE_ENFORCEMENT=enforce this is not cosmetic — it is a correctness bug. If the user-tokens router naively required users:read on use('*'), that requirement would bleed onto spaces and files requests, and managing a space would wrongly demand users:read + files:read and return 403 to a legitimate caller.

The wiring fixes this in two layers:

1. Transversals hoisted to a parent router. project-auth, integration-policy, and count-requests are mounted once on a parent /project/users router that wraps all the sub-routers. They must run exactly once per request (one auth lookup, one last_used_at write, one rate-limit charge, one stats increment), so hoisting them removes the N× duplication entirely.

apps/api/src/routes/index.ts
const projectUsers = new OpenAPIHono({ defaultHook: openApiDefaultHook })
projectUsers.use('*', projectAuth)
projectUsers.use('*', enforceIntegrationPolicy())
projectUsers.use('*', countRequests())
projectUsers.route('/', usersRouter)
projectUsers.route('/', filesRouter)
projectUsers.route('/', imagesRouter)
projectUsers.route('/', userTokensRouter)
projectUsers.route('/', spacesRouter)
v1.route('/project/users', projectUsers)

2. Router-specific guards confined with onSegment. Each sub-router keeps only its own scope/actor guards, and each is wrapped in onSegment(segment, guard) so it runs only when the request path contains that router’s unique segment:

apps/api/src/routes/images.ts
imagesRouter.use('*', onSegment('images', requireScope(SCOPES.FILES_WRITE)))
imagesRouter.use('*', subjectGuard()) // idempotent → safe to leave on '*'

onSegment('files', …), onSegment('tokens', …), onSegment('spaces', …), onSegment('images', …) each confine a guard to its router. It is robust to new routes (any path containing the segment is covered, so there is no per-route list to forget) and it ignores the external_id slot right after ext/, so an integrator who names a user files cannot trip a sibling’s guard.

The subject guard is deliberately left on use('*'): it is idempotent — it only ever pins a subject-bound token to its own path, and re-running it on a sibling is a no-op for non-subject-bound callers.

The same pattern protects the second shared prefix, /project, where jobs, stats, and project (keys/integrations) are siblings: the jobs router confines all three of its guards with onSegment('jobs', …) so they never bleed onto /project/stats/* or /project/keys*.

The rationale for choosing onSegment confinement over per-route dedup flags is recorded in Design decisions.