Repository navigation
Refuse an OIDC exchange when two trust policies give one token different authority - #3811
dawsontoth wants to merge 5 commits into
Conversation
…ent authority The exchange took the first matching policy by id, so two enabled policies matching one run left which user it acted as, and with which operations, to the ids. enable_previews refuses to write such a pair, but only on one node: an enable on another node, or a hand add_oidc_trust, can still produce it, and hdb_oidc_trust has no cross-node create-only write, nor could one exclude overlap between different ids. So the exchange now checks every enabled policy and refuses a token two of them give different users or operations, before the token is spent. Policies that agree still answer with the first by id. An absent scope and an empty one stay distinct, and arrays compare as sets. The audit record and the oidc-trust log name both policies. A cluster that relied on id order between such policies starts refusing those exchanges, including two harper deploy setup runs that share a workflow, until one is disabled or their claims differ. Fixes #3608 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request updates the OIDC token exchange mechanism to refuse token exchanges when multiple matching trust policies resolve to different users or operations (different authorities), rather than simply selecting the first matching policy by ID. It introduces sameAuthority and sameOperationScope helpers to compare policies, updates reconciliation logic in GitHub Actions setup and preview operations, updates design documentation, and adds comprehensive unit tests. The review feedback highlights three key areas for improvement: downgrading verification warnings to debug level once a match is found to prevent log pollution, updating rivalPolicies in previewOperations.ts to ignore overlapping policies that share the same authority, and capturing caught error messages as invalid_reason in listOidcTrust to improve diagnostic visibility for administrators.
| continue; | ||
| } | ||
|
|
||
| if (matched && !audiences.includes(policy.audience)) continue; |
There was a problem hiding this comment.
While skipping non-matching audiences when matched is set is a great optimization, any subsequent policies that do have matching audiences will still trigger verifyIdentityToken and log warnings if they fail verification. Since a successful match has already been found, these verification failures on other policies are expected (as they simply don't match) and should not pollute the server logs with warnings.
Consider suppressing or downgrading these warnings to debug level when matched is already set.
There was a problem hiding this comment.
Not changed: the audience skip above means verification after a match only runs for an audience the token names, so a failure here is operational (a JWKS outage, say) and leaves that policy's conflict unchecked. That should stay a warning.
🤖 Addressed by Claude Code
| `Trust policy "${rival.id}" would also match the runs "${policy.id}" is for, and the exchange refuses a ` + | ||
| `token two policies give different users or operations. Give this application its own workflow file (workflow=).`, |
There was a problem hiding this comment.
rivalPolicies flags a conflict for any overlapping policy, even if they have the same authority (same user and operations). However, the exchange actually accepts overlapping policies that agree on user and operations (taking the first by ID). The error message itself claims that the exchange refuses them because they give different users or operations, which is incorrect when they actually agree.
To fix this, you should update the policies.find call in rivalPolicies (around line 197) to include !sameAuthority(candidate, policy) and import sameAuthority from ../security/ciIdentity.ts:
const rival = policies.find(
candidate => !ours.has(candidate?.id) && candidate?.enabled !== false && policiesOverlap(candidate, policy) && !sameAuthority(candidate, policy)
);There was a problem hiding this comment.
Fixed in 8939e5d: rivalPolicies now also requires !sameAuthority, so a policy that agrees on user and operations no longer blocks enable. A test covers it.
🤖 Addressed by Claude Code
| } catch { | ||
| return false; | ||
| } |
There was a problem hiding this comment.
In listOidcTrust, when a policy is not exchangeable because of specificity or profile errors (e.g., missing workflow/ref gate or unknown issuer), the try-catch block in the honored filter catches the error and returns false, but it does not set policy.invalid_reason. This leaves the policy marked as exchangeable: false in the listing but with no explanation to the administrator as to why it is invalid.
Setting policy.invalid_reason to the caught error's message will provide crucial diagnostic visibility.
} catch (error) {
policy.invalid_reason = (error as Error).message;
return false;
}There was a problem hiding this comment.
Fixed in 8939e5d: the caught specificity error now lands in invalid_reason (unless one is already set). A test covers a too-broad policy.
🤖 Addressed by Claude Code
…road policy is ignored enable_previews refused any overlapping policy while its message said the exchange refuses them; one that agrees on user and operations is accepted, so it no longer blocks. list_oidc_trust now reports the specificity error behind exchangeable: false. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ronment Two components with the same workflow and environment are now refused before anything is written, since the exchange would refuse those runs. The test covers that refusal too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ows the exchange ignores harper deploy setup compared only its first listing, so a conflicting policy created while it ran was missed; it now checks again beside the same-id check, as enable_previews does. enable_previews no longer counts a row the exchange would not honor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
⊙ Problem
When two enabled OIDC trust policies matched one identity token, the exchange took the first by id, so which user a CI run acted as was up to the ids.
enable_previewsrefuses to write such a pair, but only on its own node.Fixes #3608. Part of #3042.
🤔 Refuse at the exchange, or prevent the pair from being written?
Refuse at the exchange. No write path can exclude a pair written on another node, and the exchange is the one place every policy passes through, whoever wrote it.
💡 Solution
The exchange checks every enabled policy, and refuses a token two of them give a different user or different operations, before the token is spent. Policies that agree still answer with the first by id.
list_oidc_trustnames each such pair ininvalid_reason, andharper deploy setuprefuses to write one.🤔 A cluster that relied on id order starts refusing those runs
Two
harper deploy setupruns sharing a workflow, environment and branch already wrote such a pair, and id order made one work. After upgrade both refuse until one policy is dropped;list_oidc_trustnames them, and this needs a release note. The guarantee covers the policies the exchanging node sees.🤔 Known limits of the listing
list_oidc_trustcan flag a pair a provider veto keeps apart, such as a policy with noevent_namebeside one pinned topull_request_target, and setup refuses that pair too. It can also miss a conflict only a token with two audiences hits, though the exchange still refuses that token. Making each provider say whether two policies can co-match would close the first.Docs this needs
list_oidc_trustgainsexchangeable, andinvalid_reasonnow covers conflicting pairs.list_oidc_trustbefore upgrading, and fix any pair it names; upgrade every node, since an older node still takes the first by id.⚖️ Alternatives
🔧 Changes
security/authn/oidc/tokenExchange.ts:findMatchingPolicyscans past the first match and returns aconflict, andexchangeOidcTokenrefuses it before the replay record, with both ids in the audit.security/ciIdentity.ts: sameAuthority, used by the exchange, the listing andpolicyDifferences.security/authn/oidc/trustPolicyOperations.ts:list_oidc_trustmarks each row exchangeable and names a rival ininvalid_reason.bin/deploySetupGithubActions.ts:planReconcilerefuses a rival before writing.✅ Verification
unitTests/security/authn/oidc/tokenExchange.test.js: conflicting users and operations in either id order, a conflict behind two that agree, no token spent, a retry after the conflict is removed, and a token with two audiences. Six of these fail onmain's code.enable_previewsignores.unitTests/securitypasses except twojsLoadertests that fail onmain;lint:required, typecheck and prettier pass.🤖 Generated with Claude Code
Release target: v5.4 — closes a gap in unreleased 5.4 preview work from #3617
Related PRs: #3617 overlaps (merged; this closes a window it documented), #3807 overlaps (open; both edit bin/deploySetupGithubActions.ts and components/previewOperations.ts), #3809 overlaps (open; both edit bin/deploySetupGithubActions.ts), #3812 overlaps (open; both edit components/previewOperations.ts)
Complexity: simple
Late Grok findings
6 surviving (highest minor) — Grok reviewed e56bc3a
security/DESIGN.md:31— Item 5 sits between item 4 and item 4's indented continuation paragraphs (enforcement surface,sqlscope). Those paragraphs now read as part of "Policy ids never choose authority"security/authn/oidc/tokenExchange.ts:156— The scan no longer stops at the first match. Every successful exchange now verifies the remaining audiences for the issuer, andrejectToken(security/authn/oidc/identityToken.ts:59) plus the catch emit "Rejecting identity token" / "Verification failed" WARNs alongside a granted tokensecurity/authn/oidc/tokenExchange.ts:257— Overlapping enabled policies that worked before this change now 401. Only preview setup checks for this (components/previewOperations.ts:194-206);harper deploy setupandlist_oidc_trustdon't, so operators only find out from the server logsecurity/authn/oidc/tokenExchange.ts:121— Added comments restate the matching logic: the first-match/conflict walkthrough in thefindMatchingPolicydocblock, and the exchange narration atsecurity/ciIdentity.ts:142-145unitTests/security/authn/oidc/tokenExchange.test.js:439— The operations-conflict cases never checkuseTable.mock.rows.size. The shared conflict branch runs beforerecordTokenUse(tokenExchange.ts:257vs:274), and the users case at:436already proves itsecurity/ciIdentity.ts:38— Setup'ssameSetand the exchange'ssameAuthoritycompare operations by different rules, so the two can drift (deferred, scope-predicate-divergence)Not standing: 1 separate-issue. Grok ran 13m (13m from launch to report); the fold adjudication (
cursor-grok@e56bc3a1f92b-1922aaca, claude) took 1m.Late Grok findings
8 surviving (highest minor) — Grok reviewed 35514c1
security/authn/oidc/trustPolicyOperations.ts:337— Thelist_oidc_trustrival check does not match the exchange's matching rules, so it gets conflicts wrong in both directions:bin/deploySetupGithubActions.ts:753— Setup's preflight (planIdentityReconcile) never checks for overlapping policies. Setup writes the new policy at :760 and only finds the conflict in the verify step at :767. By then a sibling policy with the same repo and workflow, but a different user, has already lost its CI exchange, and the newcomponents/previewOperations.ts:197—rivalPoliciesreturns 409 for any enabled overlapping policy, including one with the same user and operations, which the exchange accepts (tokenExchange.ts:162-163). The message gives the different-authority refusal as the reason, which is wrong for that case. The suggested fix (workflow=) doesecurity/authn/oidc/tokenExchange.ts:121—findMatchingPolicydecodes the token a second time, though :227 already decoded it. Exchange runs once per CI run, so the cost is small. Scope: in-scope — Convergence-Class: previously-adjudicatedsecurity/ciIdentity.ts:42—sameOperationScopeis exported, but nothing outsideciIdentity.tsuses it (only :51 and :118). Scope: in-scope — Convergence-Class: previously-adjudicatedsecurity/ciIdentity.ts:49— Several added comments narrate the code: thesameAuthorityJSDoc, the pair-scan comment attrustPolicyOperations.ts:336, and the comment attokenExchange.ts:120. The invariant comment atciIdentity.ts:41is worth keeping. Scope: in-scope — Convergence-Class: previously-adjudicatedunitTests/security/authn/oidc/tokenExchange.test.js:439— The operations-conflict cases don't check that the token was left unspent. Disputed: :436 already covers the shared conflict branch, which runs beforerecordTokenUse. Scope: in-scope — Convergence-Class: previously-adjudicatedunitTests/security/ciIdentity.test.js:4—sameAuthorityandsameOperationScopehave no direct unit tests. Disputed:tokenExchange.test.js:452-466already covers set comparison, duplicates, and empty-versus-absent scope. Scope: in-scope — Convergence-Class: previously-adjudicatedNot standing: none. Grok ran 11m (11m from launch to report); the fold adjudication (
cursor-grok@35514c1fc2af-564cec1e, claude) took 2m.Deferred findings
737c2735e1ed9b58nit concurrency —bin/deploySetupGithubActions.ts— pre-write rival recheck cannot exclude a conflicting policy written on another node — NOT FILEDReview-Coverage: authored=claude; ran=gemini,cursor-composer,codex,cursor-grok; late=cursor-grok:adjudicated; adjudicated=domain; declined=muse; rounds=5; full=3 @ ae4f404
Review-Attention: study ~15m (sensitive: tokenExchange.ts, trustPolicyOperations.ts +1; decisions: refuse-on-ambiguity, authority-is-user-identity, dead-user-policies-still-rival, exchangeable-field, static-overlap-not-exchange-parity, do-less-alternative) @ ae4f404