Skip to content

UMC v2: transactional engine, bulk HR imports, lifecycle, audit, evidence - #1

Merged
syed-913 merged 20 commits into
mainfrom
v2
Sep 30, 2026
Merged

syed-913 merged 20 commits into
mainfrom
v2

Conversation

@syed-913

Copy link
Copy Markdown
Owner

What this is

A rewrite of umc.sh that keeps v1's goals: one file, no useradd/usermod/passwd, and the same console look. It makes those goals hold under failure, concurrency and scale. v1 stays available as tag v1.0.

Highlights

  • Transactions: every change is journaled with pre/post images. Commits use shadow-utils' own protocol (same-directory temp file, fsync, rename, lock interop with useradd/PAM). Rollback and crash recovery are included.
  • Bulk onboarding from arbitrary HR CSV/JSON: encoding and delimiter sniffing, column aliases, all-or-nothing, idempotent. Temporary passwords must be changed within 24 h, or the account is locked.
  • Lifecycle: create, offboard (soft), reinstate, delete (explicit).
  • Audit: CIS-mapped audit, a tamper-evident log and structured journald records.
  • Fixes all 33 findings of the v1 review: see CHANGELOG.md and docs/SECURITY-ADVISORY-v1.md.

Evidence

  • 129 tests on 9 container distributions (CI matrix).
  • evidence/E-01 … E-15: reproducible PoCs covering crash consistency, disk-full, concurrency, rollback, a fair performance benchmark, messy HR files and more.
  • evidence/E-16: six real VMs (Rocky 9 and Alma 9 with SELinux enforcing, Debian 12/13, Ubuntu 22.04/24.04) pass 133 tests each, including SSH, SELinux, journald and systemd end-to-end tests.

Review notes

  • umc.sh is ~6,100 lines, shellcheck clean at every severity.
  • tests/vagrant/verify.sh boots each pinned box, tests it, and removes everything it downloaded.

syed-913 and others added 20 commits September 30, 2026 15:58
A public repository without a license is "all rights reserved": nobody may
legally reuse the code. MIT keeps it permissive and, importantly for a tool
that edits /etc/shadow, states that it comes without warranty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v1 edited /etc/passwd & co. through predictable files in /tmp and sed on
whole lines. A review found that this could hand /etc/shadow to any local
user, made /etc/passwd unreadable after the first change, corrupted group
memberships (removing "bob" also mangled "bobby"), and could not roll back.

v2 keeps v1's goals - one file, no useradd/usermod/passwd, the same console
look - and rebuilds the core:

* locking compatible with shadow-utils (FILE.lock hard-link protocol) and
  lckpwdf (flock --fcntl where available), taken before anything is read
* the commonio.c commit protocol: temp file in the target directory,
  owner/mode/SELinux label copied, fsync, FILE- backup, rename(), dir fsync
* validation of every line, cross-file consistency, and a blast-radius check
  that refuses any change the operation did not declare
* a journal with pre/post images: dry-run diffs, rollback, crash recovery
* entry-level editing in memory (exact-token group membership, no sed)
* ID allocation from login.defs ranges, free as UID *and* GID, NSS-checked
* passwords: pwquality policy, secrets only via pipes, batch/parallel hashing
* lock = '!' plus account expiry (so SSH keys are refused too); unlock
  never produces an empty password field
* writes inside home directories run as the user (setpriv), not as root
* joiner/mover/leaver lifecycle: offboard (reversible) before delete
* schema-flexible bulk import (CSV any delimiter/encoding, nested JSON) with
  inspect -> plan -> apply, identity matching and all-or-nothing semantics
* temporary passwords with an activation deadline enforced by a sweep and a
  shadow-expiry backstop; credential slips that self-destruct
* CIS-mapped audit, access-review export, password policy management
* hash-chained audit log plus structured journald fields
* a CLI with documented exit codes; the console runs the same commands

