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:
2026-08-23 21:32:42 +00:00
co-authored by Claude Opus 5
parent a6509f6456
commit c38f02afa3
11 changed files with 477 additions and 79 deletions
+66 -10
View File
@@ -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');
+14 -7
View File
@@ -2,7 +2,7 @@ import type { Handler } from 'hono';
import { getUsers, getUserById, updateUser, deleteUser, listRoles, getRole, OWNER_USER_ID } from 'officerdb';
import * as errors from '@@/custom-errors';
import { deprovisionOsAccount } from '@@/os-user-deprovision';
import { dropPostgresRole, provisionPostgresRole, rolePermitsDevTools } from '@@/os-user-postgres';
import { dropPostgresRole, provisionPostgresRole } from '@@/os-user-postgres';
import { lookupOsUser } from '@@/os-user';
import { DATA_PATH } from '@@/data-path';
import { broadcastProvisionableUsers } from '@@/plugins/user-provisioning';
@@ -71,9 +71,11 @@ export const updateUserRoleHandler: Handler = async function (ctx) {
// The runtime replacement for what USER_ROLES used to guard at the type level (docs/roles-and-teams.md
// phase 2 part 4): a role that is not a row in `roles` must never reach `updateUser`, or the FK on
// `users.role` throws a raw Postgres error instead of this message.
if (typeof role !== 'string' || !(await getRole(role))) {
throw errors.BAD_REQUEST(`Unknown role '${String(role)}'`);
}
if (typeof role !== 'string') throw errors.BAD_REQUEST(`Unknown role '${String(role)}'`);
// Kept rather than re-fetched below: this is the one read of the DESTINATION role's row this handler
// needs, and `wants` reuses it instead of asking the database the same question twice.
const newRole = await getRole(role);
if (!newRole) throw errors.BAD_REQUEST(`Unknown role '${role}'`);
// ck_users_owner_is_super_admin would reject this anyway — the point of catching it here is the
// message. A raw CHECK violation surfaces as a 500 with Postgres wording, which tells the person
@@ -101,7 +103,7 @@ export const updateUserRoleHandler: Handler = async function (ctx) {
// ── The database role follows the platform role ──
//
// Without this the gate in `rolePermitsDevTools` is decorative: it would decide what a Developer gets at
// Without this, `grantsPostgres` on the role row is decorative: it would decide what a Developer gets at
// creation and then never look again, so demoting one would leave their Postgres role, their databases
// and a working password in their ~/.zshenv. A permission that survives its own revocation is worse than
// not having gated it, because the UI then says something untrue.
@@ -109,8 +111,13 @@ export const updateUserRoleHandler: Handler = async function (ctx) {
// REVOKE BEFORE RECORDING, grant after. Dropping first means a failure aborts with the role unchanged, so
// the account still says Developer and the whole thing can be retried. Doing it the other way round would
// leave a Member holding database access with nothing in the row to indicate it.
const had = rolePermitsDevTools(existing.role);
const wants = rolePermitsDevTools(role);
//
// Docker does NOT follow the role here — only Postgres does. That is a pre-existing gap (docker was
// never wired into role change, only into initial provisioning in provision-os.ts), not something this
// change introduces or was asked to close.
const oldRole = await getRole(existing.role);
const had = oldRole?.grantsPostgres ?? false;
const wants = newRole.grantsPostgres;
if (had && !wants && existing.osUser) {
const dropped = await dropPostgresRole(existing.osUser);
+15 -11
View File
@@ -5,7 +5,7 @@ import { checkOsAccount } from '@@/os-user-verify';
import { provisionSshAccess } from '@@/os-user-ssh';
import { seedShellConfig } from '@@/os-user-shell';
import { provisionClaudeCli } from '@@/os-user-claude';
import { provisionPostgresRole, rolePermitsDevTools } from '@@/os-user-postgres';
import { provisionPostgresRole, roleDevGrants } from '@@/os-user-postgres';
import { provisionRootlessDocker } from '@@/os-user-docker';
import { provisionUserDirs } from '@@/data-path';
@@ -42,7 +42,7 @@ export async function provisionOsAccount(params: {
userId: number;
email: string;
username: string;
/** Decides whether they get a Postgres role. See `rolePermitsDevTools`. */
/** Decides whether they get a Postgres role and/or a Docker daemon. See `roleDevGrants`. */
role: UserRole;
/** Inbound SSH key for `authorized_keys`. Already validated by the caller. */
inboundKey?: string | null;
@@ -92,10 +92,14 @@ export async function provisionOsAccount(params: {
// cannot do it for them and must not try, because the alternative is lending them the owner's credential.
const claude = await provisionClaudeCli({ email: params.email, osUser: account.osUser });
// A Postgres login role of the same name, with CREATEDB — for a Developer, and nobody else. What
// replaced rootless Docker for the "let me run a database to develop against" case, and it inherits
// that feature's role gate along with its purpose: `rolePermitsDevTools` is the one place that rule is
// written down.
// One read of the role row for both gates below, rather than one query per tool — `roleDevGrants`
// returns both flags together for exactly this caller.
const devGrants = await roleDevGrants(params.role);
// A Postgres login role of the same name, with CREATEDB — gated on `grantsPostgres`. What replaced
// rootless Docker for the "let me run a database to develop against" case, and it inherits that
// feature's role gate along with its purpose: `roleDevGrants` is the one place that rule is written
// down, now as a row on the role rather than a name tested in code.
//
// Also the step that shuts PUBLIC out of the platform's own database — deliberately inside the function
// that creates the role rather than in the setup script, so it cannot be skipped by an install that was
@@ -103,7 +107,7 @@ export async function provisionOsAccount(params: {
//
// Null, not a skipped-but-ok result: "there is no database role because of who they are" and "the
// database role worked" are different answers and the caller reports them differently.
const postgres = rolePermitsDevTools(params.role)
const postgres = devGrants.grantsPostgres
? await provisionPostgresRole({
email: params.email,
osUser: account.osUser,
@@ -112,14 +116,14 @@ export async function provisionOsAccount(params: {
})
: null;
// Their own rootless Docker daemon — for a Developer, and nobody else. Last, and the most tolerant of
// failure: a host without the uidmap package or a kernel that will not do rootless still gets a perfectly
// good account, minus containers.
// Their own rootless Docker daemon — gated on `grantsDocker`. Last, and the most tolerant of failure: a
// host without the uidmap package or a kernel that will not do rootless still gets a perfectly good
// account, minus containers.
//
// Behind the role gate rather than unconditional. The cost is real and it is per member, not per install:
// one daemon, one image cache and one subuid range each — see os-user-docker.ts. That is worth paying for
// someone whose job is to build things here, and not for an account that just reads its dashboards.
const docker = rolePermitsDevTools(params.role)
const docker = devGrants.grantsDocker
? await provisionRootlessDocker({
osUser: account.osUser,
uid: account.uid,