import { createRouter } from '../../create-router'; import * as errors from '@@/custom-errors'; import { isSuperAdmin } from '../../super-admin'; import { listRoles, createRole, renameRole, deleteRole, setRoleGrants } from 'officerdb'; /* /api/roles — role CRUD (phase 2 part 3 of docs/roles-and-teams.md), plus grants (phase 3). This is the roles TABLE — a name, a description, the `isSystem` flag, and since phase 3 the two provisioning flags (`grantsPostgres`, `grantsDocker`). It is not the PERMISSION grants a role holds; those stay `role_permissions`, administered at `/api/users/permissions` by `permissionAdminRouter`. The two are separate screens for a reason: this router answers "which roles exist, and what does holding one provision on the machine", that one answers "what may each reach in the app" — and deleting a role here has to know the second question's table too, because the guard below is "does anyone hold it", not "what does it grant". ── Owner only, explicitly ── `roles` is `kind: 'admin'` in the permission registry, so the global gate already refuses a non-owner before a request reaches here. This is the same belt-and-braces every other admin router in this directory adds on top of that (app-store, server-admin): a mistake in the registry must not turn into the ability to rename or delete a role. ── Why delete refuses instead of reassigning ── D4 in the design doc: never move a role's holders onto a fallback. That is a permission change nobody asked for, and "absence denies" — the posture everywhere else in this system — means a role with accounts still on it stays exactly as reachable as it was until every one of them is moved off by someone who chose to. */ export const rolesRouter = createRouter(); rolesRouter.use(async (ctx, next) => { if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('Role management is owner-only'); return next(); }); /** GET /api/roles — every role, with how many accounts hold each. */ rolesRouter.get('/', async (ctx) => ctx.json({ roles: await listRoles() })); /** * POST /api/roles — create a role. Always non-system; see the schema comment on `isSystem`. * * `grantsPostgres`/`grantsDocker` default to false when omitted, matching the column defaults — creating * a role must not hand out a Postgres login and a Docker daemon because a form field was left out of the * request body. */ rolesRouter.post('/', async (ctx) => { const body = ctx.get('body') as { name?: unknown; description?: unknown; grantsPostgres?: unknown; grantsDocker?: unknown; }; if (typeof body?.name !== 'string') throw errors.BAD_REQUEST('name is required'); const description = typeof body.description === 'string' ? body.description : null; if (body.grantsPostgres !== undefined && typeof body.grantsPostgres !== 'boolean') { throw errors.BAD_REQUEST('grantsPostgres must be a boolean'); } if (body.grantsDocker !== undefined && typeof body.grantsDocker !== 'boolean') { throw errors.BAD_REQUEST('grantsDocker must be a boolean'); } const grantsPostgres = typeof body.grantsPostgres === 'boolean' ? body.grantsPostgres : undefined; const grantsDocker = typeof body.grantsDocker === 'boolean' ? body.grantsDocker : undefined; const result = await createRole({ name: body.name, description, grantsPostgres, grantsDocker }); if (!result.ok) { throw result.reason === 'invalid-name' ? errors.BAD_REQUEST('a role name cannot be empty') : errors.CONFLICT(`a role named '${body.name.trim()}' already exists`); } return ctx.json({ role: result.role }); }); /** * PATCH /api/roles/:name — rename. Refuses a system role outright: `Developer`, `Super Admin` and * `Member` are tested by string in code that has not been updated to read this table (see D4), so * renaming one of those would silently detach the code's own idea of who holds what. */ rolesRouter.patch('/:name', async (ctx) => { const from = ctx.req.param('name'); const body = ctx.get('body') as { name?: unknown }; if (typeof body?.name !== 'string') throw errors.BAD_REQUEST('name is required'); const result = await renameRole(from, body.name); if (!result.ok) { switch (result.reason) { case 'not-found': throw errors.NOT_FOUND(`no such role '${from}'`); case 'system': throw errors.FORBIDDEN( `'${from}' is a system role and the platform's own code names it — it cannot be renamed`, ); case 'invalid-name': throw errors.BAD_REQUEST('a role name cannot be empty'); case 'duplicate': throw errors.CONFLICT(`a role named '${body.name.trim()}' already exists`); } } return ctx.json({ role: result.role }); }); /** * PATCH /api/roles/:name/grants — set `grantsPostgres`/`grantsDocker`. Refuses a system role, for a * different reason than the rename route above: `scripts/seed-roles.ts` re-asserts a system role's grants * on every `postdb:push`, so a write here would look like it worked and then silently revert on the next * deploy. A custom role has no such seed pinning it, so this is where its grants actually get set once it * exists — `createRole` only takes an initial value. * * Either field may be omitted; only the ones present are changed (see `setRoleGrants`). */ rolesRouter.patch('/:name/grants', async (ctx) => { const name = ctx.req.param('name'); const body = ctx.get('body') as { grantsPostgres?: unknown; grantsDocker?: unknown }; if (body?.grantsPostgres === undefined && body?.grantsDocker === undefined) { throw errors.BAD_REQUEST('grantsPostgres and/or grantsDocker is required'); } if (body.grantsPostgres !== undefined && typeof body.grantsPostgres !== 'boolean') { throw errors.BAD_REQUEST('grantsPostgres must be a boolean'); } if (body.grantsDocker !== undefined && typeof body.grantsDocker !== 'boolean') { throw errors.BAD_REQUEST('grantsDocker must be a boolean'); } const grantsPostgres = typeof body.grantsPostgres === 'boolean' ? body.grantsPostgres : undefined; const grantsDocker = typeof body.grantsDocker === 'boolean' ? body.grantsDocker : undefined; const result = await setRoleGrants(name, { grantsPostgres, grantsDocker }); if (!result.ok) { throw result.reason === 'not-found' ? errors.NOT_FOUND(`no such role '${name}'`) : errors.FORBIDDEN( `'${name}' is a system role — its grants are declared in scripts/seed-roles.ts, not edited here`, ); } return ctx.json({ role: result.role }); }); /** DELETE /api/roles/:name — refuses a system role, and refuses one any account still holds. */ rolesRouter.delete('/:name', async (ctx) => { const name = ctx.req.param('name'); const result = await deleteRole(name); if (!result.ok) { switch (result.reason) { case 'not-found': throw errors.NOT_FOUND(`no such role '${name}'`); case 'system': throw errors.FORBIDDEN( `'${name}' is a system role and the platform's own code names it — it cannot be deleted`, ); case 'in-use': throw errors.CONFLICT( `${result.holderCount} account(s) still hold '${name}' — move them to another role first`, ); } } return ctx.json({ ok: true }); });