shellcheck: 0 findings at every severity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tests/run-in-docker.sh runs the suite in throw-away containers for nine
distributions (Debian 12/13, Ubuntu 22.04/24.04, Rocky 9, Alma 9,
Fedora 42, RHEL UBI 8/9) with the repository mounted read-only, so nothing
on the developer's machine is ever modified and no hypervisor is needed.

The import fixtures are deliberately awkward: a semicolon-separated Excel
export with a BOM, CRLF line endings, accented names and day-first dates;
a nested JSON document as an HR API would return it; and a native manifest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…' writes

The live concurrency test (UMC next to useradd and chpasswd) exposed a real
race on Debian 12: chpasswd changes passwords through pam_unix, which only
honours /etc/.pwd.lock (an fcntl lock), and util-linux 2.38 has no
'flock --fcntl'. pam_unix rewrote /etc/shadow during UMC's commit; UMC's
post-commit check caught it, but its automatic rollback would then have
reverted chpasswd's change.

* lk_pwd now falls back to a tiny python3 or perl helper that holds the same
  fcntl lock for UMC's lifetime (signals ignored; released if UMC dies)
* txn_verify rolls back only when every file is still byte-identical to what
  UMC wrote; otherwise it checks that UMC's own entries survived and reports
  instead of destroying the other program's change
* a bug found while writing tests: renaming a user declared only one of its
  two group entries, and the blast-radius check refused the commit - the check
  doing its job; the declaration is fixed
* IDs for new users continue after the highest UID (skipping numbers taken as
  a GID), closer to useradd
* configuration is resolved once in main (the sweep did not clean credential
  slips because it never resolved credentials_dir)
* sandbox-only fault injection (UMC_FAULT) to prove crash recovery in tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* unit: validators, JSON/CSV readers, date and status parsing,
  transliteration, password generation and policy, CSV formula escaping
* integration (--root sandboxes built from Debian- and RHEL-style fixtures):
  commit protocol, blast radius, invariants, user lifecycle, groups, sudo,
  bulk import, rollback, crash recovery via SIGKILL, audit-log tampering,
  onboarding deadlines with a simulated clock, audit, export, policy
