feat(kanidm): declare the account policy and gate on drift #277

Sammanfogat
supernaut sammanfogade 1 incheckning från feat/275-kanidm-account-policy-gate in i main 2026-07-31 10:25:51 +00:00
Ägare

Part of #275 — the account-policy half. The OAuth2 half is separate (see below).

Production's credential_type_minimum is mfa: Kanidm's upstream default, inherited rather than
chosen. This declares it and asserts it against the live server.

Why declared-and-asserted rather than applied

It cannot be applied. kanidm-provision v1.3.0 has no account-policy support — State carries
only groups, persons, systems, and Group only membership/unix fields. And State has no
#[serde(deny_unknown_fields)], so an accountPolicy key would be parsed and silently
discarded
: a state file that looks declarative and changes nothing.

That is the same trap this role already documents for the dead service-accounts block. Adding it
would have been worse than the gap it appeared to close, so the naive implementation was abandoned
after reading upstream rather than shipped.

And a write here should not be automatic. The CLI ships no reset-credential-type-minimum, and
Kanidm restricts downgrading the attribute, so setting it is close to one-way. A gate that reports
drift and leaves the decision to a human is the right shape; a playbook that silently forces a value
it cannot undo is not.

Safety properties

  • Read-only credential. Uses vault_kanidm_reconciler_token, so the gate cannot mutate identity
    state even if the task is wrong. No new privileged token had to be bootstrapped.
  • check_mode: false so it runs during dry-runs. Without it the gate would be dead in exactly
    the pass operators rely on before applying.
  • Narrow failure. Fails only on a value actually read that disagrees. A 403 (read-only token
    lacking the attribute), a 404, or an unexpected body shape is reported via debug and tolerated —
    a drift gate that breaks unrelated applies is worse than no gate.
  • changed_when: false, no_log: true (token in headers).

Verified against production, both directions

Match — --check --tags kanidm:

