0043. Account governance on Linux¶
- Status: accepted
- Date: 2026-07-31
Context and Problem Statement¶
ADR-0042 gave kenny the ability to steer
who may sign in — seven account-keyed tools, with local_accounts as their inventory. It
was written for Windows, and pinned all seven to windows in the server's OS-scope table.
The fleet is not Windows-only. ADR-0031 made Linux a
first-class agent target, ADR-0034
gave it a distribution path, and local_accounts already has a real Linux arm reading
/etc/passwd + /etc/group. Account governance is the one control that makes every other
control hold (ADR-0042's own argument), and on a Linux home server — where sudo is the whole
security model — it holds even harder than on a family PC.
The question is not whether to govern Linux accounts. It is what shape that takes, and the shape is decided by one constraint: the dashboard must not become two worlds. Today it would become three, because ADR-0042 already had to solve this once for local-vs-Microsoft accounts and chose "one tool family keyed by account, capability discovered from telemetry".
There is also a live defect to settle. renderAccountsDetail has no OS branch at all, so a
Linux host already renders the complete Windows verb set — "suspend", "make admin", "no
remote desktop", "built-in Windows accounts cannot be deleted" — and every button fails
server-side with requires windows. The dashboard is already one world; it is just lying
about it. Any decision here has to either make that true or make it visibly false.
Where the layers line up, and where they do not¶
| Layer | Windows | Linux | Same shape? |
|---|---|---|---|
| L1 account database | SAM/LSA | /etc/passwd, /etc/shadow, /etc/group |
yes — enable/disable, admin membership, create/delete |
| L2 session | WTS (WTSDisconnectSession/WTSLogoffSession) |
systemd-logind (loginctl) |
yes, when a session manager is present |
| L3 machine policy | net accounts |
PAM (pwquality, login.defs, faillock) |
yes, per knob — each may be absent |
| L4 remote sign-in | two distinct planes (network logon, RDP) | one plane (SSH) | no — this is the real asymmetry |
The agent runs as User=root under systemd (kenny-agent/src/service.rs), so /etc/shadow,
usermod, useradd, userdel, loginctl and the sshd configuration are all reachable.
Nothing in the Linux arm needs a privilege the agent does not already have.
Considered Options¶
- A second
linux_account_*tool family. Rejected for the same reason ADR-0042 rejectedlocal_account_*/msaccount_*: it pushes the OS distinction onto every caller — Claude, the dashboard, the operator — for a distinction that only matters at the bottom of the stack. It would also mean two dashboard panels, which is precisely the outcome this record exists to avoid. Two server tests (test_there_is_no_per_kind_tool_or_kind_argument,test_no_other_forwarded_tool_silently_acquired_a_role_gate) already fail loudly if anyone tries; they are kept untouched as the guardrail. - Keep the tools Windows-scoped, add a read-only Linux inventory. Rejected: it fixes the lying dashboard by admitting defeat, and leaves a Linux server's sudo membership — the single most valuable governance lever kenny could pull — permanently out of reach.
-
Widen the OS scope and carry every Linux asymmetry in the existing per-account
unsupportedmap (chosen). ADR-0042 built that map for exactly this: "the capability-negation shape absorbs future asymmetry without a contract change: adding password reset later means one moreunsupportedentry, not a second tool family." -
A new neutral
remote_shelldeny token for SSH vs. reusingremote_interactive(chosen). A new token is semantically cleaner in isolation, but it forces the dashboard to render a different set of checkboxes per OS — reintroducing the two worlds one layer down.remote_interactivealready means "deny interactive sign-in from elsewhere", which is exactly what an SSH deny does. The genuinely absent concept isnetwork, and absence is what the negation map is for. - A new
protected: boolfield so the guard can refuse to touch root vs. routing root's asymmetry throughunsupported(chosen). A new boolean would have been the smallest change, but it duplicates information the negation map already has to carry (root's three restrictions differ from each other — it cannot be disabled, cannot be demoted, cannot be deleted, and each needs its own reason). Reusingbuiltin_adminfor root was also considered and rejected: it would fire the "built-in Administrator enabled" health warning on every Linux host, where root being enabled is not a finding.
Decision Outcome¶
Chosen: the account-governance tool family loses its OS scope; every Linux asymmetry is
published in the per-account unsupported map; and the agent's self-protection guard
refuses any action whose verb that map names. PROTOCOL_VERSION bumps 0.15 → 0.16 — no
frame or field shape changes, only documented behaviour, a written-down vocabulary, and one
optional additive key.
That last clause is the load-bearing one. Before this record, unsupported was advisory: the
dashboard greyed a control out and the agent refused separately, in its own code. Now
guard() reads the live inventory and refuses exactly what the inventory advertises, so
the published capability set and the enforced one are the same list by construction. The
guard is already #[cfg]-free and runs before the platform split, so this is exercised on
Linux CI — including for the Windows arms.
- Verb vocabulary written down.
unsupportedkeys are capability verbs, not tool names (reset_passwordalready established that — there is no such tool). The set isset_enabled,set_admin,deny_network,deny_remote_interactive,delete,session_lock,session_logoff,reset_password. Splitting the two deny rights and the two session actions is deliberate: one tool can be partly available, and the UI needs that granularity to grey one checkbox and leave the other live. - Host capability, not just account kind. On Windows,
unsupportedis a function of the account (kind). On Linux it is a function of the account and the host — nosystemd-logind, nosshd, nofaillockare all real configurations. The collector composes host-level negations with kind-level ones; the wire shape is unchanged because the composition happens before serialization. remote_interactive= deny SSH, applied through a kenny-owned/etc/ssh/sshd_config.d/50-kenny-deny.confDenyUsersblock, validated withsshd -tbefore reload — never a blind write to a file that can lock out remote administration.networkis negated per account withno_network_logon_plane.- "Disabled" on Linux means account expiry plus a locked password, not a locked password
alone.
usermod -Lleaves SSH key authentication working, so a password lock alone would report an account as suspended while it is still reachable — the worst possible failure mode for this feature. Expiry (usermod --expiredate 1) is what actually closes the door, andenabledis read back from/etc/shadowrather than inferred. - The admin group is resolved, not assumed — the first of
sudo,wheel,adminthat exists in/etc/group, with removal stripping membership in all of them. This is the deterministic analogue of ADR-0042's well-known-SID trick: distro-proof the way the SID is locale-proof. - root is protected through the map, not through a special case:
set_admin → uid_0_is_always_root(group membership does not grant or revoke root),set_enabled → uid_0_cannot_be_disabled,delete → uid_0. The guard then refuses all three without knowing what root is. - A
nologinshell is reported, not rewritten. Such an account showsenabled: falsewithset_enabled → nologin_shell, because kenny changing a login shell is a different and more surprising act than clearing an expiry date. logon_failuresgains a Linux arm (sshd/PAM failures from the journal, falling back to/var/log/auth.log) and leavesWINDOWS_ONLY_SECTIONS.networksimply does not occur there — SSH isremote, console and display-manager attempts areinteractive. Reporting an empty third bucket is honest; inventing a mapping for it would not be.- The privacy line does not move again. ADR-0042 moved it once: identity is named and
governable, behaviour stays aggregated. Linux changes nothing about that. No home directory
contents, no command history, no per-user activity — and no source addresses in
logon_failures, exactly as on Windows. - The Linux arm shells out with
Command::newdirectly, not throughshell_exec. Theposixdeny-rule catalog (ADR-0019/0020) therefore does not apply to it. That is correct rather than an oversight: the deny catalog exists to constrain operator-supplied commands, and applying it to kenny's own fixed argv would make a policy edit able to break governance. Named here so it is a decision.
Consequences¶
- Good, because there is one account panel, one tool catalog, one mental model, and one audit-log vocabulary across both operating systems. An operator moving between a Windows PC and a Linux server sees the same verbs and the same layout, differing only where a control is greyed out with its reason shown.
- Good, because it fixes a live defect rather than working around it: buttons that today render on Linux and cannot work will either work or be visibly unavailable.
- Good, because enforcement and advertisement can no longer drift — the guard reads the same map the dashboard renders.
- Good, because on Linux
password_requiredandpassword_last_setbecome real values read from/etc/shadow, so the existing "admin requires no password" crit rule works on Linux for the first time. - Good, because the negation map now absorbs host asymmetry as well as account asymmetry,
which is what a macOS arm or a directory-backed (SSSD/LDAP)
kindwould need next. - Bad, because the wire vocabulary stays Windows-flavoured:
remote_interactivemeans SSH on Linux, andlocal_accountsnames a section that includes Microsoft accounts. ADR-0031 already accepted this cost ("two naming worlds on the wire — an accepted cosmetic cost") and rejected a renaming migration as a large breaking change for a cosmetic payoff. The same judgment applies here, and the UI is where the vocabulary is made readable. - Bad, because Linux fragmentation is real and this cannot be fully tested in CI. The sshd
drop-in directory,
faillockvs.pam_tally2,pwqualityvs.cracklib, and logind's presence all vary by distro. The design's answer is to probe and negate rather than assume — an absent knob becomes anunsupportedentry, not a silent failure — but the probes themselves need verification on real Debian and Fedora hosts before this is relied on. - Bad, because the kill switch still outranks governance (ADR-0042's stance, unchanged): all seven tools are mutating, so a user who switches remote control off can still refuse new measures. Already-applied state persists, and the drift signal remains load-bearing.
- Bad, because
shell_execbypasses the whole layer on Linux exactly aspowershell_execdoes on Windows. An operator can callusermoddirectly. Not addressed here, for the same reason ADR-0042 did not address it. - Neutral, because
account_createis now the less asymmetric verb on Linux than on Windows — there is no Linux equivalent of "a Microsoft account can only be added interactively". The asymmetry table is not symmetric, which is the point of publishing it as data.
More Information¶
- Explicitly out of scope: directory-backed identities (SSSD/LDAP/AD-joined hosts — they
would arrive as a new
kindwith a largeunsupportedmap, exactly as ADR-0042 predicted for Entra); SELinux/AppArmor confinement;sudoersrule editing beyond group membership (a drop-in-file editor for/etc/sudoers.dis a materially different and more dangerous surface); PAM stack editing beyond the three documented knobs; macOS, which keeps returningunsupported. - Contract:
docs/protocol.md§ Tool catalog → Account governance tools → Capability negation, § Telemetry sections (local_accounts,logon_failures), § Versioning (PROTOCOL_VERSION = "0.16"); fixturesdocs/fixtures/telemetry_snapshot_linux.jsonanddocs/fixtures/response_*_linux.json. - Code (agent):
kenny-agent/src/handlers/accounts.rs(guard,linux_impl),src/telemetry/collectors/local_accounts.rs(core::HostCapabilities,linux_impl),src/telemetry/collectors/logon_failures.rs(core::shape_tokens,linux_impl). - Code (server):
kenny-server/kenny_server/tools.py(_OS_SCOPED_TOOLS),health_rules.py(WINDOWS_ONLY_SECTIONS),webui/index.html(renderAccountsDetail). - Related: ADR-0042 (the record this extends — its Windows rationale stands unchanged), ADR-0031 (first-class Linux, additive growth — this is its Phase 2/3 for accounts), ADR-0011, ADR-0019 / ADR-0020 (the deny catalog this deliberately does not route through), ADR-0023 (confirm-gate parity), ADR-0024 / ADR-0029 (the privacy line, unmoved), ADR-0033 (the operator role gate, unchanged).