* live (the container's own /etc): NSS verification, shadow-utils honouring
  UMC's lock, UMC + useradd + chpasswd in parallel without lost updates

Every v1 finding has a named regression test (F-01 ... F-30). tests/run.sh
refuses to run outside a container or test VM because the live tests
rewrite /etc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The disk-full evidence run (every amount of free space from 0 to 120 KB)
showed two weaknesses, although no run ever left the files inconsistent:

* a staging write cut short by a full disk was only caught later, by the
  blast-radius check, whose message then blamed "a bug in UMC"; every write
  into a staging file is now checked and reported as "disk full?"
* when the disk filled up during the renames, restoring the old files also
  needed space, so only 'umc recover' could finish the job. Each original is
  now hard-linked to its backup name before the rename (FILE- for the four
  databases, a hidden .NAME.umc-pre otherwise); undo is a plain rename()

Also: journal entries of transactions killed before their first rename are
marked "aborted" instead of lingering as "prepared", and the minimum
util-linux version for 'flock --fcntl' is corrected to 2.41 (2.39 and 2.40
do not have it; found by the Fedora 42 matrix run).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* tests/e2e (UMC_E2E=1, VMs only): SELinux labels stay correct on an
  enforcing host; an SSH key logs in, a UMC lock refuses it while a '!'-only
  lock would not; journald receives structured records; the sweep timer
  installs
* tests/vagrant/smoke.sh PROVIDER boots each pinned box, runs a ten-second
  smoke test inside it and prints which boxes work for that hypervisor
* the Debian/Ubuntu test images carry an en_US.UTF-8 locale so the evidence
  scripts can run v1: v1's password rule is an invalid regular expression in
  the C locale (new finding F-33)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…llels

Current bento boxes no longer ship libvirt builds and the generic/* boxes
are frozen since 2024, so each VM now names a pinned box per provider via
Vagrant's override.vm.box mechanism. The repository is rsync'ed to /opt/umc
(no NFS server needed; UMC_SYNC=nfs for live editing), which retires the
shared/umc.sh copy. 'vagrant up' without a name starts nothing, so seven VMs
are never booted by accident. RHEL registration reads credentials from the
environment only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every push runs shellcheck (zero findings required for umc.sh), the test
suite in disposable containers for nine distributions, and regenerates the
evidence reports, failing if any claim no longer holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* README: what UMC is for, highlights linked to reproducible evidence,
  quick start, CLI reference with exit codes, bulk onboarding, safety model,
  compatibility, comparison with alternatives, honest limitations
* docs/DESIGN.md: architecture, commit protocol, locking, validation,
  journal and recovery, error-handling contract, threat model, trade-offs,
  and the bugs the safety nets caught during development
* docs/SECURITY-ADVISORY-v1.md: v1's security findings (impact, cause, fix,
  regression test), without exploitation instructions
* examples/: umc.conf, roles, access rules, an HR export and a manifest -
  verified end to end in a sandbox

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e benchmark

The first benchmark showed UMC slower than a useradd loop. Profiling, not
guessing, found the cause: one small state file per user (onboarding
deadline, lock and offboarding records) went through the full commit
protocol, about ten processes each.

* UMC's own state is now one table per kind (onboarding.tsv, locks.tsv,
  offboarded.tsv, managed.tsv), kept in memory and written once per
  transaction
* NSS collision checks and post-commit verification use one getent call per
  batch instead of two per user
* home directories of bulk imports are created in parallel (one worker per
  core, max 8); every failure is still reported by name

The parallel path then deadlocked in live mode: a bare 'wait' also waited
for the lckpwdf helper coprocess, which by design lives as long as UMC. It
now waits for its worker PIDs only; a live test covers it.

Result (evidence E-11, 1,000 users with hashed passwords and homes, same
container): umc apply 5.2 s, useradd loop + chpasswd 21.5 s, newusers 16.7 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The disk-full evidence run (E-06, now 0-200 KB free) found a real bug: with
136 KB free, the commit completed but recording "state=committed" in the
journal failed, unchecked. UMC reported success, and the next run's crash
recovery saw "committing" and rolled the finished transaction back. The
"committing" transition had the same flaw in the other direction.

Every journal state (committing, committed, rolled-back) is now written at
journal time, while space is guaranteed, and later only renamed into place.
Each transition is checked; UMC may fail, but never reports a success the
journal disagrees with.

Tests: findings are now traceable - every v1 finding that still applies has
a test named after it (F-17, F-29, F-31, F-32, F-16, F-23, F-03 renamed),
plus a new F-33 test (the password policy in the C locale). 126 tests pass
on Debian 12, Rocky 9 and UBI 8 (bash 4.4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
poc/run.sh runs each scenario in a throw-away container and writes a report
(claim, method, environment, one-line reproduce command, raw output,
verdict) to evidence/. CI regenerates them on every push.

* E-01 no account data staged in /tmp (v2's defence against F-01)
* E-02..E-04 v1 vs v2: file permissions, exact group membership, ID allocation
* E-05 400 random SIGKILLs: 0 inconsistent states
* E-06 disk full at every point from 0 to 200 KB free: 0 inconsistent outcomes
* E-07 UMC + useradd + chpasswd in parallel: 0 lost updates
* E-08 idempotency · E-09 byte-exact rollback · E-10 blast-radius check
* E-11 1,000 users: umc apply vs useradd loop vs newusers
* E-12 audit · E-13 tamper-evident log · E-14 messy HR files
* E-15 onboarding deadline with a simulated clock

The evidence runs themselves found four real bugs, fixed in earlier commits
(see docs/DESIGN.md section 14). v1's security bugs are documented in
docs/SECURITY-ADVISORY-v1.md rather than shipped as runnable exploits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* yescrypt hashes are now split across CPU cores like SHA-512 (mkpasswd
  hashes one password per process, so each chunk is a loop); a test checks
  with crypt(3) that every password stays paired with its own hash
* 'umc doctor', 'umc policy show' and a new audit check (AUD-24, low) report
  when login.defs' ENCRYPT_METHOD and PAM's pam_unix option differ - Debian 12
  ships SHA512 vs yescrypt - and explain that UMC follows login.defs unless
  hash_method is set in umc.conf (now accepted in any letter case)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* README: "Who needs this" (break-glass accounts, air-gapped/regulated
  networks, seasonal bulk onboarding, organisations without an IdP, leavers
  and incidents, golden images, audits) and "What is actually new here" - an
  honest novelty statement: the techniques are known; the combination for
  Linux's flat-file account databases, on hosts where the usual tools cannot
  run, is what the reviewed tools lack
* E-11 now compares like with like: the same hashing algorithm on both sides,
  every run on all cores and pinned to one core, plus a single-user row. The
  first version compared UMC's SHA-512 with newusers' yescrypt, which
  overstated the gap. Measured: ~4.5x faster than a useradd loop on 12 cores,
  2.4x on one core; newusers is faster with yescrypt on one core; useradd is
  17x faster for a single user - all stated in the README
* DESIGN.md section 15 explains where the time goes (batching, not language)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
All 15 reports pass; the 9-distribution matrix passes. README and DESIGN
quote the published E-11 numbers exactly (generated from the report, not
typed by hand).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fter itself

tests/vagrant/verify.sh replaces smoke.sh. For each pinned box it boots the
VM, runs the whole suite including the VM-only end-to-end tests (SELinux
labels, SSH key lock, journald, systemd timer), destroys the VM and removes
what the run downloaded: new boxes, their libvirt image volumes and new
libvirt networks. Pre-existing boxes, volumes and networks are snapshotted
first and never removed; network definitions are saved and restored if
anything deletes them. vagrant-libvirt 0.12 deletes a management network it
created itself on every destroy (management_network_keep only protects one
that already existed), so --keep-network re-defines it, stopped. Exits 1
when any box fails. Writes E-16.

Fixes found on the first VM runs:
- Vagrantfile: debian13 uses debian/trixie64 (crystax/debian13 never boots);
  ubuntu2204 on libvirt uses generic/ubuntu2204 - bento/ubuntu-22.04
  202502.21.0 is UEFI-only (GPT, empty MBR boot code) and hangs at "Booting
  from Hard Disk" under the default BIOS firmware; force the KVM driver
  (that bento box also sets driver "qemu", pure emulation);
  management_network_keep so a pre-existing vagrant-libvirt network survives.
- e2e: offboard before delete - a real SSH login leaves the systemd user
  manager running for a few seconds and delete rightly refuses.
- unit: sniff/translit tests run under UMC's LC_ALL=C like the real code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A transient DNS failure on the host was reported as "the box did not
boot". Download errors (catalogue unreachable, host not resolved) now get
one more try after 60 s, and are reported as download failures; a box that
downloads but does not boot is still not retried.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One run of tests/vagrant/verify.sh libvirt at d570975: Rocky 9 and Alma 9
(SELinux enforcing), Debian 12 and 13, Ubuntu 22.04 and 24.04 - 133 tests
each, 0 failed. RHEL 9 needs a subscription and was not run. README
compatibility table, limitations and evidence index updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"name: shellcheck (umc.sh: zero findings ...)" contains ": ", which YAML
reads as a nested mapping, so GitHub rejected the whole file and no job
ever started. Found on the first push of v2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@syed-913
syed-913 merged commit fbf156d into main Sep 30, 2026
22 checks passed
@syed-913
syed-913 deleted the v2 branch October 1, 2026 10:18
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.

1 participant