Files
platform/src/servers/os-user-deprovision.test.ts
T
pastilhasandClaude Opus 5 46799dada8 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>
2026-08-12 04:19:50 +00:00

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();
});
});