Skip to content

Replace account deletion with expiry and purge expired accounts after a retention period - #25

Merged
jonasehrlich merged 12 commits into
mainfrom
je/expire-over-delete
Aug 14, 2026
Merged

jonasehrlich merged 12 commits into
mainfrom
je/expire-over-delete

Conversation

@jonasehrlich

@jonasehrlich jonasehrlich commented Aug 14, 2026 •

Copy link
Copy Markdown
Owner

Closes #24. Closes #13.

A departed member's account is no longer archived and deleted. One ExpireAccount message expires it with usermod --expiredate (blocking password and pubkey SSH logins), ends the running sessions through logind and strips the supplementary groups. The account and home directory stay on the machine as the durable, reversible departure record that the existing rejoin path heals. The message is idempotent and re-sent for every departed tombstone on each sync, so an interrupted expiry converges without retry state.

Accounts expired for at least the retention period (default 180 days, --purge-after-days) are purged at the end of each sync and through the new purge command: force-kill sweep, then userdel --remove, no archive. Eligibility requires the tombstone age (store-side clock) and the account's own shadow expiry (account-side clock, cleared by any reactivation) to agree, and the member to be absent from the fetched member list. The tombstone moves to the purged map with a purged_at timestamp, so UID and GID stay reserved even for a post-purge rejoin.

Notes for review:

  • The store separates users, departed and purged. The version stays 2: the archived-key draft schema was never deployed, so it is redefined in place without compatibility.
  • Departure also removes the member's scheduled work (crontab and queued at jobs), since cron and at run without a login and would otherwise keep executing code under the departed account. A machine without cron or at installed is not a failure.
  • Session teardown talks to logind over D-Bus with the already-present zbus dependency (SetUserLinger(false), then TerminateUser gated on GetUser) instead of shelling out to loginctl. Failures are logged, and the SIGTERM/SIGKILL sweep stays the catch-all and the sole mechanism without logind. The two cases are reported differently on purpose: no system bus means no logind on the machine (debug, expected in a container), while a logind that answers and then refuses is an error, because session scopes and a lingering user manager then survive the sweep.
  • The name-recycling guard refuses creating a user whose login a departed tombstone holds under a different GitHub ID. Same-ID tombstones stay the rejoin path, tombstone-less pre-existing accounts stay adoptable by decision.
  • The dry-run store fix from Harden the remaining account mutations against stale store data #13 is included because the purge preview depends on it: the store carries the dry-run mode and save() never writes in a preview.
  • src/archiver.rs and the astral-tokio-tar/async-compression dependencies are removed. The delete command now expires all managed users.
  • The shared account resolver from Harden the remaining account mutations against stale store data #13 is included: every mutating handler (UpdateUser, ExpireAccount, PurgeAccount, SyncSupplementaryGroups) routes through one name and UID cross-check, so a stale UID can no longer strip the supplementary groups of an unrelated account. UpdateUser accepts the GitHub login as the alternate name, keeping the lost-rename recovery.
  • No purge cap on the circuit breaker, per the ticket's lean that the six-month gate makes it redundant, and the breaker's 100% threshold stays by decision.

Verified with cargo test (94 tests), clippy with denied warnings, and a cargo zigbuild --target aarch64-unknown-linux-gnu cross-check with RUSTFLAGS="-D warnings".

…schema

The departed map replaces the archived key of the unreleased v2 draft
schema, renaming the tombstone timestamp to departed_at and dropping
the home_archive field. A new purged map records tombstones whose
expired account was removed after the retention period, so even a
member rejoining after a purge gets their old UID and GID back. Fresh
ID allocation now also avoids the IDs reserved by purged tombstones.
The draft schema was never deployed, so version 2 is redefined in
place and no compatibility with it is kept.
…n period

A departed member's account is no longer archived and removed. One
ExpireAccount message expires it with usermod --expiredate, ends the
running sessions through logind (TerminateUser and disabled linger over
D-Bus, with the SIGTERM/SIGKILL sweep as the catch-all) and strips the
supplementary groups. The account and home directory stay on the
machine as the durable, reversible departure record that the existing
rejoin path heals. The message is idempotent and re-sent for every
departed tombstone on each sync, so an interrupted expiry converges.

