import type { MiddlewareHandler } from 'hono'; import { createRouter } from '../../create-router'; import * as errors from '@@/custom-errors'; import { isSuperAdmin } from '../../super-admin'; import { getAllRoleGrants, replaceRoleGrants, USER_ROLES } from 'officerdb'; import type { UserRole } from 'officerdb'; import { CAPABILITIES, GRANTABLE_CAPABILITIES, CAPABILITY_BY_KEY } from '../../capabilities/registry'; import { getEffectiveCapabilities, invalidateRoleGrants } from '../../capabilities/authorize'; // Two audiences, deliberately split. // // `/user/capabilities` answers "what may I do" for the caller, and every account may ask. The dock, the // app registry and the route guards all read it, so it is the frontend's whole view of the permission // model — and it must never be the frontend's ENFORCEMENT of it. Hiding a dock icon is a courtesy; the // 403 in origin-validation is the lock. // // Everything else here is owner-only and edits the policy itself. const ownerGate: MiddlewareHandler = async (ctx, next) => { if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('Capability management is owner-only'); return next(); }; /** What the caller may reach. Mounted under /api/user, which is a `core` capability, so nobody is 403'd. */ export const selfCapabilitiesRouter = createRouter(); selfCapabilitiesRouter.get('/capabilities', async (ctx) => { const userId = ctx.get('user').id as number; const { isOwner, grants } = await getEffectiveCapabilities(userId); // The owner holds everything, and says so by listing it rather than by a flag the frontend has to // remember to special-case. One shape for both audiences means one code path in the UI. const held = isOwner ? CAPABILITIES.map((c) => ({ key: c.key, level: 'write' as const })) : [...grants].map(([key, level]) => ({ key, level })); const heldKeys = new Set(held.map((h) => h.key)); return ctx.json({ isOwner, capabilities: held, // Flattened for the dock and the route guard, which care about paths rather than capability keys. routes: held.flatMap(({ key }) => CAPABILITY_BY_KEY.get(key)?.routes ?? []), // The complement, and the frontend genuinely needs both. "Not in `routes`" cannot distinguish a route // this account lacks from a route no capability claims at all — `/`, the settings shell, the sign-in // screens — and a guard that cannot tell those apart either blanks the app or guards nothing. deniedRoutes: CAPABILITIES.filter((c) => !heldKeys.has(c.key)).flatMap((c) => c.routes ?? []), }); }); /** Policy administration. Owner-only, mounted under /api/users. */ export const capabilityAdminRouter = createRouter(); capabilityAdminRouter.get('/capabilities', ownerGate, async (ctx) => { return ctx.json({ // Only the grantable kind is offered. `execution` and `admin` are deliberately not in this list: // a UI that shows a checkbox it will refuse to honour is worse than one that never offered it. capabilities: GRANTABLE_CAPABILITIES.map((c) => ({ key: c.key, label: c.label, description: c.description, // What the owner is actually deciding about, shown so the grant is legible rather than a name. routes: c.routes ?? [], hasPersonalWrites: !!c.personal?.length, })), // Roles a grant may name. Super Admin is excluded: the owner bypasses this table entirely, and the // database refuses a row for that role. roles: USER_ROLES.filter((r) => r !== 'Super Admin'), grants: await getAllRoleGrants(), }); }); capabilityAdminRouter.put('/capabilities/:role', ownerGate, async (ctx) => { const role = ctx.req.param('role') as UserRole; if (!USER_ROLES.includes(role)) throw errors.BAD_REQUEST(`Unknown role '${role}'`); if (role === 'Super Admin') throw errors.BAD_REQUEST('The owner is not governed by grants'); const body = ctx.get('body') as { grants?: unknown } | undefined; const raw = body?.grants; if (!Array.isArray(raw)) throw errors.BAD_REQUEST('Expected { grants: [{ capability, level }] }'); const grants: { capability: string; level: 'read' | 'write' }[] = []; for (const entry of raw) { const { capability, level } = (entry ?? {}) as { capability?: unknown; level?: unknown }; if (typeof capability !== 'string') throw errors.BAD_REQUEST('Each grant needs a capability key'); if (level !== 'read' && level !== 'write') throw errors.BAD_REQUEST(`Bad level for '${capability}'`); // The registry is the authority on what a capability key means, which is why the column has no CHECK. // This is where that authority is applied — rejecting a name nothing defines, and refusing to store a // grant the resolver would drop on read anyway. const known = CAPABILITY_BY_KEY.get(capability); if (!known) throw errors.BAD_REQUEST(`Unknown capability '${capability}'`); if (known.kind !== 'app') { throw errors.BAD_REQUEST( known.kind === 'execution' ? `${known.label} runs as the server owner and can never be granted` : `${known.label} is not grantable`, ); } grants.push({ capability, level }); } await replaceRoleGrants(role, grants); // The cache's entire invalidation contract, discharged here. Adding a second writer means adding a // second call to this — see the note on grantCache in capabilities/authorize.ts. invalidateRoleGrants(role); return ctx.json({ role, grants }); });