OFFICER_OS_USERS is gone. The platform behaves as it always would have with the flag on, and there is nothing to enable. Six conditionals, five of which were dead weight — provisionOsAccount, deprovisionOsAccount and the create/delete paths each opened with an early "not enabled on this server" return, and the API told the frontend whether to render the Linux controls at all. Those go, along with the 'disabled' DeprovisionResult stage, which nothing can produce now. The sixth is the one with teeth. assertSecretsClosed opened with `if (!OS_USERS_ENABLED) return`, described in its own comment as "a no-op when the feature is off, so an existing install is unaffected until the owner opts in". It is now unconditional: the server refuses to boot while any .env in the project root is group- or world-readable. A member's shell reading .env and printing JWT_SECRET was confirmed exploitable when this check was written, and a prerequisite that only holds when somebody remembers to set a variable is not a prerequisite. Nothing to remove on the environment side — the flag was never in .env.example or in the setup script. Not typechecked: node_modules is empty in this tree and installs are frozen, so tsgo could not run. All six files parse under `bun build --no-bundle`, and the changes are deletions of dead branches plus one removed early return. Formatted with prettier 3.9.6 via bunx rather than the pinned resolution, for the same reason; its one unrelated reformat was reverted by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
375 lines
18 KiB
TypeScript
375 lines
18 KiB
TypeScript
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<number> {
|
|
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<DeprovisionResult> {
|
|
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/<uid> 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/<uid> 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:<uid>: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:<uid>: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 };
|
|
}
|