Problem
The Cedar authorizer maps a JWT claim to a Cedar value by JSON type: a string becomes a Cedar String, an array becomes a Set. But the same logical multi-valued claim arrives in different shapes across identity providers:
- Microsoft Entra emits the scope claim
scp as one space-delimited string: "User.Read.All Mail.Read" (per RFC 6749 §3.3).
- Keycloak emits
scope the same way (space-delimited string).
- Okta emits
scp as a JSON array: ["User.Read.All","Mail.Read"].
A Cedar policy that tests element membership can't be written once against both shapes: a like-pattern predicate needs a String, .contains() needs a Set, and there is no total Cedar expression across the two (a type mismatch errors, and || doesn't swallow a left-operand error). Downstream, this means a policy authored for one IdP's claim shape silently fails closed for another's.
Change
Add an opt-in list of claim names to normalize — multi_valued_claims on the cedarv1 authz config, and multiValuedClaims on the vMCP config. The field takes claim names supplied by the caller — not tied to any IdP, so ["scp"] (Entra/Okta), ["scope"] (Keycloak), or a custom name all work.
For each listed claim, the authorizer exposes two forms, both on the principal entity and the request context:
-
claim_<name> — canonical space-delimited string (the value's normal claim_-prefixed slot):
- array → join elements with a single space into a
String;
- already a
String → passthrough unchanged;
- absent → stays absent; not listed → untouched (today's behavior).
-
claimset_<name> — a companion Cedar Set of the claim's elements (array as-is; string split on spaces), for exact-element membership via .contains() / .containsAll() / .containsAny(). The claimset_ prefix is reserved: because every JWT claim is emitted under claim_, a token can never shadow or spoof a claimset_ attribute.
Why both a joined string and a companion Set
- The joined string lets existing
like-pattern policies match uniformly across IdPs without changing the policy text. That matters where the policy producer and the authorizer runtime upgrade on independent schedules: the emitted predicate stays invariant, so no policy that works today starts failing closed when only one side upgrades. RFC 6749 §3.3 defines scope values as space-free by grammar (NQCHAR excludes %x20), so joining scope claims is lossless and unambiguous. This is the form the operator-compiled Contains predicate (space-delimited element like) consumes.
- The companion Set is the better representation for new multi-value policies:
.contains() is exact-element (no substring bleed, e.g. Mail.Read never matches Mail.ReadWrite), .containsAll() / .containsAny() express multi-scope checks in a single call, and it carries no like-metacharacter escaping obligation. It is additive — the joined string is unchanged, so nothing that relies on it is affected.
Note: both forms assume space-free-element claims (OAuth scopes). They are not appropriate for claims whose elements can contain spaces (e.g. group display names like "Engineering Team"), where element boundaries become ambiguous — list only space-free-element claims here. Group/role claims continue to use the entity-hierarchy path, not this field.
Backward compatibility
Default empty → byte-identical output to today for every existing user, including anyone matching scp == "a b c" as a whole string. Purely additive: opt-in per claim name, and even when enabled the original claim_<name> string form is preserved (the Set is a new attribute, not a replacement).
Acceptance criteria
multi_valued_claims: ["scp"] + array scp → claim_scp is a single space-delimited String (a like-pattern element match succeeds) and claimset_scp is a Set of the elements.
- Same config + string
scp → claim_scp unchanged (passthrough); claimset_scp is the space-split Set.
claimset_scp.contains("X") matches identically for the array and string shapes, is exact-element (no substring bleed), and works on both principal.claimset_scp and context.claimset_scp; claimset_scp is a bare key, never claim_claimset_scp.
- Claim absent →
claim_scp still absent, no claimset_scp fabricated; has-guards still evaluate correctly.
- Empty/unset config → byte-identical to current output, no
claimset_ keys (regression test).
- Field plumbed through the
cedarv1 config and the vMCP config.
- Unit tests in
core_test.go: array-join, string-passthrough, single-element, absent claim, multi-space edge cases, claimset_ Set construction from both shapes, and the bare-key / no-claim_claimset_ assertion.
Out of scope / follow-up
Declarative exposure on the operator AuthzConfigRef CRD is intentionally deferred (YAGNI): the enterprise compiler writes multi_valued_claims straight into the generated Cedar config, and a native operator user can set it today via a raw MCPAuthzConfig configMap. A typed CRD field (mirroring groupClaimName/roleClaimName/groupEntityType) can be added later, additively, if a native operator consumer needs it.
Problem
The Cedar authorizer maps a JWT claim to a Cedar value by JSON type: a string becomes a Cedar
String, an array becomes aSet. But the same logical multi-valued claim arrives in different shapes across identity providers:scpas one space-delimited string:"User.Read.All Mail.Read"(per RFC 6749 §3.3).scopethe same way (space-delimited string).scpas a JSON array:["User.Read.All","Mail.Read"].A Cedar policy that tests element membership can't be written once against both shapes: a
like-pattern predicate needs aString,.contains()needs aSet, and there is no total Cedar expression across the two (a type mismatch errors, and||doesn't swallow a left-operand error). Downstream, this means a policy authored for one IdP's claim shape silently fails closed for another's.Change
Add an opt-in list of claim names to normalize —
multi_valued_claimson thecedarv1authz config, andmultiValuedClaimson the vMCP config. The field takes claim names supplied by the caller — not tied to any IdP, so["scp"](Entra/Okta),["scope"](Keycloak), or a custom name all work.For each listed claim, the authorizer exposes two forms, both on the principal entity and the request context:
claim_<name>— canonical space-delimited string (the value's normalclaim_-prefixed slot):String;String→ passthrough unchanged;claimset_<name>— a companion CedarSetof the claim's elements (array as-is; string split on spaces), for exact-element membership via.contains()/.containsAll()/.containsAny(). Theclaimset_prefix is reserved: because every JWT claim is emitted underclaim_, a token can never shadow or spoof aclaimset_attribute.Why both a joined string and a companion Set
like-pattern policies match uniformly across IdPs without changing the policy text. That matters where the policy producer and the authorizer runtime upgrade on independent schedules: the emitted predicate stays invariant, so no policy that works today starts failing closed when only one side upgrades. RFC 6749 §3.3 defines scope values as space-free by grammar (NQCHARexcludes%x20), so joining scope claims is lossless and unambiguous. This is the form the operator-compiledContainspredicate (space-delimited elementlike) consumes..contains()is exact-element (no substring bleed, e.g.Mail.Readnever matchesMail.ReadWrite),.containsAll()/.containsAny()express multi-scope checks in a single call, and it carries nolike-metacharacter escaping obligation. It is additive — the joined string is unchanged, so nothing that relies on it is affected.Backward compatibility
Default empty → byte-identical output to today for every existing user, including anyone matching
scp == "a b c"as a whole string. Purely additive: opt-in per claim name, and even when enabled the originalclaim_<name>string form is preserved (theSetis a new attribute, not a replacement).Acceptance criteria
multi_valued_claims: ["scp"]+ arrayscp→claim_scpis a single space-delimitedString(alike-pattern element match succeeds) andclaimset_scpis aSetof the elements.scp→claim_scpunchanged (passthrough);claimset_scpis the space-splitSet.claimset_scp.contains("X")matches identically for the array and string shapes, is exact-element (no substring bleed), and works on bothprincipal.claimset_scpandcontext.claimset_scp;claimset_scpis a bare key, neverclaim_claimset_scp.claim_scpstill absent, noclaimset_scpfabricated;has-guards still evaluate correctly.claimset_keys (regression test).cedarv1config and the vMCP config.core_test.go: array-join, string-passthrough, single-element, absent claim, multi-space edge cases,claimset_Setconstruction from both shapes, and the bare-key / no-claim_claimset_assertion.Out of scope / follow-up
Declarative exposure on the operator
AuthzConfigRefCRD is intentionally deferred (YAGNI): the enterprise compiler writesmulti_valued_claimsstraight into the generated Cedar config, and a native operator user can set it today via a rawMCPAuthzConfigconfigMap. A typed CRD field (mirroringgroupClaimName/roleClaimName/groupEntityType) can be added later, additively, if a native operator consumer needs it.