Skip to content

feat(openbao): reconcile the OIDC client after ZITADEL rotates it - #2078

Merged
Smana merged 23 commits into
mainfrom
feat/openbao-oidc-rotation
Sep 22, 2026
Merged

Smana merged 23 commits into
mainfrom
feat/openbao-oidc-rotation

Conversation

@Smana

@Smana Smana commented Sep 22, 2026

Copy link
Copy Markdown
Owner

🔍 feat

📝 Summary

Closes #2045. Every rebuild gives ZITADEL a new OpenBao OIDC client. OpenBao's auth/oidc kept the previous one, so SSO failed with App.NotFound until someone re-applied the management stack by hand. The deploy now rotates the client itself (option B), and a new stage5 job halts the run if OpenBao, the secret store and ZITADEL still disagree (option E).

Important

Do not merge before both owner boxes below are ticked. Only a live platform proves this. Every gate here is offline.

📋 Design

Design: 2026-09-21-openbao-oidc-client-rotation-design.md
Plan: 2026-09-21-openbao-oidc-client-rotation-plan.md

The whole design: B (the script rotates the client) plus E (the stage5 check), halting on mismatch. The ownership change is recorded as an amendment to ADR-0034.

🎯 Changes

  • The sync reconciles OpenBao's OIDC config and role
  • Terraform ignores the three rotated fields after creation
  • New stage5 check: consistent, broken, or can't tell
  • The root token reaches curl through a -K file, never argv
  • Docs: first-deploy halt, recovery commands, ADR amendment

📊 Flow

flowchart LR
    zitadel["ZITADEL (new client each rebuild)"] -->|"stage4: register"| sync["zitadel-oidc-clients.sh sync"]:::modified
    sync -->|"store client"| sm["Secrets Manager openbao-oidc"]
    sync -->|"reconcile id, secret, audience"| bao["OpenBao auth/oidc"]:::modified
    tf["management stack (oidc.tf)"]:::modified -->|"creates mount, ignores 3 fields"| bao
    check["stage5 openbao-oidc-check.sh"]:::new -->|"compare"| sm
    check -->|"compare + auth_url probe"| bao
    check -->|"first hop: 302 known, 400 unknown"| zitadel
    classDef new fill:#1e3a8a,stroke:#3b82f6,stroke-width:3px,color:#fff
    classDef modified fill:#c2410c,stroke:#f97316,stroke-width:3px,color:#fff
Loading

🗂️ Files

File Type Summary
scripts/zitadel-oidc-clients.sh modified reconcile_openbao_oidc, --openbao-* flags, wired into cmd_sync
scripts/lib/openbao-api.sh new Token-file writer and openbao_req (--fail-with-body, never -k)
scripts/openbao-oidc-check.sh new stage5 check: exit 0 / 1 / 2, bounded retry on the liveness probe
opentofu/aws/openbao/management/oidc.tf modified ignore_changes on client id, secret and bound_audiences, with the hazard noted below
opentofu/aws/eks/init/workflows.tm.hcl modified stage4 passes --openbao-* on aws-0's sync only; new stage5-verify-openbao-oidc
opentofu/aws/openbao/management/variables.tfvars modified Comment: the first bootstrap needs one management apply
scripts/ci/tests/test-zitadel-oidc-clients-openbao.sh, test-openbao-oidc-check.sh new 228 and 81 checks, covering the contract guards, the no-argv proof and every exit code
scripts/ci/tests/test-zitadel-oidc-clients-{convergence,redirects}.sh modified Stub the new reconcile step
website/content/docs/… (openbao, sso, get-started/aws, commands, ADR-0034) modified Recovery commands that run as written; the first-deploy halt; the five EKS jobs

⚖️ Rulings for the owner

