roles phase 3: dev tools are a row, not a hardcoded name
rolePermitsDevTools() was `role === 'Developer'` — one line gating two real grants, a Postgres login role with CREATEDB and a rootless Docker daemon. A role the owner created at runtime could never have either, which defeats the point of roles being data. Two boolean columns on `roles` now carry it, and roleDevGrants() reads the row. A role that no longer exists reads as neither grant, matching "absence denies" everywhere else. NOT the `provisioningScript` D8 asked for, and that column is removed rather than left dead. A shell script stored on a role row and run at provisioning time is arbitrary root-level code in a database column, and D8's own warning says why that is worse than it looks: `usermod -aG docker` is root by a longer route. So the row CHOOSES from a vocabulary the platform already knows how to carry out — the same shape as `multiarch` on a plugin's osDependencies, for the same reason. The seed re-asserts both flags on every run for system roles, and only for them. Without that, existing rows take the column default of false the moment it lands and Developer silently loses its tools on the next deploy — every host already has these four rows, so an insert-only seed would have shipped that. `description` stays insert-only, because an edit made on one server must survive a deploy. `db:push` could not apply this and said nothing. Dropping a column while adding another makes drizzle-kit ask whether it is a rename; with no terminal it bails and the wrapper still exits 0, leaving the seed to fail against an unchanged schema. Recorded in src/databases/CLAUDE.md with the SQL, because it will hit every host on the next deploy and no agent driving a rollout has a terminal. Verified here: SQL applied guarded, Developer keeps both grants and no other role gained any, second push emits zero DDL and no prompt, 1423 pass / 0 fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -1,16 +1,18 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { isSuperAdmin } from '../../super-admin';
|
||||
import { listRoles, createRole, renameRole, deleteRole } from 'officerdb';
|
||||
import { listRoles, createRole, renameRole, deleteRole, setRoleGrants } from 'officerdb';
|
||||
|
||||
/*
|
||||
/api/roles — role CRUD (phase 2 part 3 of docs/roles-and-teams.md).
|
||||
/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. It is not the GRANTS a role
|
||||
holds; that stays `role_permissions`, administered at `/api/users/permissions` by
|
||||
`permissionAdminRouter`. The two are separate screens for a reason: this router answers "which roles
|
||||
exist", that one answers "what may each reach" — 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".
|
||||
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 ──
|
||||
|
||||
@@ -37,13 +39,32 @@ rolesRouter.use(async (ctx, 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`. */
|
||||
/**
|
||||
* 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 };
|
||||
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 });
|
||||
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')
|
||||
@@ -80,6 +101,41 @@ rolesRouter.patch('/:name', async (ctx) => {
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user