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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user