You decided Q1 (B), Q2 (halt) and Q7 (after #2061). I ruled on the rest from the planner's recommendations. Overturn any of them here:

# Ruling Cost if wrong
Q3 The first bootstrap stays one manual management apply. The first deploy on a new platform (every fork) ends red at stage5 on purpose, then you apply management and resume. It's documented in get-started/aws and openbao.md § OIDC client rotation Forks get a two-step first deploy. Making that one state exit 0 with an [action] line is a 3-line change
Q4 The script reads the root token from Secrets Manager, as openbao-adopt-jwt-mount.sh already does. Minting a narrow token needs root anyway None beyond today's root-token use
Q5 An ADR-0034 amendment, not a new ADR. This is an ownership decision, not a technology choice A reader looking for a standalone record finds it inside ADR-0034
Q6 Promoting a fresh ZITADEL seed is a follow-up (design risk R6) R6's pre-existing invalid_client hazard stays until then
Q8 The deploy never uses terramate script run -j. stage5 depends on stage4 having run Confirm this is true in practice
T0 I skipped the live spike, and I read the codes from source instead. In zitadel/oidc, an unknown client gets a 400 (ErrInvalidRequestRedirectURI) and a known one gets a 302. A 302 that redirects back to the redirect_uri with ?error= counts as a failure, and any other code is exit 2, which still fails the job A live ZITADEL answering differently fails stage5 loudly, never silently. T11 confirms it
R3 The first-bootstrap halt stays (see Q3). It's documented and not silenced Same as Q3
R4 The I3 hazard gets a comment now. The _wo fix is a follow-up (below) The hazard stays latent until then

⚠️ Known hazard (I3)

ignore_changes stops Terraform from planning a secret change, but terraform-provider-vault's update of vault_jwt_auth_backend re-sends the full config, with oidc_client_secret taken from state. (Checked against v5.12.0, the version this stack pins: jwtAuthBackendUpdate sends every config field for which GetOkExists || HasChange is true.) So any future in-place change to that resource, such as a tune setting or a scope, restores the creation-time secret. SSO then fails with invalid_client, and stage5's first-hop probe can't see that. Recovery: re-run the sync with --openbao-* (openbao.md § OIDC client rotation). The real fix is oidc_client_secret_wo with a static version, which v5.12.0 already ships. It's a follow-up because it needs a live apply to validate.

🧪 Evidence (offline)

Branch rebased on origin/main (0 behind). All results at cf07ef2e:

Gate Result
task ci:test (task 3.53.1) rc=0: 25 passed, 1 skipped (vector not installed), 0 failed
test-openbao-oidc-check.sh / test-zitadel-oidc-clients-openbao.sh rc=0: 81 ok / 228 ok
test-no-secret-argv.sh rc=0
test-terramate-script-refs.sh rc=0: 91 refs, 0 failed
test-script-paths.sh rc=0: 11 roots, 25 sources, 20 subjects, 0 failed
task ci:links / ci:doc-paths / ci:doc-claims rc=0 (30 claims, 51 page checks)
terramate fmt --check, tofu fmt -check, shellcheck -x -S warning rc=0

The fix waves ran mutation checks, and the suites caught every mutant (16 in the last wave). Examples: an unknown flag in stage5's call, and GCP_GKE_INIT_WORKFLOWS pointing at a missing file. Never run live: the 302/400 codes, the 5 × 10s probe retry, and the redirect-back-with-error verdict. T11 covers them.

✅ Merge gate (owner)

  • T11: live check, no rebuild. On the running platform: a dry sync prints [ok] or a diff; scripts/openbao-oidc-check.sh exits 0 (or sync --apply with the --openbao-* flags heals it); tofu plan -detailed-exitcode on opentofu/aws/openbao/management exits 0.
  • T12: a real rebuild. Don't promote a new seed first. Preflight: fix(openbao): keep the ZITADEL OIDC login across rebuilds #2011 and feat(secrets): move ZITADEL onto OpenBao, and fix the break-glass login it depends on #2017 are present, the tfvars key line is active, and the break-glass login lists secrets-admin. Record BEFORE (bao read -field=oidc_client_id auth/oidc/config), then run terramate script run --reverse destroy, verify against the cloud, and deploy with no manual re-apply. Proof: BAO == AUD == SM == NEW, where NEW is the id on stage4's [created] openbao line and differs from BEFORE. bao login -method=oidc works, and the management tofu plan exits 0.

T11 and T12 can share a cluster session with #2077's preview.

🔀 Sequencing with #2077

Whichever PR merges second rebases. If #2077 (scripts/provision/) lands first:

  • openbao-oidc-check.sh moves to provision/;
  • the suites' subject paths become ../../provision/;
  • lib/openbao-api.sh is sourced as ../lib/;
  • the zitadel-oidc-clients.sh edits carry across the rename.

Both path gates (test-script-paths.sh, test-terramate-script-refs.sh) fail on a missed spot.

📌 Follow-ups (not in this PR)

  • oidc_client_secret_wo plus a static _wo_version on vault_jwt_auth_backend (I3).
  • A fresh ZITADEL seed after T12 (Q6 / R6).
  • store_exists treats a read error as "absent" (M4). The check can then print "will DESTROY the mount" on a transient describe failure, and the reconcile reports [skip] instead of [FAILED] for a key it just wrote.
  • No curl timeouts in openbao_req or on the first hop (M5). An OpenBao #3411-style deadlock would hang stage4 and stage5. This matches the repo-wide convention today; the fix is --max-time 30.
  • Drift detect no longer sees the client id, by design, and stage5 runs only on deploy (M8). The follow-up is to run the check in the drift job.

🏷️ Labels

security, infrastructure

Smana and others added 23 commits September 22, 2026 07:46
…2045)

Option B + E, approved by the owner on 2026-09-21: the ZITADEL sync
rotates the three fields that change on every rebuild, oidc.tf ignores
them, and a stage5 check halts the deploy on a mismatch.
scripts/lib/openbao-api.sh centralises the curl -K root-token pattern
openbao-adopt-jwt-mount.sh inlined, for reconcile_openbao_oidc (#2045) to
reuse rather than copy a third time.

The new contract-guard suite pins two facts the rotation depends on: the
CONSUMERS "openbao" store key matches variables.tfvars' uncommented
openbao_oidc_secret_id (guards the #2011 regression shape), and oidc.tf's
UI callback path is /ui/vault/auth/oidc/oidc/callback. Task 2 extends the
same file with the reconcile itself.
The callback guard used grep -qF, a plain substring match satisfied by the
literal sitting after a "#" -- so commenting out oidc.tf's whole redirect_uris
entry still reported "pins the UI callback path". Anchored it to an active
list element (leading quote after indentation, trailing quote plus optional
comma at end of line), the same shape the tfvars guard next to it already
required for #2011.

Also asserts the CONSUMERS side: the header claims this guards an agreement
between oidc.tf and the "openbao" CONSUMERS entry, but nothing read the
CONSUMERS callback before now -- only its store key.
Every rebuild creates a new ZITADEL app for OpenBao, while auth/oidc keeps
the old client, so SSO breaks (#2045). Two functions in
zitadel-oidc-clients.sh, not yet called from cmd_sync (Task 3):

- openbao_oidc_config_payload: the current config minus `status`, plus the
  new id and secret, all on stdin. It refuses a non-empty provider_config
  and an empty id or secret.
- reconcile_openbao_oidc: design section 2, steps 1-10. A no-op without a
  URL, a store key or an oidc/ mount. It waits for the store to agree with
  ZITADEL, then writes the config and the role independently and only when
  they differ. A dry run only reads. The config write is retried on the
  discovery error only, and when it fails the role is not written. It reads
  both back. The EXIT trap is private to a subshell.

openbao-api.sh, from Task 1's review: `|| return 1` on both assignments,
`local -; set +x` in openbao_token_config_write, and --fail-with-body in
openbao_req, because -f discards the error body that tells a discovery
failure from any other.

The suite now stubs curl, jq and sleep on PATH. The curl stub is a small
fake OpenBao. jq's argv is logged, which proves the secret never reaches it.
New guards: printf is only ever the builtin, and the POST body reaches
curl's stdin.
The mount check read an empty 200 body, `null` or `{}` as "no oidc/ mount"
and skipped with exit 0. That reports a stale client as a first bootstrap.
Now only a real `.data` map without oidc/ skips; anything else is [FAILED ].

Review minors, folded in:
- Under --apply, an empty client id or store key is [FAILED ], a wiring
  bug; a dry run still skips.
- When the config was written but the role write failed, the message says
  the config has already moved and that re-running sync --apply finishes
  the job. The payload's refusals carry the [FAILED ] marker.
- Four role fields reset when a write omits them (role_type,
  bound_claims_type, callback_mode, oidc_disable_confirmation). The comment
  and design fact 7 now say so. role_type is sent and oidc.tf sets none of
  the other three, so behaviour is unchanged.
- Suite: the printf-builtin guard also covers the lifted reconcile. The fake
  resets those four fields on a role write, BAO_CONFIG carries
  override_allowed_server_names, and the discovery error is the server's
  exact message.
Adds --openbao-url/--openbao-root-token-secret/--openbao-ca-file (a URL
requires the other two, and the CA file must exist), and calls
reconcile_openbao_oidc once after the consumer loop with the "openbao"
consumer's store key and client id, captured on both the existing-app and
the create path. A reconcile failure exits 1 after the run summary prints.
Terraform creates the oidc/ mount, but oidc_client_id, oidc_client_secret
and bound_audiences churn on every ZITADEL rebuild and the vault provider
never refreshes the secret. Without ignore_changes, the next management
apply replays OpenBao's discovery check while ZITADEL is still down
mid-rebuild and fails (design facts 6, 8). zitadel-oidc-clients.sh's
reconcile_openbao_oidc now owns rotating those three fields after
Terraform's initial create, following the workforce-identity precedent.
Adds scripts/openbao-oidc-check.sh (design E, #2045): a post-deploy check
that verifies OpenBao's auth/oidc config and role agree with the
"openbao-oidc" secret store entry, then probes ZITADEL's authorize
endpoint directly to catch a stale client id that happens to match a
stale store copy (design risk R6). A later task wires it as a stage5
deploy job that halts on failure.

Exit 0 means consistent or not bootstrapped; exit 1 is a definite,
named problem; exit 2 means it cannot tell (OpenBao unreachable, the
root token unreadable, or a first-hop HTTP code other than 302/400).
T0, the live spike that would confirm 302/400 against a real ZITADEL,
never ran, so those two codes are unverified until the first live run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xuNsQcGpQPcEH8m1h3rDD
…nches

Review of 80c9d2b found the auth_url POST and the authorize URL's first
hop each have a "cannot reach" branch (openbao-oidc-check.sh:209-212,
:225-228) that no test case exercised, even though the curl stub already
had AUTHORIZE_CURL_FAIL and AUTH_URL_ERROR wired for exactly this.
Mutating either exit 2 to exit 0 left the suite green.

Adds one test per branch, plus a body-content assertion on the auth_url
POST (role and redirect_uri) and two new cases for the auth/oidc/config
and auth/oidc/role/default read-failure branches. Test-only; the
production script is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xuNsQcGpQPcEH8m1h3rDD
…eploy

stage4-oidc-clients now carries the cloud gate, fetches OpenBao's CA into a
temp dir, and hands --openbao-url/--openbao-root-token-secret/--openbao-ca-file
to aws-0's own sync only. The gcp-0 consumer call and gcp/gke/init pass none.
A failed CA fetch warns and registers without reconciling.

A new stage5-verify-openbao-oidc job runs scripts/openbao-oidc-check.sh after
stage4. It does not swallow the exit code: exit 1 (a definite mismatch) and
exit 2 (cannot tell) both fail the job and halt the run, so every later stack
(gcp/gke/init, aws/eks/configure, ...) is skipped. That is the owner's decision
in the design, section 7.

Contract guards in test-zitadel-oidc-clients-openbao.sh pin who passes the
flags, that stage5 follows stage4 in script "deploy", and that the check's
exit code reaches Terramate.

Refs #2045
Terraform creates the oidc/ mount once and now ignores the three fields
zitadel-oidc-clients.sh rotates on every deploy; operators need to know
that a re-apply no longer fixes a stale client, and what does.
The old guard only rejected a literal `||` on the check call's own
line, which an `if ! check; then echo; fi` wrapper or a `set +e` plus
a trailing command both dodge without ever writing one. Anchor on the
call being the heredoc's last statement instead, and add a guard for
stage5's primary_cloud skip, each proved against a mutated temp copy
of the real file.

Also reword two comments that named an incidental stack run order
(gcp/gke/init now runs after aws/eks/init, not before) instead of the
missing `after` edge that actually decides it, move stage5's context
echo above its CA fetch, and soften a stage4 comment that overclaimed
what stage5 reports on a CA-fetch failure.
check_call_last() proved the call was the heredoc's last statement but
only matched a PREFIX of that line, so stage4's own swallow style —
`check ... || echo "[warn] ..."` appended after the call's final
argument, on the same line — still matched and passed. Require the
whole last logical line to carry none of `||`, `&&`, `;` or `|`.
OpenBao v2.6.2 answers auth_url with 200 and an EMPTY auth_url when it
cannot fetch ZITADEL's discovery document -- ZITADEL still starting
mid-rebuild -- and gives the same answer for a redirect_uri the role does
not allow. The check mapped that to a definite exit 1, and any transient
first-hop error to exit 2, with no retry; stage5 halts the deploy on both.

The auth_url POST and the first hop now get 5 attempts,
OPENBAO_CHECK_RETRY_SLEEP (default 10s) apart. A 302 or a 400 is definite
and ends the loop at once, and the id/audience mismatch before it is
never retried. An empty auth_url that persists is exit 2, naming both
causes, since the check cannot tell them apart.
A 400 on the authorize URL is ZITADEL's answer for an unknown client AND
for a known client asked for a redirect_uri it never registered, so the
message now names both. A 302 is not always acceptance: ZITADEL refuses a
request for a known client by redirecting straight back to the
redirect_uri with ?error=. The check now reads %{redirect_url} and treats
that as exit 1, printing only the error parameter -- the rest of the query
carries OpenBao's state.
zitadel-oidc-clients.sh refuses to start without IDP_URL and
PRIVATE_DOMAIN, and a wrong PRIVATE_DOMAIN rewrites every app's redirect
URIs -- yet openbao.md's recovery command set neither, nor --region, and
the check printed a partial `sync --apply` hint twice. The docs command
now carries every value, checked against config.tm.hcl and stage4's own
derivation, and the check's two fix lines point at that section instead
of restating it. The suite pins that the section still exists.

sso.md's step 1 had the same gap on aws-0: no --openbao-* flags, so a
re-run after a restore left OpenBao on a client ZITADEL had forgotten. An
OPENBAO variable carries them (empty on gcp-0), and IDP_URL /
PRIVATE_DOMAIN are now exported -- as plain assignments they never
reached the script in steps 1 and 4.
The management stack runs before ZITADEL has issued OpenBao's OIDC client,
so on a brand-new platform it cannot create the oidc/ mount and stage5
halts the first deploy -- by design (ruling R3), but the getting-started
page still promised "one command". Both it and the rotation section now
say the red run is expected, give the one management apply that creates
the mount, and how to resume: re-run the deploy, or
`TM_CLOUD=gcp terramate -C opentofu/gcp/gke/init script run deploy` for
the GCP stacks the halt skipped. TM_CLOUD=gcp is required there: without
it every GCP job gates itself to [skip].
…OIDC secret

ignore_changes only stops Terraform planning oidc_client_id and
oidc_client_secret. terraform-provider-vault's jwtAuthBackendUpdate still
re-sends the whole config, secret from state, on any other in-place
update to the mount -- quietly undoing the reconcile's rotation. The
lifecycle comment now says so, names the recovery (re-run the sync with
--openbao-*) and oidc_client_secret_wo as the follow-up fix.
… read

The guard asserting gcp/gke/init passes no --openbao-* flag is an absence
check: pointed at a missing file (GCP_GKE_INIT_WORKFLOWS=/nonexistent),
it read nothing and passed. It now requires the file to exist and to
still hold at least one zitadel-oidc-clients.sh sync.
The suite pinned only stage5's --redirect-uri. openbao-oidc-check.sh
exits 2 on an unknown argument and stage5 halts on 2, so a misspelt flag
would break every deploy and show only on a live run. Every --flag in
stage5's rendered check call must now be a case label in the check's
argument parser; an in-suite mutant (--root-token-secret) proves the
guard fails.
`( set -o errexit ...; cmd_sync ) || true` never honoured errexit: a
subshell on the left of `||` runs with it ignored, whatever it sets. That
hid a 127 -- reconcile_workforce_audience was never stubbed, and the test
passed only because the failed call fell through. The stub is now
explicit, and each run's exit status is captured and asserted.
The client-rotation note was a bare bullet under Negative consequences,
which reads as part of the original decision. It is now an
`> **Amendment (2026-09-22)**` block in ADR-0002's style, naming who owns
the client since #2045: oidc.tf's ignore_changes, the sync that rotates
it, and the stage5 job that halts the deploy on disagreement.
lastVerified moves to 2026-09-22.
The section still said "three jobs" and called stage3 "a final" job;
stage4-oidc-clients and stage5-verify-openbao-oidc run after it. The
section is now a table of all five, in order, one line each.
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Rendered manifest diff — this PR vs main (desired state)

No changes to the rendered desired state. ✅

@Smana
Smana merged commit 8eceefc into main Sep 22, 2026
12 checks passed
@Smana
Smana deleted the feat/openbao-oidc-rotation branch September 22, 2026 09:15
Smana added a commit that referenced this pull request Sep 22, 2026
#2078 landed first and added the stage5 check at scripts/ root. It runs at
deploy time, so it belongs with the other provision scripts. Also points
#2078's new references (stage4's CA fetch, stage5, both suites, the lib
sources and docs) at their provision/ paths.
Smana added a commit that referenced this pull request Sep 22, 2026
…2077)

* docs(superpowers): implementation plan for PR 3 of the scripts restructure

provision/: three risk-grouped moves, each simulated end to end (refs
88/0, paths 0 failed, suites 23/1/0). The openbao-snapshot symlink moves
with openbao-config.sh, which runs it as a sibling at apply time.

* refactor(scripts): move the terramate and tofu plumbing to scripts/provision/

* refactor(scripts): move OpenBao configuration and the secret store to scripts/provision/

* refactor(scripts): move ZITADEL provisioning to scripts/provision/

* feat(task): index the human-run provision scripts

* docs(scripts): say who actually runs the provision scripts

* refactor(scripts): move openbao-oidc-check.sh to scripts/provision/

#2078 landed first and added the stage5 check at scripts/ root. It runs at
deploy time, so it belongs with the other provision scripts. Also points
#2078's new references (stage4's CA fetch, stage5, both suites, the lib
sources and docs) at their provision/ paths.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OpenBao SSO breaks after every rebuild: management applies before ZITADEL exists, so oidc_client_id is the previous rebuild's

1 participant