Skip to content

feat(authz/cedar): normalize multi-valued claims to canonical space-delimited form #5828

Description

@jhrozek

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:

  1. 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).
  2. 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 scpclaim_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 scpclaim_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.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions