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
+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) {