Normalize opt-in multi-valued JWT claims for Cedar#5880
Conversation
JAORMX
left a comment
There was a problem hiding this comment.
LGTM — clean, well-scoped opt-in feature, and the security-critical parts hold up. ✅
Reviewed by a three-lens panel (security · OAuth/OIDC · Go/duplication/reuse) against 3067776, verified against the real code:
Security (no blockers): the claimset_ prefix is genuinely unspoofable — preprocessClaims prefixes every JWT claim with claim_ (core.go:624), and the only writer of bare claimset_* keys is operator-config-driven addMultiValuedClaimSets reading trusted resolved-token values, so an attacker claim named claimset_scp lands inertly at claim_claimset_scp. Cedar also fails closed on any policy error (core.go:509-512 returns false when diagnostic.Errors is non-empty), so type confusion / absent / nested / numeric claims all deny. Principal and context surfaces share one processedClaims map, so they can't diverge.
OAuth: the claimset_<name> Set + .contains/.containsAll model matches RFC 6749 §3.3 scope semantics exactly (order-independent, exact-element — the Mail.Read vs Mail.ReadWrite test proves no substring bleed). Shape convergence across Entra/Keycloak (scp/scope string) and Okta (scp array) is solid, and the explicit warning against groups/roles (elements may contain spaces) is the right RFC-vs-reality boundary.
Go/reuse: genuine extension, not a parallel path — it reuses the existing convertToCedarValueAtDepth/convertStringArrayToCedar pipeline for Set construction, respects copy-before-mutate, and is consistent with the #5713 nested-map claim work (nested elements deliberately skipped for flat scope claims). Tests are thorough and table-driven, including the empty-config byte-identical backward-compat assertion.
Non-blocking follow-ups (see inline on core.go:674):
- Migration warning: opting an array-shaped claim in flips its
claim_<name>from Set→String, which can turn a previously-erroring (fail-closed)likepolicy into a fail-open substring match. Worth a CHANGELOG/doc note to audit existinglikepolicies before enabling, and steering operators toclaimset_. - Doc vs behavior:
claim_<name>is documented "canonical unpadded" but the string-input path is a verbatim passthrough (thecase stringbranch is a no-op re-assign) — either canonicalize it (strings.Join(strings.Fields(val), " ")) or soften the doc. - Harden the invariant in code: extract
claim_/claimset_into shared consts (currently bare literals at:624,:706-710) so the collision-freedom relationship is enforced, not incidental. - Minor: add a note that the
extractGroups(:1118) vsclaimsettype-switches deliberately differ, so nobody "dedups" them; add a string→claimset multi-space test case.
None of these block — approving. CI still running (no failures); merge remains gated on it.
🤖 AI-assisted panel review via Claude Code (security · OAuth · Go/reuse). Line numbers against 3067776.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #5880 +/- ##
==========================================
+ Coverage 71.34% 71.40% +0.05%
==========================================
Files 693 693
Lines 70587 70679 +92
==========================================
+ Hits 50358 50466 +108
+ Misses 16596 16563 -33
- Partials 3633 3650 +17 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
rdimitrov
left a comment
There was a problem hiding this comment.
Automated review (medium effort, precision-focused). The design is sound and the test coverage is genuinely thorough — I confirmed the claimset_ spoofing defense holds, no caller input is mutated, no nil-map panic path, and the normalize→group-extraction→preprocess ordering is correct. Three findings below: one correctness edge (non-conformant tokens) and two minor cleanups. Comfortable with this PR once finding 1 is addressed or explicitly deemed out of scope.
OAuth scope claims arrive in different shapes across identity providers:
Entra and Keycloak emit them as one space-delimited string, while Okta
emits a JSON array. The Cedar authorizer maps a string to a Cedar String
and an array to a Set, and no single policy expression matches element
membership across both shapes — a type mismatch errors and the policy is
silently ignored, so a policy authored for one IdP fails closed for
another.
Add an opt-in MultiValuedClaims config field naming the JWT claims to
normalize. For each listed claim, the authorizer exposes two forms:
- claim_<name> is normalized to a space-delimited string (arrays are
joined, strings pass through unchanged) so existing string-membership
policies match uniformly across IdPs; and
- a companion Cedar Set is exposed as claimset_<name> for exact-element
membership via contains/containsAll/containsAny.
Both surface on the principal entity and the request context. The
claimset_ prefix is reserved and cannot be shadowed by a JWT claim, since
all claims are prefixed with claim_. The field is opt-in: with it empty,
output is byte-identical to before, so existing deployments are unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3067776 to
81090d1
Compare
JAORMX
left a comment
There was a problem hiding this comment.
Re-approving on 81090d1 — this revision addresses the feedback from both this pass and @rdimitrov's, and I've verified each:
- Non-string array elements no longer produce spurious tokens (@rdimitrov's parity finding):
multiValuedTokensnow keeps only string elements and skips non-strings, instead offmt.Sprint-ing them into"1e+06"/"<nil>". Tests updated to match (non_string_elements_skippednow expects"a"/["a"]). claim_/claimset_extracted to constants — the collision-freedom invariant is now enforced by shared consts rather than incidental literals.- Doc corrected from "canonical unpadded" to "VERBATIM (spacing/order preserved)" for the string surface, and a clear Migration hazard note added warning that listing an array-shaped claim flips
claim_<name>Set→String and can turn a fail-closedlikepolicy into a fail-open substring match — steering operators toclaimset_+.contains. Exactly the concern from the earlier inline. - The intentional normalize-vs-claimset divergence is now documented so it won't get "deduped" incorrectly.
Real checks are green (Test Go Code, Lint); the only red is the unrelated E2E Core (core) group-removal flake (same one seen on #5839 — nothing to do with Cedar authz). Nicely done.
🤖 AI-assisted panel review via Claude Code (security · OAuth · Go/reuse). Verified against 81090d1.
Summary
OAuth scope claims arrive in different shapes across identity providers: Entra (
scp) and Keycloak (scope) emit a single space-delimited string, while Okta (scp) emits a JSON array. The Cedar authorizer maps a string to a CedarStringand an array to aSet, and there is no single Cedar expression that tests element membership across both shapes — a type mismatch errors, and an erroring policy is silently ignored (apermitfails closed). So a policy authored for one IdP's claim shape silently mis-authorizes another's.This adds an opt-in
multi_valued_claimsfield to thecedarv1authz config. For each listed claim name, the authorizer exposes two forms, on both the principal entity and the request context:claim_<name>— a canonical unpadded space-delimited string (arrays joined with single spaces; strings passed through unchanged), so existinglike/==string-membership policies match uniformly across IdPs.claimset_<name>— a companion CedarSetof the claim's elements, for exact-element membership via.contains()/.containsAll()/.containsAny()(no substring bleed —Mail.Readnever matchesMail.ReadWrite, and nolike-metacharacter escaping concern).The
claimset_prefix is reserved and cannot be shadowed or spoofed by a JWT claim, because every JWT claim is emitted under theclaim_prefix. The field is opt-in: with it empty, output is byte-identical to today, so existing deployments are unaffected.Closes #5828
Type of change
Test plan
task test)task test-e2e)task lint-fix)Unit tests were run scoped to the changed package (
go test ./pkg/authz/authorizers/cedar/...) and are green, covering: array→join, verbatim string passthrough, single-element, empty array, absent claim, non-listed claim, non-string elements, multi-space input;claimset_Set construction from both the array and string shapes; exact-element.containswith no substring bleed and.containsAll; the bare-key / never-claim_claimset_assertion; and an empty-config backward-compat regression. Fulltask testandtask lint-fixrun in CI.Does this introduce a user-facing change?
Yes. A new optional
multi_valued_claimslist on thecedarv1authorization config. When set, each named claim is additionally exposed as aclaimset_<name>CedarSetand itsclaim_<name>string form is normalized (arrays joined). Unset (default) changes nothing.Special notes for reviewers
claimset_/join behavior is consumed via the rawcedarv1config (thethvCLI, a rawMCPAuthzConfigconfigMap, or the enterprise policy compiler, which writes the cedar config directly). A typed operator CRD field was intentionally omitted (YAGNI) — and becauseVirtualMCPServerSpecembeds the vmcp config struct, a vMCP config field would surface in the CRD schema, which we're deliberately avoiding here.like-based enterpriseContainspredicate byte-for-byte, so array-claim (Okta) support lights up as a runtime-only, zero-coordination change with no regression to existing string-IdP (Entra) deployments. TheSetis the strictly-better representation for new multi-value policies and is purely additive.Containsclaim operator.Generated with Claude Code