drop the postgres role when a member is decommissioned

It was written and never called — dropPostgresRole had zero call sites, so deleting a
member left their role and databases on the cluster.

That is the uid trap in a different id space, and worse. provisionPostgresRole ADOPTS
an existing role, so a role left behind is inherited whole, with its databases, by the
next member who gets the same username. useradd hands out the lowest free uid by
accident; the owner hands out usernames on purpose, so reuse is likelier here, not
less.

Rewritten to PRESERVE rather than destroy. The first version dropped the databases,
which is inconsistent with severMemberTree three files away — that chowns a member's
files to the service user rather than deleting them, and a database is the same kind
of thing. The owner removing an account has not necessarily asked to destroy the work
in it, and dropping is the one choice that cannot be walked back.

Needs the full idiom, per database, and both halves matter:

  ALTER DATABASE .. OWNER TO         REASSIGN OWNED does not move database ownership
  REASSIGN OWNED BY .. TO ..         moves tables, schemas, functions
  DROP OWNED BY ..                   removes what is left, which after a reassign is
                                     only the GRANTS — without it DROP ROLE still
                                     refuses, an ACL entry is a dependency too

Both statements act only on the database they are connected to, so it is a connection
per database rather than a loop over `db`.

Ordered after the Linux teardown (which can fail and abort, and must not do so after
something irreversible) and before deleteUser (the row is what remembers there is
anything to clean up).

Verified live: a member with a database, a table, a row and a schema. Naive DROP ROLE
refused with "2 objects in database carol_app". After the sequence: role gone, row
intact, table owned by postgres. Then recreated the same username and confirmed she is
REFUSED from the old database — the login trigger holds because ownership moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 12:27:13 +00:00
co-authored by Claude Opus 5
parent 9f15de3448
commit 2ac58e2007
2 changed files with 121 additions and 5 deletions
+33
View File
@@ -3,6 +3,7 @@ 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 { DATA_PATH } from '@@/data-path';
// Owner-only management of the other accounts. Everything here is gated by ownerGate in
@@ -153,6 +154,38 @@ export const deleteUserHandler: Handler = async function (ctx) {
}
}
// ── The Postgres side, after the Linux side and before the row ──
//
// The same trap as the uid, in a different id space: `provisionPostgresRole` ADOPTS a role that already
// exists, so a role left behind here is inherited whole — with its databases — by the next member who
// gets the same username. The owner hands out usernames, so reuse is likelier than a reissued uid, not
// less.
//
// Ordered after the Linux teardown deliberately. That step can fail and abort, and it must not abort
// AFTER something irreversible has happened to their databases. Ordered before `deleteUser` for the
// reason stated above: the row is what remembers there is anything left to clean up.
//
// Their data is kept — the databases are reassigned to the platform role, not dropped, the same way
// `severMemberTree` chowns their files rather than deleting them.
if (existing.osUser) {
const pg = await dropPostgresRole(existing.osUser);
if (!pg.ok) {
console.error(`[users] POSTGRES DEPROVISION FAILED for ${existing.email}: ${pg.error}`);
throw errors.INTERNAL_SERVER_ERROR(
`Could not remove ${existing.email}'s Postgres role: ${pg.error} ` +
`The platform account was NOT deleted, so this can be retried.`,
);
}
if (pg.removed) {
console.info(
`[users] dropped Postgres role ${existing.osUser}` +
(pg.reassigned.length
? `${pg.reassigned.length} database(s) kept and reassigned: ${pg.reassigned.join(', ')}`
: ' — it owned no databases'),
);
}
}
// Deleting a user cascades: passkeys, dashboards, screens, email accounts, playlists, everything keyed
// to them. There is no undo, which is why the UI asks first.
await deleteUser(id);