Skip to content

Extend group extraction with dual-claim, dot-notation, and merge-order fix #4768

@jhrozek

Description

@jhrozek

Part of stacklok/stacklok-enterprise-platform#stacklok/stacklok-enterprise-platform#376

Description

Extend the existing group extraction logic (added in upstream commit 5c258a1) to support dual-claim extraction (group_claim_name + role_claim_name), dot-notation traversal for nested claims (e.g., realm_access.roles), and deduplication across both claim sources. Fix the merge-order hazard by wiring group parent UIDs onto the Client entity via the variadic parents parameter (from #4765) instead of creating dynamic THVGroup entities.

This is the convergence point that makes group-based authorization functional end-to-end with the enterprise controller's compiled Cedar policies.

Context

Upstream commit 5c258a1 ("Enforce Cedar policies on upstream IDP token claims") implemented basic group extraction:

  • extractGroupsFromClaims — single-claim extraction from flat JWT claims
  • THVGroup entity creation with the signature CreatePrincipalEntity(..., groups []string)
  • Groups set as Client parents via dynamically-created THVGroup entities

The RFC (Section 6) marks this as Partial (Changes #3 and #4) and identifies two problems with the current implementation:

  1. Merge-order hazard: Dynamic THVGroup entities with empty parents overwrite static ones from entities_json (which carry THVRole parents), severing the role hierarchy
  2. Missing functionality: No role_claim_name support, no dot-notation for nested claims (Keycloak realm_access.roles), no dedup across both claim sources

This task addresses both: it extends extraction and fixes the entity wiring.

Dependencies: #4763 (RoleClaimName field), #4764 (serverName on Authorizer), #4765 (variadic parents replacing groups parameter)
Blocks: #4769 (MCP parent on resource entities), #4771 (end-to-end integration test)

Acceptance Criteria

  • resolveNestedClaim(claims jwt.MapClaims, path string) interface{} is implemented: tries exact top-level match first (handles Auth0 URL claims with dots), then falls back to dot-notation traversal (handles Keycloak nesting)
  • extractGroups(claims jwt.MapClaims, groupClaim string) []string extends the existing extractGroupsFromClaims: resolves via resolveNestedClaim, coerces to []string, returns nil when claim is empty or missing
  • dedup(groups []string) []string removes duplicate group names while preserving order
  • In AuthorizeWithJWTClaims: groups extracted from both a.groupClaim AND a.roleClaim (from Add RoleClaimName to ConfigOptions (complement existing GroupClaimName) #4763), merged and deduplicated BEFORE preprocessClaims runs
  • All four authorize* methods accept a groups []string parameter
  • CreateEntitiesForRequest accepts groups []string and serverName string parameters
  • CreateEntitiesForRequest builds THVGroup parent UIDs from the groups slice and passes them to CreatePrincipalEntity via the variadic parents parameter
  • CreateEntitiesForRequest does NOT create dynamic THVGroup entities (merge-order hazard fix — RFC Section 6, Change Bump golangci/golangci-lint-action from 2f856675483cb8b9378ee77ee0beb67955aca9d7 to 4696ba8babb6127d732c3c6dde519db15edab9ea #3)
  • When groupClaim and roleClaim are both empty, no groups are extracted and behavior is identical to pre-5c258a11 behavior (backward compatible)
  • All existing tests pass (with updates to accommodate the new parameters)
  • New unit tests cover the extraction functions and entity parent wiring
  • Code reviewed and approved

Technical Approach

Recommended Implementation

Phase 1 — Replace extractGroupsFromClaims with resolveNestedClaim + extractGroups + dedup:

The existing extractGroupsFromClaims from 5c258a1 handles flat claims only. Replace it with three pure functions:

  • resolveNestedClaim — exact-match-first, then dot-notation traversal
  • extractGroups — wraps resolveNestedClaim with type coercion to []string
  • dedup — order-preserving deduplication

Phase 2 — Dual-claim extraction in AuthorizeWithJWTClaims:

After extracting claims and clientID but before preprocessClaims, extract groups from a.groupClaim and roles from a.roleClaim, merge, and dedup. This replaces the single-claim extraction from 5c258a1.

Phase 3 — Thread groups through authorize* methods:

Each of the four methods gains a groups []string parameter. They pass it through to CreateEntitiesForRequest.

Phase 4 — Wire group parents in CreateEntitiesForRequest:

Build THVGroup parent UIDs from the groups slice. Pass to CreatePrincipalEntity via the variadic parents parameter (from #4765). Do NOT create intermediate THVGroup entities — the static entity store from entities_json already contains them with the correct THVRole parents.

Patterns & Frameworks

  • Exact-match-first resolution: resolveNestedClaim tries a direct top-level key lookup before splitting on . for nested traversal. This handles Auth0 URL-based claim names (e.g., https://myapp.example.com/roles) that contain dots but are top-level keys.
  • Dual-claim model: Both GroupClaimName and RoleClaimName extract from different JWT claims but produce the same Cedar entity type (THVGroup). Values are unioned and deduplicated before being set as Client parents.
  • No dynamic Group entities: Only the Client entity's parent set is modified. The THVGroup entities with their THVRole parents come from the static entities_json. This is the core merge-order hazard fix described in RFC Section 6, Change Bump golangci/golangci-lint-action from 2f856675483cb8b9378ee77ee0beb67955aca9d7 to 4696ba8babb6127d732c3c6dde519db15edab9ea #3.
  • Backward compatibility: When both claim names are empty, extractGroups returns nil, dedup returns nil, and no parents are added — identical to pre-5c258a11 behavior.

Code Pointers

  • pkg/authz/authorizers/cedar/core.goAuthorizeWithJWTClaims: replace single-claim extraction from 5c258a1 with dual-claim logic; extract BEFORE preprocessClaims
  • pkg/authz/authorizers/cedar/core.goextractGroupsFromClaims (from 5c258a1): replace with resolveNestedClaim + extractGroups
  • pkg/authz/authorizers/cedar/core.goauthorizeToolCall, authorizePromptGet, authorizeResourceRead, authorizeFeatureList: add groups []string parameter
  • pkg/authz/authorizers/cedar/entity.goCreateEntitiesForRequest: add groups and serverName parameters; build parent UIDs; pass to CreatePrincipalEntity via variadic; remove dynamic group entity creation from 5c258a1
  • pkg/authz/authorizers/cedar/core_test.go — existing test call sites need updating for new parameter

Component Interfaces

// resolveNestedClaim resolves a dot-separated claim path from JWT claims.
// Tries exact top-level match first (handles Auth0 URL claim names),
// then falls back to dot-notation traversal (handles Keycloak nesting).
func resolveNestedClaim(claims jwt.MapClaims, path string) interface{}

// extractGroups extracts group/role names from JWT claims.
// Replaces the simpler extractGroupsFromClaims from 5c258a11 with
// dot-notation support and better type handling.
func extractGroups(claims jwt.MapClaims, groupClaim string) []string

// dedup removes duplicate strings preserving order.
func dedup(groups []string) []string

Updated CreateEntitiesForRequest:

// Build group parent UIDs for the principal (NO dynamic Group entities)
var groupParents []cedar.EntityUID
for _, g := range groups {
    groupParents = append(groupParents,
        cedar.NewEntityUID("THVGroup", cedar.String(g)))
}

// Create principal entity with group parents
principalUID, principalEntity := f.CreatePrincipalEntity(
    principalType, principalID, claimsMap, groupParents...)
entities[principalUID] = principalEntity

Testing Strategy

Unit Tests

  • TestResolveNestedClaim_ExactMatch: top-level key "groups" returns the value directly
  • TestResolveNestedClaim_DotNotation: "realm_access.roles" traverses nested map
  • TestResolveNestedClaim_Auth0URL: "https://myapp.example.com/roles" matches exact top-level key despite dots
  • TestResolveNestedClaim_MissingClaim: non-existent key returns nil
  • TestResolveNestedClaim_NestedTraversalHitsNonObject: "foo.bar" where foo is a string returns nil
  • TestExtractGroups_FlatClaim: replaces existing single-claim test; groupClaim="groups" with flat array
  • TestExtractGroups_NestedClaim: groupClaim="realm_access.roles" with Keycloak-style nesting
  • TestExtractGroups_EmptyClaimName: empty claim returns nil
  • TestExtractGroups_MissingClaim: absent claim returns nil
  • TestExtractGroups_NonArrayClaim: string claim value returns nil
  • TestDedup_WithDuplicates: removes duplicates, preserving first-occurrence order
  • TestDedup_NilInput: nil returns nil
  • TestAuthorizeWithJWTClaims_DualClaim: groups from both groupClaim and roleClaim are merged and deduplicated
  • TestCreateEntitiesForRequest_GroupParents: call with groups; verify principal has THVGroup parent UIDs and NO separate THVGroup entities in the entity map (merge-order hazard regression test)
  • TestCreateEntitiesForRequest_NoGroups: call with nil groups; verify empty parent set (backward compat)

Edge Cases

  • Auth0 URL claim name with dots does NOT trigger nested traversal when exact match exists
  • Keycloak 3-level nesting (resource_access.my-app.roles) resolves correctly
  • Groups from both claims merged and deduplicated
  • Non-string array elements silently skipped
  • Empty group array [] produces no parent UIDs

Out of Scope

  • Creating dynamic THVGroup entities in CreateEntitiesForRequest (explicitly prohibited — merge-order hazard)
  • Setting MCP parent on resource entities (Set MCP parent on resource entities #4769)
  • Replacing FeatureType with MCP for list operations (Replace FeatureType with MCP for list operations #4770)
  • Changes to IsAuthorized or the entity merge logic
  • Multi-claim support (group_claims plural) — deferred future extension
  • Entra ID group overage Graph API callout — MVP is fail-closed (deny)
  • Case normalization of group names — entity IDs are intentionally case-sensitive

References

Metadata

Metadata

Assignees

Labels

authorizationgoPull requests that update go code

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions