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>
103 lines
5.0 KiB
TypeScript
103 lines
5.0 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|
import { guardDeletable, guardMemberTree, parseSubUidEntry } from './os-user-deprovision';
|
|
|
|
// The guards that stand between a database column and `sudo userdel` / `sudo chown -R`.
|
|
//
|
|
// Everything else in os-user-deprovision.ts needs a machine with accounts to delete on. These three are pure
|
|
// precisely so the dangerous decisions can be tested without one — they are the part where being wrong is not
|
|
// recoverable, and the manual teardown that produced the spec is not a thing anyone should repeat to check a
|
|
// refactor.
|
|
|
|
describe('guardDeletable', () => {
|
|
const home = '/data/member@example.com/home';
|
|
|
|
test('accepts an account the platform made', () => {
|
|
expect(guardDeletable({ osUser: 'green', uid: 1002, passwdHome: home, expectedHome: home })).toEqual({ ok: true });
|
|
});
|
|
|
|
test('refuses root, and every other system account', () => {
|
|
// The scenario: a `users` row whose osUser column says 'root'. Nothing else in the sequence would stop it
|
|
// — `loginctl`, `pkill` and `userdel` would all simply do as they were told.
|
|
const result = guardDeletable({ osUser: 'root', uid: 0, passwdHome: '/root', expectedHome: home });
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error).toContain('system account');
|
|
|
|
expect(guardDeletable({ osUser: 'daemon', uid: 1, passwdHome: home, expectedHome: home }).ok).toBe(false);
|
|
expect(guardDeletable({ osUser: 'nobody', uid: 999, passwdHome: home, expectedHome: home }).ok).toBe(false);
|
|
});
|
|
|
|
test('refuses a same-named account that is not ours', () => {
|
|
// A human account that happens to share a member's username. Its home is its own, so it fails the only
|
|
// test that proves ownership. This is `ensureOsUser`'s adoption rule read backwards.
|
|
const result = guardDeletable({ osUser: 'green', uid: 1002, passwdHome: '/home/green', expectedHome: home });
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error).toContain('not an account the platform created');
|
|
});
|
|
|
|
test('refuses an account with no passwd home at all', () => {
|
|
expect(guardDeletable({ osUser: 'green', uid: 1002, passwdHome: null, expectedHome: home }).ok).toBe(false);
|
|
});
|
|
|
|
test('the uid floor is not sufficient on its own', () => {
|
|
// Both halves are required: a high uid with a foreign home is still somebody else's account.
|
|
expect(guardDeletable({ osUser: 'green', uid: 4000, passwdHome: '/srv/green', expectedHome: home }).ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('guardMemberTree', () => {
|
|
test('resolves a normal account directory', () => {
|
|
const result = guardMemberTree('/data', 'member@example.com');
|
|
expect(result).toEqual({ ok: true, tree: '/data/member@example.com' });
|
|
});
|
|
|
|
test('refuses traversal out of DATA_PATH', () => {
|
|
// The path this produces is the argument to `chown -R` and, under destroy, to a recursive delete. The
|
|
// email reaches join() from a database row.
|
|
expect(guardMemberTree('/data', '../../etc').ok).toBe(false);
|
|
expect(guardMemberTree('/data', '..').ok).toBe(false);
|
|
expect(guardMemberTree('/data', 'a/../../b').ok).toBe(false);
|
|
});
|
|
|
|
test('refuses a nested path even without traversal', () => {
|
|
// A member tree is one level down. Anything deeper is not an account directory, whatever it is.
|
|
expect(guardMemberTree('/data', 'a/b').ok).toBe(false);
|
|
});
|
|
|
|
test('refuses DATA_PATH itself', () => {
|
|
expect(guardMemberTree('/data', '').ok).toBe(false);
|
|
expect(guardMemberTree('/data', '.').ok).toBe(false);
|
|
});
|
|
|
|
test('refuses an empty DATA_PATH', () => {
|
|
// Unset DATA_PATH would otherwise make every member tree a path under the process's cwd.
|
|
expect(guardMemberTree('', 'member@example.com').ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('parseSubUidEntry', () => {
|
|
const file = 'pastilhas:100000:65536\ngreen:231072:65536\nofficer_jg:165536:65536\n';
|
|
|
|
test('reads the range for the named account only', () => {
|
|
expect(parseSubUidEntry(file, 'green')).toEqual({ start: 231072, count: 65536 });
|
|
expect(parseSubUidEntry(file, 'officer_jg')).toEqual({ start: 165536, count: 65536 });
|
|
});
|
|
|
|
test('a missing entry is null, not a guess', () => {
|
|
// Normal: rootless Docker is tolerated when it fails, and accounts predating it have no entry. A guessed
|
|
// range would make the audit check a different range than the one that was actually freed.
|
|
expect(parseSubUidEntry(file, 'nobody')).toBeNull();
|
|
expect(parseSubUidEntry('', 'green')).toBeNull();
|
|
});
|
|
|
|
test('does not match on a prefix', () => {
|
|
// 'green' must not match the 'green2' line — the ranges are different and the wrong one verifies nothing.
|
|
expect(parseSubUidEntry('green2:300000:65536\n', 'green')).toBeNull();
|
|
});
|
|
|
|
test('rejects a malformed line rather than producing NaN', () => {
|
|
// NaN would flow into `assert-uid-free.sh --check` as an argument and quietly scan nothing.
|
|
expect(parseSubUidEntry('green:notanumber:65536\n', 'green')).toBeNull();
|
|
expect(parseSubUidEntry('green:231072:0\n', 'green')).toBeNull();
|
|
});
|
|
});
|