fail-closed tier fallback + bounded per-tier storage caps (#125 H1+H2) #164

Sammanfogat
supernaut sammanfogade 3 incheckningar från feat/125-reconciler-quota-hardening in i main 2026-07-20 08:51:21 +00:00
Ägare

Closes the two HIGH findings in #125 (H1, H2). ⚠️ Review + dry-run before applying — the reconciler timer applies within ~5 min of the role apply.

H1 — fail-closed tier fallback

The old fallback sent any unmatched/mis-provisioned user to lfs-basic, whose shared base rule granted unlimited non-LFS storage. A user whose tier_participant assignment failed at signup (but whose JIT Forgejo account was created) silently got unlimited hosting.

  • No tier signal → zero-quota participant (was lfs-basic).
  • tier_basic is now read from Kanidm as the positive paid-seat signal (payments webhook adds members) rather than being the catch-all.
  • First-party identities (forgejo_users + forgejo_service_accounts + reconciler_user_exempt_extra) are exempted into the unlimited group — otherwise fail-closed would zero-quota the operator admin and the bots.
  • A WARN is logged on every fail-closed fallback, which also surfaces an ACL-filtered empty tier read (M8) in the journal.

⚠️ reconciler_user_exempt_extra currently lists supernaut (group_vars) — the operator admin predates Ansible provisioning (forgejo_users is empty). Confirm that's the correct + only out-of-band admin login before applying.

H2 — bounded per-tier non-LFS storage

The shared base rule (size:repos:all + size:assets:all = -1) let one basic user fill the shared data volume with ordinary git push/packages — a shared-volume outage with only reactive DiskUsageCritical (90%) to catch it.

  • Each tier group now carries a <group>-store rule (size:repos:all + size:assets:all) capped at 10 GiB (the paid-seat figure from the pricing decision — 10 GiB shared, no repo-count cap). LFS add-ons bump LFS only.
  • The shared base rule is detached from every group and deleted; groups that still carry it self-heal.
  • Subjects don't overlap the per-tier size:git:lfs rule, so Forgejo's most-permissive merge can't defeat either cap.

Not in scope (still open on #125)

H3 (token-scope no-op → fatal/alertable), M4 (per-cycle + org Actions bypass), M8 (sanity floor on group reads), and the upload/file-size half of M3. M3 note: MAX_CREATION_LIMIT is deliberately not set — the pricing decision chose "no repo-count cap, bounded by storage quota".

Verification

Rendered with prod-shaped vars + bash -n clean; ansible-playbook --syntax-check + ansible-lint (production profile) clean. Not yet dry-run on the instance.

Refs #125.

Closes the two HIGH findings in #125 (H1, H2). ⚠️ **Review + dry-run before applying** — the reconciler timer applies within ~5 min of the role apply. ## H1 — fail-closed tier fallback The old fallback sent any unmatched/mis-provisioned user to `lfs-basic`, whose shared `base` rule granted **unlimited** non-LFS storage. A user whose `tier_participant` assignment failed at signup (but whose JIT Forgejo account was created) silently got unlimited hosting. - No tier signal → **zero-quota `participant`** (was `lfs-basic`). - `tier_basic` is now read from Kanidm as the **positive** paid-seat signal (payments webhook adds members) rather than being the catch-all. - First-party identities (`forgejo_users` + `forgejo_service_accounts` + `reconciler_user_exempt_extra`) are exempted into the unlimited group — otherwise fail-closed would zero-quota the operator admin and the bots. - A `WARN` is logged on every fail-closed fallback, which also surfaces an ACL-filtered empty tier read (M8) in the journal. ⚠️ **`reconciler_user_exempt_extra` currently lists `supernaut`** (group_vars) — the operator admin predates Ansible provisioning (`forgejo_users` is empty). Confirm that's the correct + only out-of-band admin login before applying. ## H2 — bounded per-tier non-LFS storage The shared `base` rule (`size:repos:all` + `size:assets:all` = **-1**) let one basic user fill the shared data volume with ordinary `git push`/packages — a shared-volume outage with only reactive `DiskUsageCritical` (90%) to catch it. - Each tier group now carries a `<group>-store` rule (`size:repos:all` + `size:assets:all`) capped at **10 GiB** (the paid-seat figure from the pricing decision — 10 GiB shared, no repo-count cap). LFS add-ons bump LFS only. - The shared `base` rule is detached from every group and **deleted**; groups that still carry it self-heal. - Subjects don't overlap the per-tier `size:git:lfs` rule, so Forgejo's most-permissive merge can't defeat either cap. ## Not in scope (still open on #125) H3 (token-scope no-op → fatal/alertable), M4 (per-cycle + org Actions bypass), M8 (sanity floor on group reads), and the upload/file-size half of M3. **M3 note:** `MAX_CREATION_LIMIT` is deliberately *not* set — the pricing decision chose "no repo-count cap, bounded by storage quota". ## Verification Rendered with prod-shaped vars + `bash -n` clean; `ansible-playbook --syntax-check` + `ansible-lint` (production profile) clean. Not yet dry-run on the instance. Refs #125.
supernaut lade till 1 incheckning 2026-07-20 08:30:18 +00:00
- H1: users with NO Kanidm tier signal now land in the zero-quota participant
  group (was: lfs-basic with unlimited non-LFS storage). tier_basic is a
  positive signal read from Kanidm (payments webhook adds members). First-party
  admins/bots (forgejo_users + forgejo_service_accounts + explicit extras) are
  exempted into the unlimited group so fail-closed can't lock out the operator.
- H2: each tier group now carries a bounded <group>-store rule
  (size:repos:all + size:assets:all, 10 GiB seat default per the 2026-07-12
  pricing/limits proposal) replacing the shared unlimited 'base' rule, which is
  detached from every group and deleted. One basic user can no longer fill the
  shared data volume with plain git pushes.
- WARN log on the fail-closed path makes a systematically empty tier read (M8)
  visible in the journal.
- runbook: reconciler section updated (cadence, mapping, caps).

Rendered + bash -n verified; H3/M4/M3/M8 remain open on #125.
supernaut lade till 1 incheckning 2026-07-20 08:40:27 +00:00
fix(reconciler): source user-exempt list from vault, not the public repo (#125)
Alla kontroller lyckades
ci / ci (pull_request) Successful in 2m59s
fb7d69be58
The operator admin login must not sit in this PUBLIC repo. Mirror the
admin_allow_cidrs pattern: vars.yml references vault_reconciler_user_exempt_extra
(default []), the real list lives in the encrypted vault.yml.
supernaut lade till 1 incheckning 2026-07-20 08:46:51 +00:00
fix: vaulted reconciler excempt users
Alla kontroller lyckades
ci / ci (pull_request) Successful in 3m2s
9383e0c79b
supernaut sammanfogade incheckning 50fdd92242 till main 2026-07-20 08:51:21 +00:00
supernaut tog bort grenen feat/125-reconciler-quota-hardening 2026-07-20 08:51:21 +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!164
Ingen beskrivning angiven.