TASK [kanidm : Assert the account policy matches what this repo declares (#275)]
ok: [gitborg-prod] => "Kanidm account policy on idm_all_persons matches the declared
                       credential_type_minimum 'mfa'."
PLAY RECAP: ok=24 changed=0 failed=0

Drift — same run with -e kanidm_account_policy_credential_type_minimum=passkey:

fatal: [gitborg-prod]: FAILED! => Kanidm account policy DRIFT on idm_all_persons:
credential_type_minimum is 'mfa' but this repo declares 'passkey'. …

The failure message names the live value, the declared value, the ordering, the one-way caveat, and
the file to edit.

Also recorded

Why the credential UX behaves as it does, since it caused real confusion: ordering is
any < mfa < passkey < attested_passkey, so a password alone ranks below mfa and Kanidm demands
TOTP alongside it, while a passkey ranks above mfa and satisfies the policy by itself. A
passkey-only person needs no TOTP; only choosing a password pulls in a second factor. That is the
intended posture, not a bug.

Not in this PR

The OAuth2 half of #275, which is now the more urgent part. The live client list shows the
production bitborg-web client carries https://localhost:4321/auth/callback as a registered
origin alongside the real callback, and the stray bitborg-web-dev client is scope-mapped to
forgejo_users — i.e. visible to every user. Both want fixing, and present: false plus explicit
origin_url/scope_maps can do it declaratively. Kept separate because a mistake there breaks SSO.

Part of #275 — the account-policy half. The OAuth2 half is separate (see below). Production's `credential_type_minimum` is `mfa`: Kanidm's upstream default, inherited rather than chosen. This declares it and **asserts** it against the live server. ## Why declared-and-asserted rather than applied **It cannot be applied.** `kanidm-provision` v1.3.0 has no account-policy support — `State` carries only `groups`, `persons`, `systems`, and `Group` only membership/unix fields. And `State` has no `#[serde(deny_unknown_fields)]`, so an `accountPolicy` key would be **parsed and silently discarded**: a state file that looks declarative and changes nothing. That is the same trap this role already documents for the dead `service-accounts` block. Adding it would have been worse than the gap it appeared to close, so the naive implementation was abandoned after reading upstream rather than shipped. **And a write here should not be automatic.** The CLI ships no `reset-credential-type-minimum`, and Kanidm restricts downgrading the attribute, so setting it is close to one-way. A gate that reports drift and leaves the decision to a human is the right shape; a playbook that silently forces a value it cannot undo is not. ## Safety properties - **Read-only credential.** Uses `vault_kanidm_reconciler_token`, so the gate cannot mutate identity state even if the task is wrong. No new privileged token had to be bootstrapped. - **`check_mode: false`** so it runs during dry-runs. Without it the gate would be dead in exactly the pass operators rely on before applying. - **Narrow failure.** Fails only on a value actually read that disagrees. A 403 (read-only token lacking the attribute), a 404, or an unexpected body shape is reported via `debug` and tolerated — a drift gate that breaks unrelated applies is worse than no gate. - `changed_when: false`, `no_log: true` (token in headers). ## Verified against production, both directions Match — `--check --tags kanidm`: ``` TASK [kanidm : Assert the account policy matches what this repo declares (#275)] ok: [gitborg-prod] => "Kanidm account policy on idm_all_persons matches the declared credential_type_minimum 'mfa'." PLAY RECAP: ok=24 changed=0 failed=0 ``` Drift — same run with `-e kanidm_account_policy_credential_type_minimum=passkey`: ``` fatal: [gitborg-prod]: FAILED! => Kanidm account policy DRIFT on idm_all_persons: credential_type_minimum is 'mfa' but this repo declares 'passkey'. … ``` The failure message names the live value, the declared value, the ordering, the one-way caveat, and the file to edit. ## Also recorded Why the credential UX behaves as it does, since it caused real confusion: ordering is `any < mfa < passkey < attested_passkey`, so a **password alone ranks below `mfa`** and Kanidm demands TOTP alongside it, while a **passkey ranks above `mfa`** and satisfies the policy by itself. A passkey-only person needs no TOTP; only choosing a password pulls in a second factor. That is the intended posture, not a bug. ## Not in this PR The OAuth2 half of #275, which is now the more urgent part. The live client list shows the **production** `bitborg-web` client carries `https://localhost:4321/auth/callback` as a registered origin alongside the real callback, and the stray `bitborg-web-dev` client is scope-mapped to `forgejo_users` — i.e. visible to every user. Both want fixing, and `present: false` plus explicit `origin_url`/`scope_maps` can do it declaratively. Kept separate because a mistake there breaks SSO.
supernaut lade till 1 incheckning 2026-07-31 10:07:50 +00:00
feat(kanidm): declare the account policy and gate on drift
Alla kontroller lyckades
ci / ci (pull_request) Successful in 1m36s
e5aa109d3d
Part of #275.

Production's credential_type_minimum is 'mfa' — Kanidm's upstream default,
inherited rather than chosen. Nothing in this repo recorded it, so nobody had
reviewed it against principle 4 and a change to the upstream default would have
altered the credential requirements for every person with no diff anywhere.

Declares the value and asserts it against the live server.

Not applied, because it cannot be. kanidm-provision v1.3.0's State carries only
groups, persons and systems, and Group only membership and unix fields — there is
no account-policy support. And State has no deny_unknown_fields, so an
accountPolicy key would be parsed and silently discarded, producing a state file
that looks declarative and changes nothing. That is the same dead-declaration
trap already documented for the old service-accounts block, so adding it would
have been worse than the gap it appeared to close.

Asserted rather than set, deliberately. The CLI ships no
reset-credential-type-minimum and Kanidm restricts downgrading the attribute, so
a write is close to one-way. Detecting drift and leaving the decision to a human
beats a playbook silently forcing a value it cannot undo.

Reads with the reconciler token, which is read-only, so the gate cannot mutate
identity state even if it is wrong. check_mode: false so it runs in dry-runs too
— otherwise the gate would be dead in exactly the pass operators rely on.
Failure is narrow: it fails only on a value actually read that disagrees, while
an unreadable policy (403, 404, unexpected body) is reported and tolerated. A
drift gate that breaks unrelated applies is worse than no gate.

Verified both directions against production: the match case passes with
changed=0, and forcing a mismatch via -e fails with a message naming the live
value, the declared value, the ordering, the one-way caveat and the file to edit.

Also records why a passkey needs no TOTP while a password does: a password alone
ranks below mfa, a passkey ranks above it.
supernaut sammanfogade incheckning 2e5109ade7 till main 2026-07-31 10:25:51 +00:00
supernaut tog bort grenen feat/275-kanidm-account-policy-gate 2026-07-31 10:25:51 +00:00
supernaut refererade denna ändringsförfrågan från en incheckning 2026-07-31 11:47:21 +00:00
supernaut refererade denna ändringsförfrågan från en incheckning 2026-08-03 09:41:35 +00:00
Logga in för att delta i denna konversation.
Inga granskare
Ingen milstolpe
Inget projekt
Inga tilldelade
1 deltagare
Notiser
Förfallodatum
Förfallodatumet är ogiltigt eller utanför gränserna. Använd formatet "åååå-mm-dd".

Inget förfallodatum satt.

Beroenden

Inga beroenden satta

Referens
bitborg/bitborg-infra!277
Ingen beskrivning angiven.