Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions cmd/thv-operator/pkg/vmcpconfig/converter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
}
})
},
Expand All @@ -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)
},
},
{
Expand Down
14 changes: 13 additions & 1 deletion cmd/thv-operator/pkg/vmcpconfig/converter_authz_ref_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package vmcpconfig

import (
"encoding/json"
"strings"
"testing"

Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/operator/crd-api.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 2 additions & 3 deletions pkg/vmcp/auth/factory/incoming.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions pkg/vmcp/auth/factory/incoming_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package factory

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
Expand Down Expand Up @@ -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)
}
4 changes: 4 additions & 0 deletions pkg/vmcp/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions pkg/vmcp/config/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading