fix(kanidm): declare the portal OAuth2 clients and drop the localhost origin from production #278

Sammanfogat
supernaut sammanfogade 1 incheckning från fix/275-oauth2-client-declarations in i main 2026-07-31 10:40:21 +00:00
Ägare

Part of #275, and the more urgent half. Two live misconfigurations, fixed declaratively.

1. The production client accepted a localhost redirect

name: bitborg-web                                            ← PRODUCTION
oauth2_rs_origin: https://localhost:4321/auth/callback
oauth2_rs_origin: https://www.gitborg.se/auth/callback
oauth2_rs_scope_map: forgejo_users@auth.gitborg.se
oauth2_strict_redirect_uri: true

oauth2_strict_redirect_uri only requires the redirect to match some registered origin, and
localhost was registered — so it offered no protection here. Combined with a scope map covering
forgejo_users (every person on the service), an authorization code intended for the portal could be
redirected to a listener on a user's own machine.

Declaring only the real callback removes the localhost entry: originUrl replaces the attribute
rather than appending, verified in upstream main.rs.

2. The dev client was visible to every user

bitborg-web-dev was also scope-mapped to forgejo_users, and Kanidm lists an application on the
landing page of anyone in a scope-mapped group — which is exactly the reported symptom
("bitborg portal (dev)" showing to non-admins).

Remapped to forgejo_admins. Verified against the live server with the read-only reconciler token
that the group resolves to ['supernaut@auth.gitborg.se'], so local development keeps working and
nobody else sees the client.

Kept rather than deleted, deliberately. Deleting it would push local OIDC testing back onto the
production client — which is plausibly how the localhost origin got there in the first place. Fixing
the visibility while keeping a legitimate dev path is the durable outcome.

What is NOT declared, and why

forgejo and grafana are omitted. The forgejo client carries seven oauth2_rs_claim_map
entries driving entitlement claims (ent_lfs, ent_runners_*) and the forgejo_role admin claim.
Declaring a client replaces its attributes, and removeOrphanedClaimMaps defaults true, so a
partial declaration would strip those claim maps and break entitlement mapping and admin assignment.

--no-auto-remove means an undeclared client is left untouched rather than deleted, so omitting them
is safe. Bringing them under declaration needs their claim maps reproduced exactly — its own change,
with its own dry-run.

Checks made before touching anything near SSO

  • Client secrets survive. basicSecretFile is never emitted, and kanidm-provision only rewrites
    the secret when that field is present:
    if let Some(secret_file) = &oauth2.basic_secret_file { … }
    
    Absent field, block skipped, existing secret untouched.
  • Omitted fields reset to serde defaults, so every schema-known field is set to match live state
    except the two being changed. preferShortUsername is true for bitborg-web and false for the
    dev client, both matching current live values.
  • Attributes outside the schema (notably oauth2_strict_redirect_uri) are not managed and stay as-is.

Hardening: the state file is now validated

Added validate: "python3 -m json.tool %s" to the template task. The template hand-assembles JSON
from Jinja loops, so an unbalanced brace is a realistic edit mistake — and it would replace a working
state file and surface as a confusing kanidm-provision parse error. Neither validate nor the
provisioning command runs under --check, so a dry-run could never catch it.

Verification

  • --syntax-check passes; ansible-lint roles/kanidm/ passes on the production profile.
  • --check --diff --tags kanidm against prod renders the new systems.oauth2 block with balanced
    braces, and the account-policy gate from #277 still reports
    matches the declared credential_type_minimum 'mfa'. ok=24 failed=0.
  • The provisioning run itself cannot be dry-run — it is a command, so it skips under --check.
    The rendered state file is verified; the mutations it will drive are not. Worth watching the apply
    output for Updating lines against bitborg-web and bitborg-web-dev and nothing else.

Post-apply checks

  1. kanidm system oauth2 get bitborg-web — one origin only.
  2. kanidm system oauth2 get bitborg-web-dev — scope map on forgejo_admins.
  3. Portal SSO login at www.gitborg.se still works (the client secret should be untouched).
  4. A non-admin user no longer sees "bitborg portal (dev)".
  5. Local pnpm dev OIDC login still works.
