Gunnar

nordisk/Skidbladnir

.nornir/service-user-design.md

<!-- nornir:class: reasoning -->
# Service-user creation → skidbladnir's pure-Rust `useradd` replacement (exposed)

**dup-audit P1 (finding #4).** nornir shelled `Command::new("useradd") --system …`
(`nornir/src/bin/nornir.rs:7481`) to mint its dedicated service user. The
constellation law is: service/user management lives in **skidbladnir** (the
service layer), and there is NO shell-out (zero-shell charter). skidbladnir
already had the 100%-Rust replacement — it just wasn't reachable from a consumer.

## What was already there (private)
`systemd.rs` grows the dedicated service user as part of `install_systemd`: it
allocates the lowest free system id in `[100, 1000)` and appends matching
`/etc/group` + `/etc/passwd` + a locked `/etc/shadow` row — no `useradd`
subprocess. That logic lived in a private `ensure_system_user` /
`create_system_user_in`, callable only from inside the crate.

## What this change does — expose the seam
Three symbols in `skidbladnir::systemd` are now **public** so nornir (and holger,
dwarfs) consume them instead of hand-rolling `useradd` + `chown`:

| symbol | shape | role |
|---|---|---|
| `ensure_system_user(user, home)` | `-> Result<(u32, u32)>` | the `useradd --system --shell /usr/sbin/nologin` replacement: idempotent, creates `home`, chowns it, returns the `(uid, gid)` |
| `create_system_user_in(passwd, group, shadow, user, home)` | `-> Result<(u32, u32)>` | the file-targeted core (drive an alternate root / test fixture; unit-tested against temp files) |
| `chown_recursive(path, uid, gid)` | `-> Result<()>` | libc `chown` tree-walk so a consumer can re-own extra state dirs to the fresh user (kills the `chown` twin too) |

`ensure_system_user` now **returns `(uid, gid)`** (was `()`) so a caller can reuse
the ids for additional chowns without re-parsing `/etc/passwd`. A `#[cfg(not(unix))]`
stub mirrors `install_systemd` (clear linux-only error).

## Consumer migration (the twin-kill)
nornir's `ensure_system_user` (the `useradd` shell-out), its `passwd_ids`
re-parse and its `chown_recursive` twin collapse to:

```rust
let (uid, gid) = skidbladnir::systemd::ensure_system_user(user, NORNIR_HOME)?;
```

That deletes the `Command::new("useradd")` deploy-subprocess entirely. (Landed
separately in the nornir repo; skidbladnir's job is to expose the seam — done
here.)

## Tests
`tests/public_api.rs` pins the new public surface from a consumer's view
(no live `/etc`, no root): `create_system_user_in` against a temp fixture root
proves the id-allocation + idempotence contract, and `ensure_system_user` /
`chown_recursive` are asserted reachable as public `fn` items. The in-crate
`create_system_user_allocates_consistent_id_and_is_idempotent` unit test still
covers the allocation math.