diff --git a/cmd/thv-operator/pkg/vmcpconfig/converter.go b/cmd/thv-operator/pkg/vmcpconfig/converter.go index 018a98693e..2c81d56f60 100644 --- a/cmd/thv-operator/pkg/vmcpconfig/converter.go +++ b/cmd/thv-operator/pkg/vmcpconfig/converter.go @@ -269,12 +269,13 @@ func (c *Converter) resolveAuthzConfigRef( } authz := &vmcpconfig.AuthzConfig{ - Type: "cedar", - Policies: opts.Policies, - EntitiesJSON: opts.EntitiesJSON, - GroupClaimName: opts.GroupClaimName, - RoleClaimName: opts.RoleClaimName, - GroupEntityType: opts.GroupEntityType, + Type: "cedar", + Policies: opts.Policies, + EntitiesJSON: opts.EntitiesJSON, + MultiValuedClaims: opts.MultiValuedClaims, + GroupClaimName: opts.GroupClaimName, + RoleClaimName: opts.RoleClaimName, + GroupEntityType: opts.GroupEntityType, } // PrimaryUpstreamProvider is an auth-server (control-plane) property resolved @@ -328,6 +329,7 @@ func (c *Converter) convertAuthzConfig( } authz.Policies = opts.Policies authz.EntitiesJSON = opts.EntitiesJSON + authz.MultiValuedClaims = opts.MultiValuedClaims // ConfigMap-supplied JWT-claim mapping values are the default; spec-level // overrides apply below. PrimaryUpstreamProvider is an auth-server property // (spec.authServerConfig.primaryUpstreamProvider) so it is not read from diff --git a/cmd/thv-operator/pkg/vmcpconfig/converter_authz_configmap_test.go b/cmd/thv-operator/pkg/vmcpconfig/converter_authz_configmap_test.go index b62f6b688c..bb186886b9 100644 --- a/cmd/thv-operator/pkg/vmcpconfig/converter_authz_configmap_test.go +++ b/cmd/thv-operator/pkg/vmcpconfig/converter_authz_configmap_test.go @@ -140,10 +140,11 @@ func TestConvertAuthzConfig_ConfigMapPath(t *testing.T) { "policies": []string{ `permit(principal in ClaimGroup::"engineering", action == Action::"call_tool", resource);`, }, - "entities_json": `[{"uid":{"type":"ClaimGroup","id":"engineering"}}]`, - "group_claim_name": "groups", - "role_claim_name": "roles", - "group_entity_type": "ClaimGroup", + "entities_json": `[{"uid":{"type":"ClaimGroup","id":"engineering"}}]`, + "group_claim_name": "groups", + "role_claim_name": "roles", + "group_entity_type": "ClaimGroup", + "multi_valued_claims": []string{"scope"}, } }) }, @@ -156,6 +157,7 @@ func TestConvertAuthzConfig_ConfigMapPath(t *testing.T) { assert.Equal(t, "groups", authz.GroupClaimName) assert.Equal(t, "roles", authz.RoleClaimName) assert.Equal(t, "ClaimGroup", authz.GroupEntityType) + assertMultiValuedClaims(t, authz) }, }, { diff --git a/cmd/thv-operator/pkg/vmcpconfig/converter_authz_ref_test.go b/cmd/thv-operator/pkg/vmcpconfig/converter_authz_ref_test.go index 3f48b67b19..2e7b85f74b 100644 --- a/cmd/thv-operator/pkg/vmcpconfig/converter_authz_ref_test.go +++ b/cmd/thv-operator/pkg/vmcpconfig/converter_authz_ref_test.go @@ -4,6 +4,7 @@ package vmcpconfig import ( + "encoding/json" "strings" "testing" @@ -15,6 +16,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + vmcpconfigpkg "github.com/stacklok/toolhive/pkg/vmcp/config" ) // newMCPAuthzConfig builds an MCPAuthzConfig of the given type in the "default" @@ -62,7 +64,16 @@ func newAuthzVmcpForRef(refName string) *mcpv1beta1.VirtualMCPServer { func cedarRefPayload() string { return `{"policies":["permit(principal in ClaimGroup::\"engineering\", action == Action::\"call_tool\", resource);"],` + `"entities_json":"[{\"uid\":{\"type\":\"ClaimGroup\",\"id\":\"engineering\"}}]",` + - `"group_claim_name":"groups","role_claim_name":"roles","group_entity_type":"ClaimGroup"}` + `"group_claim_name":"groups","role_claim_name":"roles","group_entity_type":"ClaimGroup",` + + `"multi_valued_claims":["scope"]}` +} + +func assertMultiValuedClaims(t *testing.T, authz *vmcpconfigpkg.AuthzConfig) { + t.Helper() + + encoded, err := json.Marshal(authz) + require.NoError(t, err) + assert.Contains(t, string(encoded), `"multiValuedClaims":["scope"]`) } // TestConvertAuthzConfigRef_CedarSuccess verifies a cedarv1 MCPAuthzConfig @@ -89,6 +100,7 @@ func TestConvertAuthzConfigRef_CedarSuccess(t *testing.T) { assert.Equal(t, "groups", authz.GroupClaimName) assert.Equal(t, "roles", authz.RoleClaimName) assert.Equal(t, "ClaimGroup", authz.GroupEntityType) + assertMultiValuedClaims(t, authz) } // TestConvertAuthzConfigRef_NonCedarFailsFast verifies a non-Cedar diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml index d9d277a5d1..e2bcdd93a4 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -2973,6 +2973,12 @@ spec: constructed with an empty entity store and `in` checks against absent entities silently evaluate to false. Defaults to "[]" when empty. type: string + multiValuedClaims: + description: MultiValuedClaims lists JWT claim names that Cedar should + also expose as sets. + items: + type: string + type: array groupClaimName: description: |- GroupClaimName is the JWT claim key that contains group membership for diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml index d4f0c37b02..350d5832b9 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml @@ -2976,6 +2976,12 @@ spec: constructed with an empty entity store and `in` checks against absent entities silently evaluate to false. Defaults to "[]" when empty. type: string + multiValuedClaims: + description: MultiValuedClaims lists JWT claim names that Cedar should + also expose as sets. + items: + type: string + type: array groupClaimName: description: |- GroupClaimName is the JWT claim key that contains group membership for diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index cf43c3ec93..8c621acf83 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -299,6 +299,7 @@ _Appears in:_ | `type` _string_ | Type is the authz type: "cedar", "none" | | | | `policies` _string array_ | Policies contains Cedar policy definitions (when Type = "cedar"). | | | | `entitiesJson` _string_ | EntitiesJSON is a JSON string representing Cedar entities. Required for
enterprise policies that rely on transitive relationships (e.g.
`ClaimGroup → PlatformRole`) — without it the Cedar authorizer is
constructed with an empty entity store and `in` checks against absent
entities silently evaluate to false. Defaults to "[]" when empty. | | Optional: \{\}
| +| `multiValuedClaims` _string array_ | MultiValuedClaims lists JWT claim names that Cedar should also expose as sets. | | Optional: \{\}
| | `primaryUpstreamProvider` _string_ | PrimaryUpstreamProvider names the upstream IDP provider whose access
token should be used as the source of JWT claims for Cedar evaluation.
When empty, claims from the ToolHive-issued token are used.
Must match an upstream provider name configured in the embedded auth server
(e.g. "default", "github"). Only relevant when the embedded auth server is active. | | Optional: \{\}
| | `groupClaimName` _string_ | GroupClaimName is the JWT claim key that contains group membership for
the principal. When set, takes priority over the well-known defaults
("groups", "roles", "cognito:groups"). Use this for IDPs that place
groups under a URI-style claim (e.g. "https://example.com/groups").
When empty, only the well-known claim names are checked. | | Optional: \{\}
| | `roleClaimName` _string_ | RoleClaimName is the JWT claim key that contains role membership for the
principal. When set, the claim is extracted separately from GroupClaimName
and both are mapped to the configured group entity type. When empty, no
role extraction is performed. | | Optional: \{\}
| @@ -5295,6 +5296,5 @@ This type is shared with the Kubernetes operator CRD (VirtualMCPServer.Status.Di - diff --git a/pkg/vmcp/auth/factory/incoming.go b/pkg/vmcp/auth/factory/incoming.go index 713254f726..1797010a8f 100644 --- a/pkg/vmcp/auth/factory/incoming.go +++ b/pkg/vmcp/auth/factory/incoming.go @@ -184,15 +184,14 @@ func buildCedarAuthzConfig(authzCfg *config.AuthzConfig) (*authz.Config, error) // Build the Cedar config structure expected by the authorizer factory. // PrimaryUpstreamProvider is forwarded so Cedar evaluates claims from the // upstream IDP token when the embedded auth server is active. - // GroupClaimName, RoleClaimName, and GroupEntityType plumb the enterprise - // JWT-to-entity mapping (groups/roles claims → Cedar parent UIDs) through - // to the authorizer. + // MultiValuedClaims and the group/role fields configure Cedar claim mapping. cedarConfig := cedar.Config{ Version: "1.0", Type: cedar.ConfigType, Options: &cedar.ConfigOptions{ Policies: authzCfg.Policies, EntitiesJSON: entitiesJSON, + MultiValuedClaims: authzCfg.MultiValuedClaims, PrimaryUpstreamProvider: authzCfg.PrimaryUpstreamProvider, GroupClaimName: authzCfg.GroupClaimName, RoleClaimName: authzCfg.RoleClaimName, diff --git a/pkg/vmcp/auth/factory/incoming_test.go b/pkg/vmcp/auth/factory/incoming_test.go index e2ded405eb..c256573bb0 100644 --- a/pkg/vmcp/auth/factory/incoming_test.go +++ b/pkg/vmcp/auth/factory/incoming_test.go @@ -4,6 +4,7 @@ package factory import ( + "encoding/json" "net/http" "net/http/httptest" "testing" @@ -232,3 +233,21 @@ func TestNewCedarAuthzMiddleware_PropagatesPrimaryUpstreamProvider(t *testing.T) assert.Equal(t, providerName, extracted.Options.PrimaryUpstreamProvider, "PrimaryUpstreamProvider must be preserved through authorizers.NewConfig round-trip") } + +func TestBuildCedarAuthzConfig_PropagatesMultiValuedClaims(t *testing.T) { + t.Parallel() + + var authzCfg config.AuthzConfig + input := []byte( + `{"type":"cedar","policies":["permit(principal, action, resource);"],` + + `"multiValuedClaims":["scope"]}`, + ) + err := json.Unmarshal(input, &authzCfg) + require.NoError(t, err) + + got, err := buildCedarAuthzConfig(&authzCfg) + require.NoError(t, err) + extracted, err := cedar.ExtractConfig(got) + require.NoError(t, err) + assert.Equal(t, []string{"scope"}, extracted.Options.MultiValuedClaims) +} diff --git a/pkg/vmcp/config/config.go b/pkg/vmcp/config/config.go index f21a3740ad..4640801711 100644 --- a/pkg/vmcp/config/config.go +++ b/pkg/vmcp/config/config.go @@ -300,6 +300,10 @@ type AuthzConfig struct { // Policies contains Cedar policy definitions (when Type = "cedar"). Policies []string `json:"policies,omitempty" yaml:"policies,omitempty"` + // MultiValuedClaims lists JWT claim names that Cedar should also expose as sets. + // +optional + MultiValuedClaims []string `json:"multiValuedClaims,omitempty" yaml:"multiValuedClaims,omitempty"` + // EntitiesJSON is a JSON string representing Cedar entities. Required for // enterprise policies that rely on transitive relationships (e.g. // `ClaimGroup → PlatformRole`) — without it the Cedar authorizer is diff --git a/pkg/vmcp/config/zz_generated.deepcopy.go b/pkg/vmcp/config/zz_generated.deepcopy.go index 36dae5490a..598c7a9571 100644 --- a/pkg/vmcp/config/zz_generated.deepcopy.go +++ b/pkg/vmcp/config/zz_generated.deepcopy.go @@ -66,6 +66,11 @@ func (in *AuthzConfig) DeepCopyInto(out *AuthzConfig) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.MultiValuedClaims != nil { + in, out := &in.MultiValuedClaims, &out.MultiValuedClaims + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AuthzConfig.