Part of #275, and the more urgent half. Two live misconfigurations, fixed declaratively. ## 1. The production client accepted a localhost redirect ``` name: bitborg-web ← PRODUCTION oauth2_rs_origin: https://localhost:4321/auth/callback oauth2_rs_origin: https://www.gitborg.se/auth/callback oauth2_rs_scope_map: forgejo_users@auth.gitborg.se oauth2_strict_redirect_uri: true ``` `oauth2_strict_redirect_uri` only requires the redirect to match **some** registered origin, and localhost was registered — so it offered no protection here. Combined with a scope map covering `forgejo_users` (every person on the service), an authorization code intended for the portal could be redirected to a listener on a user's own machine. Declaring only the real callback removes the localhost entry: `originUrl` **replaces** the attribute rather than appending, verified in upstream `main.rs`. ## 2. The dev client was visible to every user `bitborg-web-dev` was also scope-mapped to `forgejo_users`, and Kanidm lists an application on the landing page of anyone in a scope-mapped group — which is exactly the reported symptom ("bitborg portal (dev)" showing to non-admins). Remapped to `forgejo_admins`. Verified against the live server with the read-only reconciler token that the group resolves to `['supernaut@auth.gitborg.se']`, so local development keeps working and nobody else sees the client. **Kept rather than deleted, deliberately.** Deleting it would push local OIDC testing back onto the production client — which is plausibly how the localhost origin got there in the first place. Fixing the visibility while keeping a legitimate dev path is the durable outcome. ## What is NOT declared, and why `forgejo` and `grafana` are omitted. The forgejo client carries **seven** `oauth2_rs_claim_map` entries driving entitlement claims (`ent_lfs`, `ent_runners_*`) and the `forgejo_role` admin claim. Declaring a client replaces its attributes, and `removeOrphanedClaimMaps` defaults **true**, so a partial declaration would strip those claim maps and break entitlement mapping and admin assignment. `--no-auto-remove` means an undeclared client is left untouched rather than deleted, so omitting them is safe. Bringing them under declaration needs their claim maps reproduced exactly — its own change, with its own dry-run. ## Checks made before touching anything near SSO - **Client secrets survive.** `basicSecretFile` is never emitted, and kanidm-provision only rewrites the secret when that field is present: ```rust if let Some(secret_file) = &oauth2.basic_secret_file { … } ``` Absent field, block skipped, existing secret untouched. - **Omitted fields reset to serde defaults**, so every schema-known field is set to match live state except the two being changed. `preferShortUsername` is `true` for `bitborg-web` and `false` for the dev client, both matching current live values. - Attributes outside the schema (notably `oauth2_strict_redirect_uri`) are not managed and stay as-is. ## Hardening: the state file is now validated Added `validate: "python3 -m json.tool %s"` to the template task. The template hand-assembles JSON from Jinja loops, so an unbalanced brace is a realistic edit mistake — and it would replace a working state file and surface as a confusing kanidm-provision parse error. Neither `validate` nor the provisioning command runs under `--check`, so a dry-run could never catch it. ## Verification - `--syntax-check` passes; `ansible-lint roles/kanidm/` passes on the production profile. - `--check --diff --tags kanidm` against prod renders the new `systems.oauth2` block with balanced braces, and the account-policy gate from #277 still reports `matches the declared credential_type_minimum 'mfa'`. `ok=24 failed=0`. - **The provisioning run itself cannot be dry-run** — it is a `command`, so it skips under `--check`. The rendered state file is verified; the mutations it will drive are not. Worth watching the apply output for `Updating` lines against `bitborg-web` and `bitborg-web-dev` and nothing else. ## Post-apply checks 1. `kanidm system oauth2 get bitborg-web` — one origin only. 2. `kanidm system oauth2 get bitborg-web-dev` — scope map on `forgejo_admins`. 3. Portal SSO login at www.gitborg.se still works (the client secret should be untouched). 4. A non-admin user no longer sees "bitborg portal (dev)". 5. Local `pnpm dev` OIDC login still works.
supernaut lade till 1 incheckning 2026-07-31 10:29:19 +00:00
Part of #275.

The production `gitborg-web` client carried TWO registered origins:

  oauth2_rs_origin: https://localhost:4321/auth/callback
  oauth2_rs_origin: https://www.gitborg.se/auth/callback

oauth2_strict_redirect_uri only requires a redirect to match SOME registered
origin, so an authorization code intended for the portal could be redirected to a
listener on a user's own machine. The client is scope-mapped to forgejo_users, so
every person on the service could consent to it. Declaring only the real callback
removes the localhost entry, because originUrl replaces the attribute rather than
appending to it.

The separate `gitborg-web-dev` client was scope-mapped to forgejo_users too, which
is why "gitborg portal (dev)" appeared on every user's Kanidm landing page —
Kanidm lists an application for anyone in a scope-mapped group. Remapped to
forgejo_admins (currently just supernaut, verified against the live server with
the read-only reconciler token), so local development keeps working while nobody
else sees it.

Kept rather than deleted, deliberately: removing it would push local OIDC testing
back onto the production client, which is how the localhost origin got there.

`forgejo` and `grafana` are deliberately NOT declared. The forgejo client carries
seven claim maps driving entitlement claims and the admin role; declaring a client
replaces its attributes and removeOrphanedClaimMaps defaults true, so a partial
declaration would strip them and break entitlement mapping. --no-auto-remove means
an undeclared client is left untouched, not deleted, so omitting them is safe.

Client secrets are never declared: basicSecretFile is omitted, and kanidm-provision
only rewrites the secret when that field is present. Verified in upstream main.rs
before touching anything near SSO.

Every field emitted IS in kanidm-provision's schema, so an omitted-but-known field
resets to its serde default — hence preferShortUsername is set true for gitborg-web
(matching live) and left false for the dev client (matching live). This change
alters origins and scope maps only.

Also adds `validate: python3 -m json.tool` to the state-file template task. The
template hand-assembles JSON from Jinja loops, a malformed render would otherwise
replace a good state file and surface as a confusing provisioning parse error, and
neither validate nor the provisioning command runs under --check — so a dry-run
cannot catch it.
supernaut sammanfogade incheckning e3cc497fdb till main 2026-07-31 10:40:21 +00:00
supernaut tog bort grenen fix/275-oauth2-client-declarations 2026-07-31 10:40:21 +00:00
supernaut refererade denna ändringsförfrågan från en incheckning 2026-07-31 11:14:59 +00:00
supernaut refererade denna ändringsförfrågan från en incheckning 2026-07-31 11:39:40 +00:00
supernaut refererade denna ändringsförfrågan från en incheckning 2026-07-31 11:44:30 +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!278
Ingen beskrivning angiven.