diff --git a/src/servers/api/users/create-user.ts b/src/servers/api/users/create-user.ts index 48207773..12c50b4b 100644 --- a/src/servers/api/users/create-user.ts +++ b/src/servers/api/users/create-user.ts @@ -96,7 +96,7 @@ export const createUserHandler: Handler = async function (ctx) { // // Retryable in place afterwards via POST /users/:id/provision-linux, so a host that was not ready when the // account was made does not cost anybody their password and dashboards. - const os = await provisionOsAccount({ userId: user.id, email, username, inboundKey }); + const os = await provisionOsAccount({ userId: user.id, email, username, role: role as UserRole, inboundKey }); if (os.error) console.warn(`[users] created ${email} but its Linux side did not finish: ${os.error}`); diff --git a/src/servers/api/users/manage-users.ts b/src/servers/api/users/manage-users.ts index 864ec6d1..0afd3462 100644 --- a/src/servers/api/users/manage-users.ts +++ b/src/servers/api/users/manage-users.ts @@ -3,7 +3,8 @@ import { getUsers, getUserById, updateUser, deleteUser, USER_ROLES, OWNER_USER_I import type { UserRole } from 'officerdb'; import * as errors from '@@/custom-errors'; import { deprovisionOsAccount } from '@@/os-user-deprovision'; -import { dropPostgresRole } from '@@/os-user-postgres'; +import { dropPostgresRole, provisionPostgresRole, rolePermitsDatabase } from '@@/os-user-postgres'; +import { lookupOsUser } from '@@/os-user'; import { DATA_PATH } from '@@/data-path'; // Owner-only management of the other accounts. Everything here is gated by ownerGate in @@ -87,9 +88,61 @@ export const updateUserRoleHandler: Handler = async function (ctx) { const existing = await getUserById(id); if (!existing) throw errors.NOT_FOUND('User not found'); + // ── The database role follows the platform role ── + // + // Without this the gate in `rolePermitsDatabase` 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. + // + // 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 = rolePermitsDatabase(existing.role); + const wants = rolePermitsDatabase(role as UserRole); + + if (had && !wants && existing.osUser) { + const dropped = await dropPostgresRole(existing.osUser); + if (!dropped.ok) { + console.error(`[users] could not revoke database access for ${existing.email}: ${dropped.error}`); + throw errors.INTERNAL_SERVER_ERROR( + `Could not revoke ${existing.email}'s database access: ${dropped.error} The role was NOT changed.`, + ); + } + if (dropped.reassigned.length) { + // Their data is kept, exactly as on deletion — but it is now owned by the platform and they cannot + // reach it, so say where it went rather than letting it look deleted. + console.info( + `[users] revoked ${existing.osUser}'s database role — ${dropped.reassigned.length} database(s) ` + + `kept and reassigned to the platform: ${dropped.reassigned.join(', ')}`, + ); + } + } + const updated = await updateUser(id, { role: role as UserRole }); if (!updated) throw errors.NOT_FOUND('User not found'); - return ctx.json({ user: toPublicUser(updated) }); + + // Promotion. Non-fatal and reported, like every other provisioning step: the role change itself has + // already happened and is the thing the owner asked for. Retryable via POST /users/:id/provision-linux, + // which reads the role from the row. + let databaseError: string | null = null; + if (!had && wants && existing.osUser) { + const ids = await lookupOsUser(existing.osUser); + if (!ids) { + databaseError = `${existing.osUser} is not a Linux account on this machine, so it got no database role.`; + } else { + const granted = await provisionPostgresRole({ + email: existing.email, + osUser: existing.osUser, + uid: ids.uid, + gid: ids.gid, + }); + if (!granted.ok) databaseError = granted.error; + } + if (databaseError) console.warn(`[users] ${existing.email}: ${databaseError}`); + } + + return ctx.json({ user: toPublicUser(updated), databaseError }); }; export const deleteUserHandler: Handler = async function (ctx) { diff --git a/src/servers/api/users/provision-linux-route.ts b/src/servers/api/users/provision-linux-route.ts index b4b8f1d2..94c70cb4 100644 --- a/src/servers/api/users/provision-linux-route.ts +++ b/src/servers/api/users/provision-linux-route.ts @@ -40,10 +40,13 @@ export const provisionLinuxHandler: Handler = async function (ctx) { inboundKey = checked.key; } + // Their CURRENT role, read from the row rather than taken from the request — this route is also how a + // member promoted to Developer gets the database role they did not qualify for when the account was made. const result = await provisionOsAccount({ userId: user.id, email: user.email, username: user.username, + role: user.role, inboundKey, }); diff --git a/src/servers/api/users/provision-os.ts b/src/servers/api/users/provision-os.ts index 15dcf5da..69f56316 100644 --- a/src/servers/api/users/provision-os.ts +++ b/src/servers/api/users/provision-os.ts @@ -1,9 +1,10 @@ +import type { UserRole } from 'officerdb'; import { updateUser } from 'officerdb'; import { ensureOsUser, osUserHome } from '@@/os-user'; import { provisionSshAccess } from '@@/os-user-ssh'; import { seedShellConfig } from '@@/os-user-shell'; import { provisionClaudeCli } from '@@/os-user-claude'; -import { provisionPostgresRole } from '@@/os-user-postgres'; +import { provisionPostgresRole, rolePermitsDatabase } from '@@/os-user-postgres'; // Disabled 2026-08-13 — see the commented-out step in provisionOsAccount below. // import { provisionRootlessDocker } from '@@/os-user-docker'; import { provisionUserDirs } from '@@/data-path'; @@ -41,6 +42,8 @@ export async function provisionOsAccount(params: { userId: number; email: string; username: string; + /** Decides whether they get a Postgres role. See `rolePermitsDatabase`. */ + role: UserRole; /** Inbound SSH key for `authorized_keys`. Already validated by the caller. */ inboundKey?: string | null; }): Promise { @@ -79,18 +82,25 @@ 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. What replaced rootless Docker for the - // "let me run a database to develop against" case, at roughly none of the cost. + // 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: `rolePermitsDatabase` is the one place that rule is + // written down. // // 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 // set up before this existed. See os-user-postgres.ts. - const postgres = await provisionPostgresRole({ - email: params.email, - osUser: account.osUser, - uid: account.uid, - gid: account.gid, - }); + // + // 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 = rolePermitsDatabase(params.role) + ? await provisionPostgresRole({ + email: params.email, + osUser: account.osUser, + uid: account.uid, + gid: account.gid, + }) + : null; // ── Rootless Docker: DISABLED 2026-08-13, code kept ── // @@ -119,8 +129,8 @@ export async function provisionOsAccount(params: { // Reported in order of consequence, not in order of execution: no keys matters more than a plain prompt, // which matters more than no containers. Only one is surfaced because the UI shows one line — the rest are // in the log. - for (const step of [claude, shell, postgres] as const) { - if (!step.ok) console.warn(`[users] ${params.email}: ${step.error}`); + for (const step of [claude, shell, postgres]) { + if (step && !step.ok) console.warn(`[users] ${params.email}: ${step.error}`); } const error = !ssh.ok ? ssh.error @@ -128,7 +138,7 @@ export async function provisionOsAccount(params: { ? claude.error : !shell.ok ? shell.error - : !postgres.ok + : postgres && !postgres.ok ? postgres.error : null; return { osUser: account.osUser, sshPublicKey, error }; diff --git a/src/servers/os-user-postgres.ts b/src/servers/os-user-postgres.ts index 0c54270c..37ee56ca 100644 --- a/src/servers/os-user-postgres.ts +++ b/src/servers/os-user-postgres.ts @@ -1,6 +1,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import type { UserRole } from 'officerdb'; import { sql } from 'drizzle-orm'; import postgres from 'postgres'; import { db } from 'officerdb/db'; @@ -65,6 +66,21 @@ import { osUserHome, runAs } from './os-user'; // OWNER TO, CREATE OR REPLACE over the top, and `PGOPTIONS=-c event_triggers=off` (that GUC is superuser // only). A superuser can still use it, which is the recovery path if this ever locks something out. +/** + * Which platform roles get a database role at all. + * + * `Developer` only. This is the gate rootless Docker was under before it was disabled, inherited by the + * thing that replaced it rather than invented for it — a member who was not trusted to run containers is + * not thereby trusted to run databases. + * + * `Admin` is deliberately NOT included. Administering the platform is not developing on it, and the two + * are separate roles precisely so they can be held separately. Add it here if that turns out to be wrong; + * do not add it at a call site. + * + * The owner never reaches this — they run as the service account and already have the cluster. + */ +export const rolePermitsDatabase = (role: UserRole): boolean => role === 'Developer'; + /** Legal Linux/Postgres account name. Identical to `validateUsername`, restated because this one reaches SQL. */ const ROLE_NAME_RE = /^[a-zA-Z0-9._-]{2,32}$/;