Files
platform/src/servers/api/roles/router.ts
T
pastilhasandClaude Opus 5 2a0f688e10 plugins: installing one grants it to every role
The default was deny-everything: a newly installed plugin reached nobody, and
every grant had to be added by hand, per role, on a different screen, by the
owner. That made sense while the permission model was being built and there were
no members. It stopped making sense once installing a plugin became a deliberate
administrative act — somebody who installs a thing has already decided the server
should have it, and a second decision elsewhere has one reliable outcome: it gets
forgotten and the plugin looks broken.

So install grants, the owner revokes. uninstallPlugin already removed those
grants, so the pair is symmetric.

ROWS, NOT A RULE CHANGE. authorize.ts still denies on absence. The other way to
get this behaviour is to make absence mean allow, and that is the version that
bites in a year: every capability with no row for any reason — a failed write, a
half-finished install, a bug — becomes permitted and nothing looks wrong. The
blast radius here is only ever a plugin's own keys; core, execution, admin and
confined are unreachable from it.

Super Admin is skipped because ck_role_permissions_not_owner refuses it outright
and would fail the whole insert. The owner bypasses grants anyway.

Also fixes something older that would have made this half-true. A role created
through /api/roles began with NOTHING — DEFAULT_ROLE_PERMISSIONS was only ever
applied in bootstrap, so a new role could not reach chat, files or terminal while
Member could, and nothing said why. A new role now starts where Member does, plus
every installed plugin. Leaving it would mean the policy held for roles that
existed when a plugin was installed and not for any created after, which reads as
a bug in whichever half you meet first.

Verified against the live database with a throwaway role and a throwaway plugin:
the new role seeded with the confined baseline plus every installed plugin, the
grant reached all four roles and never Super Admin, and cleanup left the table
exactly as it was. 1417 pass.

NOT done: existing installs. browser, email and offscale are installed here and
granted to nobody, because they predate this. A backfill is one query and is the
owner's call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 01:39:22 +00:00

173 lines
7.9 KiB
TypeScript

import { createRouter } from '../../create-router';
import * as errors from '@@/custom-errors';
import { isSuperAdmin } from '../../super-admin';
import { listRoles, createRole, renameRole, deleteRole, setRoleGrants } from 'officerdb';
import { seedGrantsForNewRole } from '@@/plugins/grant-on-install';
/*
/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`);
}
/*
A new role starts where Member does: the confined baseline plus every installed plugin.
Not doing this is how the policy ends up half-applied — granted to roles that existed when a plugin was
installed, and not to any created afterwards. It also fixes something older: DEFAULT_ROLE_PERMISSIONS
was only ever applied at bootstrap, so a role created here used to begin with nothing at all, not even
chat or files, and nothing said why.
Not fatal. The role exists either way and its grants can be set on the permissions screen; failing the
creation over a grant would be the worse trade.
*/
const seeded = await seedGrantsForNewRole(result.role.name).catch(() => 0);
return ctx.json({ role: result.role, seededGrants: seeded });
});
/**
* 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 });
});