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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user