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>
severMemberTree reassigned the tree and left the access-control entries behind. confineUserTree
grants each member a named ACL on their whole tree — u:<uid>:rwx plus a default: copy — and
chown does not remove them: they are xattrs rather than ownership, and they record the uid
numerically.
Measured before this change: after chown -h -R to the service user, user:<uid>:rwx was still
present on the directory, on its children and in their defaults. The tree read as the
platform's while still granting the freed uid read and write on every byte, so the next account
allocated that number would inherit the previous member's home, keys, credential and container
storage — the hazard this file exists to prevent, reached through a door that find -uid cannot
see.
Now chown then setfacl -R -P -b. Proven on a scratch tree: owner 1001 with five entries naming
1001 becomes owner 1000 with none.
-b rather than removing the member's entries alone, because the service user owns all of it
afterwards and "no ACLs" is cheaper to verify than "no ACL naming one id". -P is already the
default for a recursive setfacl — verified, a symlink out of the tree was not followed — and is
stated for the same reason the chown above carries -h: a member chooses what their symlinks
point at, and this argv should not rest on a traversal default holding.
Found by running assert-uid-free.sh against a real tree; the spec and the checker had the same
blind spot and were corrected in a2f63dc5.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>