reconciler doesn't expand nested Kanidm entitlement groups — tier_pro users get zero quota #166

Stängd
öppnade 2026-07-20 09:11:42 +00:00 av supernaut · 1 kommentar
Ägare

Severity: HIGH (enforcement inversion — Pro users get less quota than Basic). Found live during the #125 apply (2026-07-20).

What

The entitlement taxonomy (ADR 0016, roles/kanidm/defaults/main.yml) grants entitlements by nesting a tier group into the entitlement groups it confers — e.g. ent_lfs.member = [tier_pro], ent_orgs.member = [tier_pro], ent_runners_shared.member = [tier_pro]. The comment is explicit: "a tier is a MEMBER OF the entitlement groups it grants, so a person in the tier inherits those entitlements via transitive memberOf."

But the reconciler's members() (roles/reconciler/templates/gitborg-reconciler.sh.j2) reads:

kd "${KANIDM_URL}/v1/group/$1" | jq -r '.attrs.member // [] | .[]' | sed 's/@.*//'

.attrs.member is direct membership only. For ent_lfs it returns the string tier_pro (the nested group), not the persons in tier_pro. So in_set "$user" "$LFS_PRO_M" never matches a real Pro user.

Impact

Every tier_pro user misses lfs-pro, allow_create_organization (ent_orgs), and the Actions unit (ent_runners_shared). With #125's fail-closed fallback they now land in participant (size:all = 0) — a paying Pro seat gets zero hosting quota. (Before #125 they hit the lfs-basic fallback = unlimited non-LFS, so the entitlement miss was masked; #125 made it acute.)

Observed in the live reconciler run:

WARN: alexanderkjall has no Kanidm tier signal — fail-closed to 'participant'
alexanderkjall: lfs=participant org_create=false actions=false renovate=true (cap 100)

renovate cap 100 proves alexanderkjall IS in tier_pro (the Renovate cap reads tier_pro directly), yet quota/org/actions all fell through. tier_basic is unaffected (the reconciler reads it directly since #125); only the nested tier_pro → ent_* path is broken. Acute blast radius is currently nil (alexanderkjall owns no repos), so this is fix-forward, not an incident.

Fix

Make members() expand nested groups (Kanidm member is not transitive; only memberof is). Recurse into any member that is itself a group, with a cycle guard — so ent_lfs → tier_pro → {persons} resolves. Alternatively query each person's transitive memberof. Keep the abort-on-Kanidm-read-failure behaviour.

Verify after fix

A tier_pro user resolves to lfs=lfs-pro org_create=true actions=true; a tier_participant/tierless user still → participant; tier_basic → lfs-basic. Dry-run the reconciler and read the journal.

Related: #125 (M8 — partial/again mis-tier). Split out because this is a distinct, higher-severity resolution bug, not just an empty-read guard.

**Severity: HIGH** (enforcement inversion — Pro users get *less* quota than Basic). Found live during the #125 apply (2026-07-20). ## What The entitlement taxonomy (ADR 0016, `roles/kanidm/defaults/main.yml`) grants entitlements by **nesting a tier group into the entitlement groups it confers** — e.g. `ent_lfs.member = [tier_pro]`, `ent_orgs.member = [tier_pro]`, `ent_runners_shared.member = [tier_pro]`. The comment is explicit: *"a tier is a MEMBER OF the entitlement groups it grants, so a person in the tier inherits those entitlements via transitive memberOf."* But the reconciler's `members()` (`roles/reconciler/templates/gitborg-reconciler.sh.j2`) reads: ``` kd "${KANIDM_URL}/v1/group/$1" | jq -r '.attrs.member // [] | .[]' | sed 's/@.*//' ``` `.attrs.member` is **direct** membership only. For `ent_lfs` it returns the string `tier_pro` (the nested group), **not** the persons in `tier_pro`. So `in_set "$user" "$LFS_PRO_M"` never matches a real Pro user. ## Impact Every **tier_pro** user misses `lfs-pro`, `allow_create_organization` (ent_orgs), and the Actions unit (ent_runners_shared). With #125's fail-closed fallback they now land in **`participant` (size:all = 0)** — a paying Pro seat gets zero hosting quota. (Before #125 they hit the `lfs-basic` fallback = unlimited non-LFS, so the entitlement miss was masked; #125 made it acute.) Observed in the live reconciler run: ``` WARN: alexanderkjall has no Kanidm tier signal — fail-closed to 'participant' alexanderkjall: lfs=participant org_create=false actions=false renovate=true (cap 100) ``` `renovate cap 100` proves `alexanderkjall` IS in `tier_pro` (the Renovate cap reads `tier_pro` directly), yet quota/org/actions all fell through. `tier_basic` is unaffected (the reconciler reads it directly since #125); only the **nested** `tier_pro → ent_*` path is broken. Acute blast radius is currently nil (alexanderkjall owns no repos), so this is fix-forward, not an incident. ## Fix Make `members()` expand nested groups (Kanidm `member` is not transitive; only `memberof` is). Recurse into any member that is itself a group, with a cycle guard — so `ent_lfs → tier_pro → {persons}` resolves. Alternatively query each person's transitive `memberof`. Keep the abort-on-Kanidm-read-failure behaviour. ## Verify after fix A tier_pro user resolves to `lfs=lfs-pro org_create=true actions=true`; a tier_participant/tierless user still → participant; tier_basic → lfs-basic. Dry-run the reconciler and read the journal. Related: #125 (M8 — partial/again mis-tier). Split out because this is a distinct, higher-severity resolution bug, not just an empty-read guard.
Upphovsperson
Ägare

Fix implemented on branch feat/166-nested-group-expansion (committed, push pending — SSH agent re-locked). members() now recurses into nested groups (Kanidm member is direct-only) with a cycle guard, so ent_lfs → tier_pro → {persons} resolves and Pro users get lfs-pro/org-create/actions instead of failing closed to participant. Verified with a mocked-topology unit test (nesting, mixed direct+nested, cycle, direct). Ready to push + PR; after apply, verify a tier_pro user resolves to lfs=lfs-pro org_create=true actions=true.

Fix implemented on branch `feat/166-nested-group-expansion` (committed, **push pending** — SSH agent re-locked). `members()` now recurses into nested groups (Kanidm `member` is direct-only) with a cycle guard, so `ent_lfs → tier_pro → {persons}` resolves and Pro users get lfs-pro/org-create/actions instead of failing closed to participant. Verified with a mocked-topology unit test (nesting, mixed direct+nested, cycle, direct). Ready to push + PR; after apply, verify a tier_pro user resolves to `lfs=lfs-pro org_create=true actions=true`.
Logga in för att delta i denna konversation.
Ingen milstolpe
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#166
Ingen beskrivning angiven.