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:
2026-08-12 04:19:50 +00:00
co-authored by Claude Opus 5
parent f34d7fef70
commit 46799dada8
5 changed files with 564 additions and 12 deletions
+52 -5
View File
@@ -1,11 +1,12 @@
# Deprovisioning a member's Linux account
**Status:** specification. Not implemented. Written from a manual teardown performed on the production host on
2026-08-11, so the ordering constraints below are measured rather than reasoned.
**Status:** implemented 2026-08-12 in `src/servers/os-user-deprovision.ts`, called by `deleteUserHandler`.
**Not yet run against a real account** — see "What is still unproven" at the bottom before trusting it.
Written from a manual teardown performed on the production host on 2026-08-11, so the ordering constraints
below are measured rather than reasoned.
**Trigger for implementing:** before the first account that does not belong to the server owner. Not "after
per-user Claude" — the risk opens when a real person has an account that might later be deleted, which may or
may not be the same moment.
The spec is kept as written rather than rewritten in the past tense: it is the reasoning the implementation
has to keep satisfying, and the failure modes it names are still the ones a change would reintroduce.
---
@@ -228,3 +229,49 @@ subuid ranges, and the final verified-clean state (no accounts ≥ 1000 but the
The one thing not measured is the preserve path. The teardown used `rm -rf`, because the data was a disposable
test database. `chown -R` as a severing mechanism is reasoned, not observed.
---
## What the implementation decided, where the spec left it open
- **Q1, where severed data goes:** in place. A `deleted/` location is a second thing that can fail between
severing and releasing, and the ordering rule already says nothing may come between them.
- **Q2, destroy in the UI:** no. `policy: 'destroy'` exists and has no call site; `deleteUserHandler` always
preserves. It is also implemented as *chown, then delete as the service user* rather than `sudo rm -rf`, so
a recursive delete as root built from a database column does not exist in the codebase at all.
- **Q3, monotonic uid allocation:** not done. Severing addresses the same hazard and also fixes files orphaned
by any other route; the two are not exclusive and this one is still available later.
- **Q4, a preserved member's Docker images:** unchanged — they stay on disk, owned by the service user,
readable by nobody who wants them. Correct and wasteful, as the spec predicted.
Two guards were added that the spec did not ask for, both exported and unit-tested:
- `guardDeletable` — the adoption rule from `ensureOsUser` read backwards. An account is only deletable if its
passwd home is the home the platform would have confined, and its uid is ≥ 1000. Without it, `userdel root`
is one bad `users.osUser` value away, and nothing else in the sequence would object.
- `guardMemberTree` — the member 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 on this host that `chown -R` already declines to follow a symlink out of the
tree and re-owns the link itself, but the flag states it in the argv rather than resting on traversal
semantics — and re-owning links is what makes `find -uid` (which uses `lstat`) a meaningful check.
## What is still unproven
The whole thing has been exercised only by unit tests over the pure guards. Nothing has run against a live
account, and this dev machine is deliberately not the place to try it.
To validate on the production host, against a throwaway account:
1. Create a member, open a terminal as them, and **leave a shell running** — that is the case
`terminate-user` does not handle, and the reap loop is the part most likely to be wrong.
2. Give them a container that writes as a non-root user, so the subuid range is genuinely populated:
`postgres:18-alpine` drops to uid 70 and its data directory lands on `subuid_start + 70`.
3. `./scripts/assert-uid-free.sh --capture <user>`**before** deleting, or the range is gone.
4. Confirm the range check *fails* on that tree while the account still exists. A checker that has never
failed has not been tested.
5. Delete through the UI, then
`sudo ./scripts/assert-uid-free.sh --check <user> <uid> <start> <count>`.
The delete handler logs that exact command line with the captured values after a successful deprovision,
because after `userdel` nothing else on the machine remembers the range.