deprovision a member's linux account when the platform account goes

Implements docs/deprovision-os-account.md. Until now deleteUserHandler removed the row, cascaded the
database, and left the entire Linux side running — measured on production on 2026-08-12: working login
shell, healthy postgres container, 454M of data, uid queued for the next useradd to reissue along with
everything still owned by it.

The load-bearing rule from the spec: sever the data from the uid BEFORE releasing the uid, and if
severing fails, do not release. A failed deprovision is not a broken account, it is a trap for whoever
is created next.

Sequence: disable-linger, terminate-user, reap-and-prove, chown -R, userdel (never -r).

  reap    terminate-user is not a barrier. Production measured a three-hour-old `/bin/zsh -i` surviving
          it AND the removal of /run/user/<uid>. So: pkill, bounded wait, pkill -9, bounded wait, and a
          final count that must be zero or the account is not released.
  chown   fixes the uid and subuid halves in one pass — it rewrites every file it walks whatever owned
          it. The range is still captured first, because userdel removes the /etc/subuid entry and after
          that nothing on the machine remembers what it was. It is returned on every path including the
          failures, and logged as the exact assert-uid-free.sh command line.

Two guards the spec did not ask for, both pure and unit-tested:

  guardDeletable    ensureOsUser's adoption rule backwards. Deletable only if the passwd home is the one
                    the platform would have confined, and uid >= 1000. Without it `userdel root` is one
                    bad users.osUser away and nothing else in the sequence would object.
  guardMemberTree   the tree must resolve to a direct child of DATA_PATH. The email reaches join() from a
                    database row and the result is the argument to a recursive chown.

chown runs with -h. Measured here that `chown -R` already declines to follow a symlink out of the tree and
re-owns the link itself, but the argv should say so rather than rest on traversal semantics — and
re-owning links is what makes `find -uid` (lstat) a meaningful check afterwards.

destroy exists, has no call site, and is chown-then-delete-as-the-service-user rather than sudo rm -rf, so
a recursive root delete built from a database column does not exist in this codebase.

deleteUserHandler now runs this FIRST and refuses to delete the row if it fails: the row is what remembers
there is anything to clean up, so deleting it first makes a failure unrecoverable through the UI.

NOT YET RUN AGAINST A REAL ACCOUNT. Only the pure guards have tests. The five-step validation is in the
doc; it needs the production host, a shell left open, and a container writing as a non-root user — the two
cases the quiet path passes vacuously.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 04:19:50 +00:00
co-authored by Claude Opus 5
parent f34d7fef70
commit 46799dada8
5 changed files with 564 additions and 12 deletions
+43
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 { OS_USERS_ENABLED } from '@@/os-user';
import { deprovisionOsAccount } from '@@/os-user-deprovision';
// Owner-only management of the other accounts. Everything here is gated by ownerGate in
// users-router.ts; these handlers assume the caller is the Super Admin.
@@ -104,6 +105,48 @@ export const deleteUserHandler: Handler = async function (ctx) {
const existing = await getUserById(id);
if (!existing) throw errors.NOT_FOUND('User not found');
// ── The Linux side goes FIRST, and its failure stops the delete ──
//
// This used to be the whole handler: remove the row, cascade the database, done. Measured on the
// production host on 2026-08-12, immediately after deleting a member through this route: their Linux
// account was still alive with a working login shell, their rootless Docker daemon was still running a
// healthy postgres container, and 454 MB of their data was intact — while the platform had forgotten
// they existed. `useradd` hands out the lowest free uid, so that number was queued up to be reissued to
// the next member along with everything still owned by it.
//
// Ordered this way round because the row is what remembers there is anything to clean up. Delete it
// first and a failed deprovision is unrecoverable through the UI: no row, no osUser, nothing to retry
// against. Keeping the account on failure is also the safer half of the trade — an account that still
// exists is inert, whereas a freed uid whose files still carry it is the hazard itself.
if (OS_USERS_ENABLED && existing.osUser) {
const deprovisioned = await deprovisionOsAccount({ email: existing.email, osUser: existing.osUser });
if (!deprovisioned.ok) {
// Loud on purpose. A missing Docker install warns into a log; this one names the account, the stage
// and the freed range — which after a failed release is the only surviving record of it.
console.error(
`[users] DEPROVISION FAILED for ${existing.email} at stage '${deprovisioned.stage}': ${deprovisioned.error}` +
(deprovisioned.freed
? ` — uid ${deprovisioned.freed.uid}, subuid ${deprovisioned.freed.subUid?.start ?? 'none'}` +
` ${deprovisioned.freed.subUid?.count ?? ''}`
: ''),
);
throw errors.INTERNAL_SERVER_ERROR(
`Could not remove ${existing.email}'s Linux account: ${deprovisioned.error} ` +
`The platform account was NOT deleted, so this can be retried.`,
);
}
for (const warning of deprovisioned.warnings) console.warn(`[users] ${existing.email}: ${warning}`);
if (deprovisioned.freed) {
// The audit line. `scripts/assert-uid-free.sh --check` takes exactly these arguments, and after
// `userdel` this log is the only place the freed subuid range still exists.
const { osUser, uid, subUid } = deprovisioned.freed;
console.info(
`[users] deprovisioned ${osUser} — verify with: sudo ./scripts/assert-uid-free.sh --check ` +
`${osUser} ${uid} ${subUid?.start ?? '<no-subuid-range>'} ${subUid?.count ?? ''}`,
);
}
}
// 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);