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:
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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}$/;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user