feat(kanidm): make the entry-manager delegation declarative via a carried patch #268
Inga granskare
Etiketter
Inga etiketter
area/backups
area/ci
area/control-panel
area/identity
area/infra
area/observability
area/payments
area/security
area/storage
area/web
blocked
needs-info
needs-triage
ready-for-implementation
type
bug
type
chore
type
docs
type
epic
type
feature
type
task
wontfix
Ingen milstolpe
Inget projekt
Inga tilldelade
1 deltagare
Notiser
Förfallodatum
Inget förfallodatum satt.
Beroenden
Inga beroenden satta
Referens
bitborg/bitborg-infra!268
Läser in…
Hänvisa till i nytt ärende
Ingen beskrivning angiven.
Ta bort grenen "feat/kanidm-declarative-entry-manager"
Borttagning av en gren är permanent. Även om den borttagna grenen kan fortsätta existera en kort tid innan den faktiskt tas bort, kan det INTE ångras i de flesta fall. Vill du fortsätta?
Retires the manual
kanidm group set-entry-managerstep whose omission broke sign-up for 13 days(2026-07-17..30). ADR 0029 moved the tier group and ADR 0036 added the trial group; the manual
delegation followed neither, and Kanidm reports a denied member-write as 404, so the symptom was a
bare
group add -> 404.The premise that blocked this was wrong
"kanidm-provision has no ACP support" is true but irrelevant. bitborg does not need access-control
profiles.
kanidm group set-entry-manageris one ordinary attribute write:That is exactly the request
update_entity_attrs()already issues, as theidm_adminit alreadyauthenticates as. The blocker was a missing optional field on
struct Group— not an architecture.The patch (three parts, on the pinned upstream ref)
state.rs—entry_managed_by: Option<String>onGroup(camelCase ⇒entryManagedBy).Omitted/null means leave alone.
client.rs— extend the SPN-stripping that already exists formembertoentry_managed_by.Kanidm returns it as
name@domain, so without this the comparison never matches what we wrote andthe attribute is rewritten on every run.
kaniopindependently normalises the same attribute thesame way — useful corroboration.
main.rs— write it in the Syncing group members phase, not insync_groups(), becausestate.groupsis an unorderedHashMap: by that phase every group exists, so the manager can safelybe another provisioned group.
The
if let Some(...)guard is load-bearing.update_entity_attrs()treats an emptyVecwithappend: falseas thevalues.is_empty()case and issues a DELETE — so passing an empty list foran undeclared field would strip an entry manager set outside this tool. Omission must mean "leave
alone", never "remove".
Two structural fixes alongside
Decoupled the image tag from the upstream ref.
kanidm_provision_image_tagdoubled as the git refto clone, so a patched image could not be labelled distinctly without breaking the clone. Now
kanidm_provision_upstream_ref(what to clone) andkanidm_provision_image_tag(
v1.3.0-bitborg1, bumped when the patch changes — the marker from #266 catches Containerfile edits,not patch edits).
Collapsed three copies of the delegated-group list into one —
kanidm_portal_managed_groupsingroup_vars/all, consumed by the state template and by site.yml's health gate. Three copies thathad to agree is the root shape of the outage. (
signup-drill.shreads the same underlyingweb_kanidm_*_groupvars, because a shellsedcannot resolve Jinja.)Verification — each step, not by reasoning
git apply --checkagainst a fresh v1.3.0 clonecargo build --releaseonrust:1-trixieentryManagedBy: 12345→invalid type: integer 12345, expected a stringgit apply)entryManagedByentryManagedByon exactly the 4 managed groups; no key on the other 12--syntax-check,ansible-lintproductionThat wrong-type test is worth noting: it is precisely the test that would have caught the dead
service-accountsblock (#267). A known field validates its type; an unknown one is silentlydropped.
git applyrather thanpatch, deliberately — a context drift on a future ref bump fails the buildloudly instead of half-applying.
What stays
The health gate and
signup-drill.shremain. They become belt-and-braces rather than the onlydefence — and the gate is what will catch it if this patch is ever dropped on a ref bump.
The manual command is kept in the runbook as documented break-glass.
Apply note
This bumps the image tag, so the next apply rebuilds the image on the host (native Rust compile,
~3 min on prod based on the last one). Being offered upstream separately; there is no existing issue or
PR for the feature.
`kanidm-state.json.j2` declared a `"service-accounts"` block, and the role defaults stated "The account + its group memberships are declared in kanidm-state.json.j2 so kanidm-provision manages them." Half of that was false. The block was DEAD. Verified against upstream v1.3.0 `src/state.rs`: #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct State { pub groups: …, pub persons: …, pub systems: … } Three fields, and no `#[serde(deny_unknown_fields)]` — so serde parsed `"service-accounts"`, found no matching field, and discarded it without a warning. Service-account support is upstream PR #29, unreviewed for 12 months. The template's own comment already flagged this as unresolved: "⚠️ VERIFY the exact key name "service-accounts" and the object shape against kanidm-provision's schema". That verification was never done, and the answer was "it does not exist". What is actually true: - The service account's GROUP MEMBERSHIPS are managed — _sa_group_members merges it into the `groups` block, and the rendered state declares gitborg-web-provision as a member of idm_people_on_boarding, idm_people_admins and idm_gitborg_ent_managers. - The ACCOUNT OBJECT is not. It is a manual bootstrap, created by hand at the same time as its API token. This is the same shape of latent gap as the entry-managed-by delegation that broke sign-up for 13 days: something assumed declarative that never was. Worth removing rather than leaving in place looking managed. Also documents two things in the runbook: - A rebuild-ordering hazard: because `groups` names the account as a member, a fresh host must have the service account created BEFORE this role provisions, or those member writes reference a nonexistent entity. Full rebuild sequence added. - `"present": false` DELETES regardless of `--no-auto-remove` — that flag only gates removal of orphans (entities dropped from the state file). Nothing here uses present:false today; the note exists so nobody reaches for it expecting protection. Verified by rendering the template offline with representative vars: the output is valid JSON, its top-level keys are exactly groups/persons/systems (matching upstream's struct, so nothing is silently dropped any more), `service-accounts` is gone, and the service account is still declared as a member of the three privilege groups.Retires the manual `kanidm group set-entry-manager` step whose omission broke sign-up for 13 days (2026-07-17..30). ADR 0029 moved the tier group and ADR 0036 added the trial group; the manual delegation followed neither, and Kanidm reports a denied member-write as 404, so the symptom was a bare "group add -> 404". The premise that blocked this was wrong. "kanidm-provision has no ACP support" is true but irrelevant: gitborg does not need access-control PROFILES. Kanidm's `kanidm group set-entry-manager` is one ordinary attribute write — PUT /v1/group/<group>/_attr/entry_managed_by ["<manager>"] — which is exactly the request `update_entity_attrs()` already issues, as the idm_admin it already authenticates as. The blocker was a missing optional field on `struct Group`, not an architecture. So: a carried patch on the pinned upstream ref, in three parts. - state.rs: `entry_managed_by: Option<String>` on Group (camelCase => `entryManagedBy`). Omitted/null means LEAVE ALONE. - client.rs: extend the SPN-stripping that already exists for `member` to `entry_managed_by`. Kanidm returns it as "name@domain", so without this the comparison never matches what we wrote and the attribute is rewritten every run. kaniop independently normalises the same attribute the same way. - main.rs: write it in the "Syncing group members" phase, NOT in sync_groups(), because state.groups is an unordered HashMap — by that phase every group exists, so the manager can safely be another provisioned group. The `if let Some(...)` guard is load-bearing. update_entity_attrs() treats an empty Vec with append=false as the `values.is_empty()` case and issues a DELETE, so passing an empty list for an undeclared field would STRIP an entry manager set outside this tool. Omission must mean "leave alone", never "remove". Also decouples two things that were conflated: kanidm_provision_image_tag doubled as the upstream git ref, so a patched image could not be labelled distinctly without breaking the clone. Now kanidm_provision_upstream_ref (what to clone) and kanidm_provision_image_tag ("v1.3.0-gitborg1", bumped when the patch changes). And collapses THREE copies of the delegated-group list into one (kanidm_portal_managed_groups in group_vars/all), consumed by the state template and by site.yml's health gate. Three copies that had to agree is the root shape of the outage; the drill reads the same underlying web_kanidm_*_group vars because a shell sed cannot resolve Jinja. Verified, each step rather than by reasoning: - patch applies cleanly to a fresh v1.3.0 clone (`git apply --check`) - `cargo build --release` succeeds on rust:1-trixie - the field is genuinely PARSED, not silently ignored: entryManagedBy: 12345 fails with "invalid type: integer `12345`, expected a string". (That same test would have caught the dead service-accounts block — a known field validates its type, an unknown one is dropped.) - building through the real Containerfile succeeds, exercising `git apply` in the flow - the patched binary parses a realistic state containing entryManagedBy - rendering the state template offline: valid JSON, entryManagedBy on exactly the four managed groups, and NO key on the other 12 — so their existing entry manager is untouched - --syntax-check and ansible-lint clean at the production profile `git apply` (not `patch`) on purpose: a context drift on a future ref bump fails the build loudly instead of half-applying. The health gate and signup-drill stay. They become belt-and-braces rather than the only defence — and the gate is what will catch it if this patch is ever dropped. Offering upstream separately; there is no existing issue or PR for it.30ca733b2b825b513443825b51344336c1207ae6