Accounts expired for at least the retention period (default 180 days,
--purge-after-days) are purged at the end of each sync and through the
new explicit purge command: a force-kill sweep, then userdel --remove,
no archive. Eligibility requires the tombstone age and the account's
own shadow expiry to agree, and the member to be absent from the
fetched member list. The tombstone moves to the purged map with a
purged_at timestamp, so the IDs stay reserved even after the purge.

The archiver and its tar dependencies are removed. The delete command
now expires all managed users instead of deleting them.
With expiry, departed accounts stay on the machine as adoptable by
name. A leaver's GitHub login can be released and claimed by a
different person, and adopting the expired account would hand the new
member the previous owner's home directory and UID. A tombstone whose
GitHub ID matches the joining member stays the rejoin path, and a
pre-existing account without a tombstone stays adoptable by decision.
--dry-run only swapped in the mock backend, while sync and delete still
saved the rebuilt store into the real data directory: new members were
persisted with invented mock UIDs and leavers were tombstoned for real,
so a later real run acted on preview data. The store itself now carries
the dry-run mode and its save method skips the write, so no caller can
persist preview data. This also makes the purge preview side-effect
free.
…og entries

The home directory archive section is replaced by the departure
lifecycle. Unreleased changelog entries describing removed behavior
(home archiving, the second sweep before userdel, the deletion-window
expiry) are pruned or folded into the new entries, since none of it
was ever released.
…oss-check

SyncSupplementaryGroups resolved the account by the stored UID alone
and replaced that account's supplementary groups. Since octosync owns
the supplementary groups of synced users, a stale UID stripped e.g.
sudo from an unrelated account that reuses it. The resolver returns
missing or matches and treats a store that disagrees with the system
as an error, and UpdateUser, ExpireAccount, PurgeAccount and the group
sync all route through it. UpdateUser passes the GitHub login as the
accepted alternate name, so a rename whose store update was lost is
still picked up instead of refused.
…ions

cron and at run jobs without a login, so ending the sessions left the
departed account still executing code. Departure now deletes the user's
crontab and their queued at jobs. Expiring the account already blocks
the PAM account check both perform, so this is the second half of that
guarantee and it also covers a machine whose cron does not consult PAM.

The removal runs before the process sweep, so no job can spawn
processes into the teardown. A machine without cron or at installed has
no scheduled work to remove rather than a failing departure.
@jonasehrlich
jonasehrlich force-pushed the je/expire-over-delete branch from 0b47f04 to 0f27097 Compare August 14, 2026 15:43
The sync re-sent ExpireAccount for every departed tombstone on every run,
so the full teardown -- a /proc walk, a system bus connection with its
round trips and the crontab and atq spawns -- was paid for every tombstone
on every sync, for the whole 180 day retention period. The cost grew with
the number of departures the machine had ever seen.

A departed tombstone now carries the timestamp of the teardown that
completed, and only a tombstone without it is torn down. A teardown that
failed or was interrupted leaves the timestamp empty and is retried by the
next sync, so convergence stays store-driven instead of relying on
re-sending the message blindly.

The account of a departed member is no longer reconciled continuously as a
consequence: an expiry lifted by hand stays lifted until the member is
synced again.
…epartures

Two ways the departure teardown could stop making progress.

zbus applies no reply timeout of its own and the bus does not enforce one
on the method calls it forwards, so a logind that accepts the call and
never answers left the teardown waiting forever. It runs inside the user
manager actor, so that stalled every following platform operation while the
process kept holding the lockfile, and the sync stopped without failing.
The logind conversation now has a deadline and falls back to the process
sweep, which the design already treats as the catch-all.

The teardown steps also ran as one fallible sequence, so a process stuck in
uninterruptible I/O or an unhealthy at queue skipped the steps behind it.
The supplementary groups of a departed member could stay in place for the
whole retention period because of a stuck process, failing the same way on
every sync. Only the expiry is a precondition of the rest and stays fatal;
the remaining steps now all run before the first error is returned, so the
retry still converges.
@jonasehrlich
jonasehrlich merged commit 103ca1e into main Aug 14, 2026
10 checks passed
@jonasehrlich
jonasehrlich deleted the je/expire-over-delete branch August 14, 2026 19:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Replace account deletion with expiry and purge expired accounts after six months Harden the remaining account mutations against stale store data

1 participant