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.
The five axes
Section titled “The five axes”| # | 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.
Axis 1 — project (project-auth)
Section titled “Axis 1 — project (project-auth)”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.
Axis 2 — scopes (scope-guard)
Section titled “Axis 2 — scopes (scope-guard)”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 scopeand lets the request through.enforce— returns403on 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— onlyactorType === 'project'passes. Reserved for managing API keys, integrations, and plan definitions. An integration must never see or rotate thePROJECT_API_KEY, regardless of scopes.require-provisioner— onlyprojectorintegrationpasses; 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— onlyprojectoruserpasses; 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.
Axis 4 — subject (subject-guard)
Section titled “Axis 4 — subject (subject-guard)”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.
Axis 5 — space (Ruta)
Section titled “Axis 5 — space (Ruta)”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.
The request pipeline
Section titled “The request pipeline”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.
The Hono shared-prefix “bleed” gotcha
Section titled “The Hono shared-prefix “bleed” gotcha”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.
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:
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.