only a Developer gets a postgres role

The gate rootless Docker was under, which I did not know about when the Postgres role
replaced it. It inherits the rule along with the purpose: a member not trusted to run
containers is not thereby trusted to run databases.

rolePermitsDatabase() is the single place that rule is written. Admin is deliberately
NOT included — administering the platform is not developing on it, and they are
separate roles precisely so they can be held separately. Say so if that is wrong.

provisionOsAccount now takes the role. Two call sites: create-user passes what the
owner picked, provision-linux-route reads it from the row — which makes that route
the way a member promoted to Developer gets the database role they did not qualify
for when their account was made.

The half that makes the gate real is in updateUserRoleHandler. Without it the rule
would decide what a Developer gets at creation and never look again, so demoting one
would leave their role, their databases and a working password in their ~/.zshenv —
a permission surviving its own revocation, with the UI then saying something untrue.

Revoke before recording, grant after: the drop runs BEFORE updateUser so a failure
aborts with the role unchanged and the whole thing retryable. Promotion runs after and
is non-fatal, like every other provisioning step.

Demotion keeps their data, same as deletion — databases are reassigned to the platform
role, not dropped — and logs where it went, so it does not look deleted.

KNOWN, not handled: a demoted member keeps a stale ~/.pgpass and ~/.zshenv naming a
role that no longer exists. Harmless (the connection just fails) but untidy, and it
means `cat ~/.zshenv` shows a password that no longer works.

Verified: transpiles, both call sites updated. Still no tsgo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 12:30:33 +00:00
co-authored by Claude Opus 5
parent 2ac58e2007
commit bf7e919593
5 changed files with 97 additions and 15 deletions
+1 -1
View File
@@ -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}`);
+55 -2
View File
@@ -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) {
@@ -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,
});
+22 -12
View File
@@ -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<OsProvisionOutcome> {
@@ -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 };