import { rm } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { userInfo } from 'node:os'; import { join, resolve } from 'node:path'; import { DATA_PATH } from './data-path'; import { lookupOsUser, osUserHome } from './os-user'; // Taking a member's Linux account away again. // // The specification, and every measured claim in it, is `docs/deprovision-os-account.md` — written from a // manual teardown on the production host on 2026-08-11. Read it before changing the ORDER of anything here. // // ── Why this is not the mirror image of provisioning ── // // `provisionOsAccount` may fail freely: an account that did not get its Linux side is merely unusable, and // the owner presses retry. This one cannot. `useradd` allocates the lowest free uid, so the moment `userdel` // returns, that number is available to the next member — while the previous member's home, keys and // container storage may still be owned by it. A half-finished deprovision does not leave a broken account; // it leaves a **trap**, and the next person to create an account walks into it. // // Hence the one rule the sequence exists to enforce: // // Sever the data from the uid BEFORE releasing the uid. If severing fails, DO NOT release. // // The manual teardown got exactly this backwards — it ran `userdel` first and cleaned up afterwards — and // the window it opened is the reason this file's steps are ordered rather than grouped. // // ── The subuid half ── // // A member's rootless Docker files are not owned by their uid. Container processes map through // `/etc/subuid`, so on the production host `green`'s postgres data directory was owned by 231141 // (their range start + 70, postgres's uid inside the Alpine image). `userdel` frees the whole range along // with the uid. So "nothing is owned by uid 1002" can be true while hundreds of megabytes are still owned by // ids a future member's containers will map onto. // // `chown -R` fixes both at once — it rewrites every file it walks regardless of who owned it — which is why // severing is a single operation rather than one pass per id space. But the range still has to be CAPTURED // before `userdel`, because that is the last moment anyone can ask what it was. It is returned to the caller // for exactly that reason; `scripts/assert-uid-free.sh --check` takes it as an argument. /** What to do with the member's files. `preserve` is the default and the only one with a call site. */ export type DeprovisionPolicy = 'preserve' | 'destroy'; /** * The identity of the account as it was before deletion. * * Returned even on failure when it was captured, because it is unrecoverable afterwards: `userdel` removes * the `/etc/subuid` entry, and nothing on the machine then remembers which range the account held. */ export type FreedIdentity = { osUser: string; uid: number; /** Null when the account had no `/etc/subuid` entry — rootless Docker was never provisioned for it. */ subUid: { start: number; count: number } | null; }; export type DeprovisionResult = | { ok: true; /** False when there was nothing to do — no Linux account by that name. Idempotent re-runs land here. */ removed: boolean; freed: FreedIdentity | null; /** Non-fatal residue. The uid is safe to reissue; something cosmetic outlived the teardown. */ warnings: string[]; } | { ok: false; error: string; /** Which step refused. `sever` in particular means the account is intact and MUST stay that way. */ stage: 'guard' | 'reap' | 'sever' | 'release'; freed: FreedIdentity | null; }; async function sudo(args: string[]): Promise<{ ok: boolean; out: string }> { const proc = Bun.spawn(['sudo', '-n', ...args], { stdout: 'pipe', stderr: 'pipe' }); const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); return { ok: (await proc.exited) === 0, out: `${out}${err}`.trim() }; } async function plain(args: string[]): Promise<{ ok: boolean; out: string }> { const proc = Bun.spawn(args, { stdout: 'pipe', stderr: 'pipe' }); const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); return { ok: (await proc.exited) === 0, out: `${out}${err}`.trim() }; } /** The account this process runs as — who severed data is reassigned to. */ const serviceUser = (): string => userInfo().username; /** * The account's `/etc/subuid` range, read before anything is removed. * * Absent is normal, not an error: rootless Docker is provisioned per member but tolerated when it fails, and * an account made before that existed has no entry at all. */ export function parseSubUidEntry(fileContent: string, osUser: string): { start: number; count: number } | null { for (const line of fileContent.split('\n')) { const [name, start, count] = line.split(':'); if (name !== osUser) continue; const parsedStart = Number(start); const parsedCount = Number(count); if (!Number.isInteger(parsedStart) || !Number.isInteger(parsedCount) || parsedCount <= 0) return null; return { start: parsedStart, count: parsedCount }; } return null; } /** * How many processes the uid still owns. * * `pgrep` exits 1 on no match, which is the expected answer here — so the exit code says nothing and the * line count is the measurement. */ async function processCount(uid: number): Promise { const found = await plain(['pgrep', '-u', String(uid)]); if (!found.out) return 0; return found.out.split('\n').filter((line) => line.trim()).length; } /** * Refuse to touch an account that is not ours to touch. * * This is `ensureOsUser`'s adoption rule pointed the other way, and it is the most important function in the * file. Adoption asks "may I take this account over"; if it is wrong, a member gets somebody else's home. * This asks "may I DELETE this account" — and if it is wrong, `userdel root` is a single sudo away, driven by * a string in a database row. * * The passwd home is the proof of ownership. `ensureOsUser` refuses to adopt an account whose home is not the * one it was going to confine, so any account the platform created or adopted has `home == osUserHome(email)`. * Anything else on this machine, by any name, does not — including every system account. * * Exported so it can be tested against the cases that matter without a machine to delete users on. */ export function guardDeletable(params: { osUser: string; uid: number; passwdHome: string | null; expectedHome: string; }): { ok: true } | { ok: false; error: string } { if (params.uid < 1000) { return { ok: false, error: `refusing to deprovision '${params.osUser}': uid ${params.uid} is a system account` }; } if (params.passwdHome !== params.expectedHome) { return { ok: false, error: `refusing to deprovision '${params.osUser}': its home is ${params.passwdHome ?? 'unknown'}, not ` + `${params.expectedHome}. This is not an account the platform created.`, }; } return { ok: true }; } /** * The member's tree must be a direct child of `DATA_PATH`, spelled with no traversal. * * The email arrives from a database row and reaches `join()`, so `../..` in it would walk out of `DATA_PATH` * — and this path is the argument to a recursive `chown` and, under `destroy`, to a recursive delete. Cheap * to assert, and the failure it prevents has no upper bound. */ export function guardMemberTree( dataPath: string, email: string, ): { ok: true; tree: string } | { ok: false; error: string } { if (!dataPath) return { ok: false, error: 'DATA_PATH is empty; refusing to resolve a member tree' }; const tree = resolve(join(dataPath, email)); const parent = resolve(dataPath); if (tree === parent || !tree.startsWith(`${parent}/`) || tree.slice(parent.length + 1).includes('/')) { return { ok: false, error: `refusing to operate on ${tree}: not a direct child of ${parent}` }; } return { ok: true, tree }; } /** * Remove a member's Linux account, severing their data from the uid first. * * Idempotent. An account that is already gone returns `{ ok: true, removed: false }`, and every step * tolerates having been done before — so a partial failure can be retried by calling this again rather than * by finishing it by hand. * * Never throws; returns a result. But unlike the provisioning functions, `ok: false` here is not * cosmetic — see the note at the top of the file, and `stage` on the failure result. */ export async function deprovisionOsAccount(params: { email: string; osUser: string; policy?: DeprovisionPolicy; }): Promise { const policy: DeprovisionPolicy = params.policy ?? 'preserve'; const warnings: string[] = []; // Absent is success. This is what a re-run after a completed deprovision looks like, and what an account // that never had a Linux side looks like — neither is a problem to report. const ids = await lookupOsUser(params.osUser); if (!ids) return { ok: true, removed: false, freed: null, warnings }; const passwd = await plain(['getent', 'passwd', params.osUser]); const passwdHome = passwd.ok ? (passwd.out.split(':')[5] ?? null) : null; const expectedHome = osUserHome(params.email); const allowed = guardDeletable({ osUser: params.osUser, uid: ids.uid, passwdHome, expectedHome }); if (!allowed.ok) return { ok: false, stage: 'guard', freed: null, error: allowed.error }; const treeGuard = guardMemberTree(DATA_PATH, params.email); if (!treeGuard.ok) return { ok: false, stage: 'guard', freed: null, error: treeGuard.error }; // ── Capture, before anything can destroy the evidence ── // // Read now because `userdel` removes the /etc/subuid entry with the account, and after that there is no way // to ask what range it held. Carried on every return path below, including the failures, so an operator // auditing a partial teardown still has the numbers `assert-uid-free.sh --check` needs. const subuidFile = await sudo(['cat', '/etc/subuid']); const freed: FreedIdentity = { osUser: params.osUser, uid: ids.uid, subUid: subuidFile.ok ? parseSubUidEntry(subuidFile.out, params.osUser) : null, }; if (!subuidFile.ok) warnings.push('could not read /etc/subuid; the freed range is unknown and unverifiable'); // ── 1. Linger off, before terminating anything ── // // Lingering keeps a systemd user manager alive with no login session. Terminate first and linger can bring // it straight back; disable first and nothing can respawn in the gap. A non-lingering account makes this a // no-op, which is why its failure is a warning rather than a stop. const linger = await sudo(['loginctl', 'disable-linger', params.osUser]); if (!linger.ok) warnings.push(`disable-linger reported: ${linger.out}`); // ── 2. Terminate, then PROVE it ── // // Measured on the production host: `terminate-user` is not a barrier. A three-hour-old `/bin/zsh -i` owned // by the member survived it, and survived /run/user/ being removed. `userdel` refuses while any // process owned by the account lives, so trusting this call works on a quiet account and fails on a member // who left a shell open — which is the normal case, not the edge one. await sudo(['loginctl', 'terminate-user', params.osUser]); const reaped = await reapProcesses(ids.uid); if (!reaped.ok) return { ok: false, stage: 'reap', freed, error: reaped.error }; // ── 3. Sever the data from the uid, BEFORE releasing it ── const severed = await severMemberTree({ tree: treeGuard.tree, policy }); if (!severed.ok) { // Deliberately returns here rather than continuing. An account left intact is inert; a freed uid whose // files are still owned by it is the hazard this whole file exists to prevent. return { ok: false, stage: 'sever', freed, error: severed.error }; } // ── 4. Release ── // // Never `-r`. It would delete the home, which contradicts `preserve` and would make `destroy` a consequence // of a flag rather than of an explicit decision. Plain `userdel` was measured to remove the passwd, shadow // and group entries and both the /etc/subuid and /etc/subgid ranges. const del = await sudo(['userdel', params.osUser]); if (!del.ok) { return { ok: false, stage: 'release', freed, error: `userdel failed for ${params.osUser}: ${del.out}. Their data has already been reassigned to ` + `${serviceUser()}, so the account is inert — but it still exists. Retry.`, }; } // Residue: reported, not fatal. /run/user/ is a tmpfs systemd normally reaps with the session; if it // outlives one, it is empty, cleared at reboot, and no reason to call a correct teardown a failure. if (existsSync(`/run/user/${ids.uid}`)) { warnings.push(`/run/user/${ids.uid} still exists; it is tmpfs and clears on reboot`); } if (existsSync(`/var/lib/systemd/linger/${params.osUser}`)) { warnings.push(`linger marker for ${params.osUser} survived disable-linger`); } return { ok: true, removed: true, freed, warnings }; } /** * Kill everything the uid owns, escalating, and refuse to return success while any of it lives. * * The bounded waits are the point. `pkill` returns as soon as the signal is delivered, not when the process * has gone, so a check that follows it immediately reads the state before the kill took effect. */ async function reapProcesses(uid: number): Promise<{ ok: true } | { ok: false; error: string }> { // Re-asserted here rather than trusted from the caller. This is the one function that signals by uid alone // — the name is not in the argv — so if the number were ever wrong, it would be wrong about somebody else's // processes with nothing else to catch it. if (uid < 1000) return { ok: false, error: `refusing to signal uid ${uid}: not a member account` }; if ((await processCount(uid)) === 0) return { ok: true }; await sudo(['pkill', '-u', String(uid)]); for (let attempt = 0; attempt < 10 && (await processCount(uid)) > 0; attempt++) { await Bun.sleep(200); } if ((await processCount(uid)) > 0) { await sudo(['pkill', '-9', '-u', String(uid)]); for (let attempt = 0; attempt < 10 && (await processCount(uid)) > 0; attempt++) { await Bun.sleep(200); } } const remaining = await processCount(uid); if (remaining > 0) { return { ok: false, error: `${remaining} process(es) owned by uid ${uid} survived SIGKILL; not releasing the account`, }; } return { ok: true }; } /** * Break the link between the member's files and their uid. * * `preserve` reassigns; `destroy` reassigns and then deletes. Destroying via reassignment rather than via * `sudo rm -rf` is deliberate: after the chown the service user owns every byte and can delete the tree * itself, so a recursive delete as root — with a path built from a database column — never has to exist in * this codebase. * * `-h` because a symlink must be re-owned rather than followed. Measured on this host: `chown -R` already * behaves this way (a symlink pointing outside the tree was left untouched, and the link's own ownership was * rewritten), but the flag states it in the argv instead of resting on traversal semantics nobody documented * — and if it ever did follow, the target would be whatever a member chose to point at. * * Re-owning the symlinks is also what makes the audit meaningful: `find -uid` uses `lstat`, so a link left * owned by the freed uid is a failing check. */ async function severMemberTree(params: { tree: string; policy: DeprovisionPolicy; }): Promise<{ ok: true } | { ok: false; error: string }> { // Nothing to sever is success — an account whose directories were already removed by hand still needs its // passwd entry released, and refusing here would leave that undone forever. if (!existsSync(params.tree)) return { ok: true }; const who = serviceUser(); const chowned = await sudo(['chown', '-h', '-R', `${who}:${who}`, params.tree]); if (!chowned.ok) return { ok: false, error: `could not reassign ${params.tree} to ${who}: ${chowned.out}` }; // ── Ownership is not the only link to the uid ── // // `confineUserTree` grants the member a NAMED ACL entry on their whole tree — `u::rwx` plus a // `default:` copy inherited by everything either party creates afterwards. `chown` does not remove those: // they are xattrs rather than ownership, and they record the uid NUMERICALLY. // // Measured on this host: after `chown -h -R` to the service user, `user::rwx` was still present on the // directory, on its children, and in their defaults. So the tree reads as the platform's while still // granting the freed uid read and write on every byte of it — and the next account allocated that number // inherits the previous member's home, keys, credential and container storage. Severing ownership without // severing this just moves the hazard somewhere ownership checks cannot see it, including `find -uid`. // // `-b` removes every ACL rather than the member's entries alone. The service user owns all of it now, so a // named entry granting themselves what ownership already grants is redundant, and "no ACLs" is a much // cheaper thing to verify than "no ACL naming one particular id". // // `-P` is the physical walk. It is already the default for `setfacl -R` — verified here, a symlink out of // the tree was not followed — but it is stated for the same reason `chown` above carries `-h`: a member // chooses what their symlinks point at, and this argv should not depend on a traversal default holding. const stripped = await sudo(['setfacl', '-R', '-P', '-b', params.tree]); if (!stripped.ok) { return { ok: false, error: `reassigned ${params.tree} but could not clear its ACLs: ${stripped.out}` }; } if (params.policy === 'preserve') return { ok: true }; try { await rm(params.tree, { recursive: true, force: true }); } catch (ex) { return { ok: false, error: `reassigned ${params.tree} but could not delete it: ${ex instanceof Error ? ex.message : String(ex)}`, }; } return { ok: true }; }