diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go
index 0aefd93764..467b445924 100644
--- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go
+++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go
@@ -232,6 +232,26 @@ type EmbeddedAuthServerConfig struct {
// +listMapKey=name
UpstreamProviders []UpstreamProviderConfig `json:"upstreamProviders"`
+ // PrimaryUpstreamProvider names the upstream IDP whose access token Cedar
+ // should read claims from when authorising a request. Must match the name
+ // of one of the entries in UpstreamProviders. When empty, the controller
+ // auto-selects the first entry of UpstreamProviders.
+ //
+ // Only meaningful on VirtualMCPServer, where multiple upstream providers
+ // can be configured and Cedar needs to pick which token's claims to
+ // evaluate. The VirtualMCPServer controller validates this field against
+ // UpstreamProviders at admission and rejects unresolvable values.
+ //
+ // On MCPServer and MCPRemoteProxy this field is structurally present (the
+ // EmbeddedAuthServerConfig struct is shared) but has no runtime effect:
+ // those CRDs are restricted to a single upstream so there is no choice to
+ // make. Setting it on those CRDs is silently ignored.
+ // +optional
+ // +kubebuilder:validation:MinLength=1
+ // +kubebuilder:validation:MaxLength=63
+ // +kubebuilder:validation:Pattern=`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`
+ PrimaryUpstreamProvider string `json:"primaryUpstreamProvider,omitempty"`
+
// Storage configures the storage backend for the embedded auth server.
// If not specified, defaults to in-memory storage.
// +optional
diff --git a/cmd/thv-operator/api/v1beta1/mcpserver_types.go b/cmd/thv-operator/api/v1beta1/mcpserver_types.go
index 6eaa4dcca6..fb9e1d5d68 100644
--- a/cmd/thv-operator/api/v1beta1/mcpserver_types.go
+++ b/cmd/thv-operator/api/v1beta1/mcpserver_types.go
@@ -96,6 +96,12 @@ const (
// when spec.authzConfig.inline.primaryUpstreamProvider is non-empty on a CR type
// that has no embedded auth server (MCPServer / MCPRemoteProxy). The field has
// no effect on those resources and is documented as VirtualMCPServer-only.
+ //
+ // Tied to the deprecated InlineAuthzConfig.PrimaryUpstreamProvider field
+ // (see mcpserver_types.go). When that field is removed at end of the
+ // deprecation cycle, this condition and ConditionReasonAuthzPrimaryUpstreamProviderIgnored
+ // below should be removed in the same change: there is no other path that
+ // fires this advisory.
ConditionTypeAuthzPrimaryUpstreamProviderIgnored = "AuthzPrimaryUpstreamProviderIgnored"
)
@@ -652,16 +658,46 @@ type AuthzConfigRef struct {
// Only used when Type is "inline"
// +optional
Inline *InlineAuthzConfig `json:"inline,omitempty"`
+
+ // 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
+ // Type is "configMap", a group_claim_name entry in the referenced ConfigMap
+ // is overridden by this field if both are set.
+ // +optional
+ // +kubebuilder:validation:MaxLength=253
+ GroupClaimName string `json:"groupClaimName,omitempty"`
+
+ // 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 GroupEntityType. When Type is
+ // "configMap", a role_claim_name entry in the referenced ConfigMap is
+ // overridden by this field if both are set.
+ // +optional
+ // +kubebuilder:validation:MaxLength=253
+ RoleClaimName string `json:"roleClaimName,omitempty"`
+
+ // GroupEntityType is the Cedar entity type name used for principal parent
+ // UIDs synthesised from JWT group/role claims. Defaults to "THVGroup" when
+ // empty. Must match the entity type used in the static entity store for
+ // transitive `in` checks (e.g. `ClaimGroup → PlatformRole`) to resolve.
+ // Namespaced names (`Foo::Bar`) are not yet supported. When Type is
+ // "configMap", a group_entity_type entry in the referenced ConfigMap is
+ // overridden by this field if both are set.
+ // +optional
+ // +kubebuilder:validation:MaxLength=63
+ // +kubebuilder:validation:Pattern=`^[A-Za-z_][A-Za-z0-9_]*$`
+ GroupEntityType string `json:"groupEntityType,omitempty"`
}
-// ExplicitPrimaryUpstreamProvider returns the user-specified primary upstream
-// provider name from the authz config, or "" if none is set.
-//
-// Currently reads from inline config only. ConfigMap-sourced authz needs to
-// load and parse the referenced ConfigMap; until that path lands (see the
-// matching TODO in cmd/thv-operator/pkg/vmcpconfig/converter.go), configMap
-// users always fall through to auto-selection of the first upstream.
-func (r *AuthzConfigRef) ExplicitPrimaryUpstreamProvider() string {
+// DeprecatedInlinePrimaryUpstreamProvider returns the legacy inline
+// PrimaryUpstreamProvider value, or "" when the field or the AuthzConfigRef
+// is nil. The field has moved to spec.authServerConfig.primaryUpstreamProvider
+// on VirtualMCPServer; this accessor is the single read point for the
+// deprecated location so callers can emit a deprecation warning when it
+// returns a non-empty value.
+func (r *AuthzConfigRef) DeprecatedInlinePrimaryUpstreamProvider() string {
if r == nil || r.Inline == nil {
return ""
}
@@ -736,7 +772,11 @@ func (r *MCPGroupRef) GetName() string {
return r.Name
}
-// InlineAuthzConfig contains direct authorization configuration
+// InlineAuthzConfig contains direct authorization configuration.
+//
+// Source-agnostic Cedar JWT-claim mapping settings (GroupClaimName,
+// RoleClaimName, GroupEntityType) live on the parent AuthzConfigRef so they
+// work the same way for inline and configMap-sourced authz.
type InlineAuthzConfig struct {
// Policies is a list of Cedar policy strings
// +kubebuilder:validation:Required
@@ -744,22 +784,27 @@ type InlineAuthzConfig struct {
// +listType=atomic
Policies []string `json:"policies"`
- // EntitiesJSON is a JSON string representing Cedar entities
+ // EntitiesJSON is a JSON string representing Cedar entities. Required when
+ // transitive policies (e.g. `ClaimGroup → PlatformRole`) need a static
+ // entity store; defaults to "[]".
// +kubebuilder:default="[]"
// +optional
EntitiesJSON string `json:"entitiesJson,omitempty"`
- // PrimaryUpstreamProvider names the upstream IDP whose access token's claims
- // Cedar should evaluate. Currently honored only when the parent
- // AuthzConfigRef.Type is "inline"; configMap-sourced policies will support
- // this in a future release (see #5208). Only meaningful for VirtualMCPServer
- // with an embedded auth server. When empty and an embedded auth server has
- // upstreams configured, the controller defaults to the first upstream
- // provider. The name must match one of the upstreams declared on
- // spec.authServerConfig.upstreamProviders; otherwise the VirtualMCPServer is
- // rejected with AuthServerConfigValidated=False. MCPServer and MCPRemoteProxy
- // have no embedded auth server; setting this field on those CRs surfaces an
- // AuthzPrimaryUpstreamProviderIgnored advisory condition on the resource.
+ // PrimaryUpstreamProvider names the upstream IDP whose access token's
+ // claims Cedar should evaluate.
+ //
+ // Deprecated: on VirtualMCPServer this field has moved to
+ // spec.authServerConfig.primaryUpstreamProvider. The old location is
+ // still read for one release for backward compatibility; the
+ // VirtualMCPServer controller emits an AuthzPrimaryUpstreamProviderDeprecated
+ // Warning event whenever it is consumed, and removal is planned for the
+ // release after the deprecation cycle.
+ //
+ // On MCPServer and MCPRemoteProxy this field has always been a structural
+ // no-op (those CRDs do not run an embedded auth server). Setting it
+ // continues to surface the AuthzPrimaryUpstreamProviderIgnored advisory
+ // condition; the deprecation does not change that behaviour.
// +optional
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=63
diff --git a/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go b/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go
index d4f31b5d66..f224c48e73 100644
--- a/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go
+++ b/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go
@@ -338,6 +338,13 @@ const (
// ConditionReasonIncomingAuthInvalid indicates incoming auth is invalid
ConditionReasonIncomingAuthInvalid = "IncomingAuthInvalid"
+ // Note: ConditionReasonAuthzConfigMapNotFound is shared with MCPRemoteProxy and is
+ // declared in mcpremoteproxy_types.go.
+
+ // ConditionReasonAuthzConfigMapInvalid indicates the referenced authz ConfigMap was
+ // found but its payload is missing/empty/malformed or fails Cedar validation.
+ ConditionReasonAuthzConfigMapInvalid = "AuthzConfigMapInvalid"
+
// ConditionReasonGroupRefValid indicates the GroupRef is valid
ConditionReasonVirtualMCPServerGroupRefValid = "GroupRefValid"
@@ -501,6 +508,28 @@ func (r *VirtualMCPServer) ResolveGroupName() string {
return r.Spec.GroupRef.GetName()
}
+// ExplicitPrimaryUpstreamProvider returns the user-configured primary upstream
+// provider name and a flag indicating whether the value came from the
+// deprecated spec.incomingAuth.authzConfig.inline.primaryUpstreamProvider
+// location (fromDeprecated=true) or the canonical
+// spec.authServerConfig.primaryUpstreamProvider location (fromDeprecated=false).
+// Returns ("", false) when neither location is set.
+//
+// Precedence: the canonical location wins if set; the deprecated location is
+// read only as a backward-compatibility fallback. Callers should emit a
+// Warning event when fromDeprecated is true.
+func (r *VirtualMCPServer) ExplicitPrimaryUpstreamProvider() (name string, fromDeprecated bool) {
+ if r.Spec.AuthServerConfig != nil && r.Spec.AuthServerConfig.PrimaryUpstreamProvider != "" {
+ return r.Spec.AuthServerConfig.PrimaryUpstreamProvider, false
+ }
+ if r.Spec.IncomingAuth != nil {
+ if dep := r.Spec.IncomingAuth.AuthzConfig.DeprecatedInlinePrimaryUpstreamProvider(); dep != "" {
+ return dep, true
+ }
+ }
+ return "", false
+}
+
// Validate performs validation for VirtualMCPServer
// This method is called by the controller during reconciliation
func (r *VirtualMCPServer) Validate() error {
diff --git a/cmd/thv-operator/api/v1beta1/virtualmcpserver_types_test.go b/cmd/thv-operator/api/v1beta1/virtualmcpserver_types_test.go
index 01c93bdc32..466400709c 100644
--- a/cmd/thv-operator/api/v1beta1/virtualmcpserver_types_test.go
+++ b/cmd/thv-operator/api/v1beta1/virtualmcpserver_types_test.go
@@ -633,3 +633,132 @@ func TestVirtualMCPServer_ResolveGroupName(t *testing.T) {
})
}
}
+
+// TestVirtualMCPServer_ExplicitPrimaryUpstreamProvider locks the precedence
+// between the canonical spec.authServerConfig.primaryUpstreamProvider location
+// and the deprecated spec.incomingAuth.authzConfig.inline.primaryUpstreamProvider
+// fallback. The returned fromDeprecated flag is the signal callers use to emit
+// the AuthzPrimaryUpstreamProviderDeprecated warning event, so its semantics
+// matter beyond the name string.
+func TestVirtualMCPServer_ExplicitPrimaryUpstreamProvider(t *testing.T) {
+ t.Parallel()
+
+ withCanonical := func(primary string) *EmbeddedAuthServerConfig {
+ return &EmbeddedAuthServerConfig{
+ UpstreamProviders: []UpstreamProviderConfig{{Name: "okta"}, {Name: "github"}},
+ PrimaryUpstreamProvider: primary,
+ }
+ }
+ withDeprecatedInline := func(primary string) *IncomingAuthConfig {
+ return &IncomingAuthConfig{
+ Type: "oidc",
+ AuthzConfig: &AuthzConfigRef{
+ Type: "inline",
+ Inline: &InlineAuthzConfig{
+ Policies: []string{`permit(principal, action, resource);`},
+ PrimaryUpstreamProvider: primary,
+ },
+ },
+ }
+ }
+
+ tests := []struct {
+ name string
+ authServerConfig *EmbeddedAuthServerConfig
+ incomingAuth *IncomingAuthConfig
+ wantName string
+ wantFromDeprecated bool
+ }{
+ {
+ name: "neither location set returns empty",
+ authServerConfig: &EmbeddedAuthServerConfig{},
+ incomingAuth: &IncomingAuthConfig{Type: "anonymous"},
+ wantName: "",
+ wantFromDeprecated: false,
+ },
+ {
+ name: "canonical set returns canonical value with fromDeprecated=false",
+ authServerConfig: withCanonical("github"),
+ incomingAuth: &IncomingAuthConfig{Type: "anonymous"},
+ wantName: "github",
+ wantFromDeprecated: false,
+ },
+ {
+ name: "deprecated inline set returns inline value with fromDeprecated=true",
+ authServerConfig: nil,
+ incomingAuth: withDeprecatedInline("okta"),
+ wantName: "okta",
+ wantFromDeprecated: true,
+ },
+ {
+ name: "canonical wins when both are set",
+ authServerConfig: withCanonical("github"),
+ incomingAuth: withDeprecatedInline("okta"),
+ wantName: "github",
+ wantFromDeprecated: false,
+ },
+ {
+ name: "nil authServerConfig falls through to deprecated inline",
+ authServerConfig: nil,
+ incomingAuth: withDeprecatedInline("okta"),
+ wantName: "okta",
+ wantFromDeprecated: true,
+ },
+ {
+ name: "nil IncomingAuth with empty canonical returns empty",
+ authServerConfig: &EmbeddedAuthServerConfig{},
+ incomingAuth: nil,
+ wantName: "",
+ wantFromDeprecated: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ vmcp := &VirtualMCPServer{
+ Spec: VirtualMCPServerSpec{
+ AuthServerConfig: tt.authServerConfig,
+ IncomingAuth: tt.incomingAuth,
+ },
+ }
+ gotName, gotFromDeprecated := vmcp.ExplicitPrimaryUpstreamProvider()
+ assert.Equal(t, tt.wantName, gotName, "name mismatch")
+ assert.Equal(t, tt.wantFromDeprecated, gotFromDeprecated, "fromDeprecated mismatch")
+ })
+ }
+}
+
+// TestAuthzConfigRef_DeprecatedInlinePrimaryUpstreamProvider validates the
+// helper that reads the legacy InlineAuthzConfig.PrimaryUpstreamProvider field.
+// Callers depend on the empty-string return for nil receivers and nil
+// Inline subtrees to keep the deprecation-fallback path safe.
+func TestAuthzConfigRef_DeprecatedInlinePrimaryUpstreamProvider(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ ref *AuthzConfigRef
+ want string
+ }{
+ {name: "nil receiver", ref: nil, want: ""},
+ {name: "nil Inline", ref: &AuthzConfigRef{Type: "configMap"}, want: ""},
+ {name: "Inline without primary", ref: &AuthzConfigRef{
+ Type: "inline",
+ Inline: &InlineAuthzConfig{Policies: []string{`permit(principal, action, resource);`}},
+ }, want: ""},
+ {name: "Inline with primary set", ref: &AuthzConfigRef{
+ Type: "inline",
+ Inline: &InlineAuthzConfig{
+ Policies: []string{`permit(principal, action, resource);`},
+ PrimaryUpstreamProvider: "okta",
+ },
+ }, want: "okta"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ assert.Equal(t, tt.want, tt.ref.DeprecatedInlinePrimaryUpstreamProvider())
+ })
+ }
+}
diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_controller.go b/cmd/thv-operator/controllers/mcpremoteproxy_controller.go
index b832fa17ee..f3da34e04a 100644
--- a/cmd/thv-operator/controllers/mcpremoteproxy_controller.go
+++ b/cmd/thv-operator/controllers/mcpremoteproxy_controller.go
@@ -1091,7 +1091,7 @@ func (r *MCPRemoteProxyReconciler) validateGroupRef(ctx context.Context, proxy *
// Mirrors the validateGroupRef convention: this only sets/removes the
// condition; the caller is responsible for persisting status.
func (*MCPRemoteProxyReconciler) validateAuthzPrimaryUpstreamProviderIgnored(proxy *mcpv1beta1.MCPRemoteProxy) {
- provider := proxy.Spec.AuthzConfig.ExplicitPrimaryUpstreamProvider()
+ provider := proxy.Spec.AuthzConfig.DeprecatedInlinePrimaryUpstreamProvider()
conditionType := mcpv1beta1.ConditionTypeAuthzPrimaryUpstreamProviderIgnored
if provider == "" {
meta.RemoveStatusCondition(&proxy.Status.Conditions, conditionType)
diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_reconciler_test.go b/cmd/thv-operator/controllers/mcpremoteproxy_reconciler_test.go
index 32b4e993d3..c12788caa8 100644
--- a/cmd/thv-operator/controllers/mcpremoteproxy_reconciler_test.go
+++ b/cmd/thv-operator/controllers/mcpremoteproxy_reconciler_test.go
@@ -981,3 +981,103 @@ func TestValidateAndHandleConfigs(t *testing.T) {
})
}
}
+
+// TestMCPRemoteProxy_ValidateAuthzPrimaryUpstreamProviderIgnored locks the
+// advisory condition behaviour on MCPRemoteProxy: the deprecated
+// spec.authzConfig.inline.primaryUpstreamProvider field continues to fire
+// AuthzPrimaryUpstreamProviderIgnored=True after the relocation of the field
+// onto EmbeddedAuthServerConfig, because MCPRemoteProxy has no embedded auth
+// server to act on the value regardless of where it lives. Clearing the field
+// removes the condition.
+func TestMCPRemoteProxy_ValidateAuthzPrimaryUpstreamProviderIgnored(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ authzConfig *mcpv1beta1.AuthzConfigRef
+ preexisting *metav1.Condition
+ wantPresent bool
+ wantReason string
+ wantMessageSubstr string
+ }{
+ {
+ name: "nil AuthzConfig leaves no advisory",
+ authzConfig: nil,
+ wantPresent: false,
+ },
+ {
+ name: "deprecated inline primary set fires the advisory",
+ authzConfig: &mcpv1beta1.AuthzConfigRef{
+ Type: mcpv1beta1.AuthzConfigTypeInline,
+ Inline: &mcpv1beta1.InlineAuthzConfig{
+ Policies: []string{`permit(principal, action, resource);`},
+ PrimaryUpstreamProvider: "okta",
+ },
+ },
+ wantPresent: true,
+ wantReason: mcpv1beta1.ConditionReasonAuthzPrimaryUpstreamProviderIgnored,
+ wantMessageSubstr: `primaryUpstreamProvider="okta"`,
+ },
+ {
+ name: "inline without primary leaves no advisory",
+ authzConfig: &mcpv1beta1.AuthzConfigRef{
+ Type: mcpv1beta1.AuthzConfigTypeInline,
+ Inline: &mcpv1beta1.InlineAuthzConfig{
+ Policies: []string{`permit(principal, action, resource);`},
+ },
+ },
+ wantPresent: false,
+ },
+ {
+ name: "configMap authz with no inline leaves no advisory",
+ authzConfig: &mcpv1beta1.AuthzConfigRef{
+ Type: mcpv1beta1.AuthzConfigTypeConfigMap,
+ ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{Name: "authz-cm"},
+ },
+ wantPresent: false,
+ },
+ {
+ name: "pre-existing advisory is cleared when field is unset",
+ authzConfig: &mcpv1beta1.AuthzConfigRef{
+ Type: mcpv1beta1.AuthzConfigTypeInline,
+ Inline: &mcpv1beta1.InlineAuthzConfig{
+ Policies: []string{`permit(principal, action, resource);`},
+ },
+ },
+ preexisting: &metav1.Condition{
+ Type: mcpv1beta1.ConditionTypeAuthzPrimaryUpstreamProviderIgnored,
+ Status: metav1.ConditionTrue,
+ Reason: mcpv1beta1.ConditionReasonAuthzPrimaryUpstreamProviderIgnored,
+ },
+ wantPresent: false,
+ },
+ }
+
+ r := &MCPRemoteProxyReconciler{}
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ proxy := &mcpv1beta1.MCPRemoteProxy{
+ ObjectMeta: metav1.ObjectMeta{Name: "p", Namespace: "default", Generation: 7},
+ Spec: mcpv1beta1.MCPRemoteProxySpec{AuthzConfig: tt.authzConfig},
+ }
+ if tt.preexisting != nil {
+ proxy.Status.Conditions = []metav1.Condition{*tt.preexisting}
+ }
+ r.validateAuthzPrimaryUpstreamProviderIgnored(proxy)
+
+ cond := meta.FindStatusCondition(proxy.Status.Conditions, mcpv1beta1.ConditionTypeAuthzPrimaryUpstreamProviderIgnored)
+ if !tt.wantPresent {
+ assert.Nil(t, cond, "advisory should be absent")
+ return
+ }
+ require.NotNil(t, cond, "advisory should be set")
+ assert.Equal(t, metav1.ConditionTrue, cond.Status)
+ assert.Equal(t, tt.wantReason, cond.Reason)
+ assert.Equal(t, int64(7), cond.ObservedGeneration)
+ if tt.wantMessageSubstr != "" {
+ assert.Contains(t, cond.Message, tt.wantMessageSubstr)
+ }
+ })
+ }
+}
diff --git a/cmd/thv-operator/controllers/mcpserver_authz_test.go b/cmd/thv-operator/controllers/mcpserver_authz_test.go
index 6c6fd9ba28..7cae812239 100644
--- a/cmd/thv-operator/controllers/mcpserver_authz_test.go
+++ b/cmd/thv-operator/controllers/mcpserver_authz_test.go
@@ -11,6 +11,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -347,3 +348,104 @@ func TestGenerateAuthzVolumeConfig(t *testing.T) {
})
}
}
+
+// TestValidateAuthzPrimaryUpstreamProviderIgnored locks the advisory condition
+// behaviour: when the deprecated
+// spec.authzConfig.inline.primaryUpstreamProvider field is set on an MCPServer,
+// the operator surfaces AuthzPrimaryUpstreamProviderIgnored=True because
+// MCPServer has no embedded auth server to act on the value. Clearing the field
+// removes the condition. This is the only structural backstop against the
+// field's silent no-op on MCPServer; the relocation of primaryUpstreamProvider
+// onto EmbeddedAuthServerConfig in PR #5290 does not affect this advisory.
+func TestValidateAuthzPrimaryUpstreamProviderIgnored(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ authzConfig *mcpv1beta1.AuthzConfigRef
+ preexisting *metav1.Condition
+ wantPresent bool
+ wantReason string
+ wantMessageSubstr string
+ }{
+ {
+ name: "nil AuthzConfig leaves no advisory",
+ authzConfig: nil,
+ wantPresent: false,
+ },
+ {
+ name: "deprecated inline primary set fires the advisory",
+ authzConfig: &mcpv1beta1.AuthzConfigRef{
+ Type: mcpv1beta1.AuthzConfigTypeInline,
+ Inline: &mcpv1beta1.InlineAuthzConfig{
+ Policies: []string{`permit(principal, action, resource);`},
+ PrimaryUpstreamProvider: "okta",
+ },
+ },
+ wantPresent: true,
+ wantReason: mcpv1beta1.ConditionReasonAuthzPrimaryUpstreamProviderIgnored,
+ wantMessageSubstr: `primaryUpstreamProvider="okta"`,
+ },
+ {
+ name: "inline without primary leaves no advisory",
+ authzConfig: &mcpv1beta1.AuthzConfigRef{
+ Type: mcpv1beta1.AuthzConfigTypeInline,
+ Inline: &mcpv1beta1.InlineAuthzConfig{
+ Policies: []string{`permit(principal, action, resource);`},
+ },
+ },
+ wantPresent: false,
+ },
+ {
+ name: "configMap authz with no inline leaves no advisory",
+ authzConfig: &mcpv1beta1.AuthzConfigRef{
+ Type: mcpv1beta1.AuthzConfigTypeConfigMap,
+ ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{Name: "authz-cm"},
+ },
+ wantPresent: false,
+ },
+ {
+ name: "pre-existing advisory is cleared when field is unset",
+ authzConfig: &mcpv1beta1.AuthzConfigRef{
+ Type: mcpv1beta1.AuthzConfigTypeInline,
+ Inline: &mcpv1beta1.InlineAuthzConfig{
+ Policies: []string{`permit(principal, action, resource);`},
+ },
+ },
+ preexisting: &metav1.Condition{
+ Type: mcpv1beta1.ConditionTypeAuthzPrimaryUpstreamProviderIgnored,
+ Status: metav1.ConditionTrue,
+ Reason: mcpv1beta1.ConditionReasonAuthzPrimaryUpstreamProviderIgnored,
+ },
+ wantPresent: false,
+ },
+ }
+
+ r := &MCPServerReconciler{}
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ mcpServer := &mcpv1beta1.MCPServer{
+ ObjectMeta: metav1.ObjectMeta{Name: "m", Namespace: "default", Generation: 7},
+ Spec: mcpv1beta1.MCPServerSpec{AuthzConfig: tt.authzConfig},
+ }
+ if tt.preexisting != nil {
+ mcpServer.Status.Conditions = []metav1.Condition{*tt.preexisting}
+ }
+ r.validateAuthzPrimaryUpstreamProviderIgnored(mcpServer)
+
+ cond := meta.FindStatusCondition(mcpServer.Status.Conditions, mcpv1beta1.ConditionTypeAuthzPrimaryUpstreamProviderIgnored)
+ if !tt.wantPresent {
+ assert.Nil(t, cond, "advisory should be absent")
+ return
+ }
+ require.NotNil(t, cond, "advisory should be set")
+ assert.Equal(t, metav1.ConditionTrue, cond.Status)
+ assert.Equal(t, tt.wantReason, cond.Reason)
+ assert.Equal(t, int64(7), cond.ObservedGeneration)
+ if tt.wantMessageSubstr != "" {
+ assert.Contains(t, cond.Message, tt.wantMessageSubstr)
+ }
+ })
+ }
+}
diff --git a/cmd/thv-operator/controllers/mcpserver_controller.go b/cmd/thv-operator/controllers/mcpserver_controller.go
index eec776c05e..18c834b62f 100644
--- a/cmd/thv-operator/controllers/mcpserver_controller.go
+++ b/cmd/thv-operator/controllers/mcpserver_controller.go
@@ -657,7 +657,7 @@ func (r *MCPServerReconciler) updateCABundleStatus(ctx context.Context, mcpServe
// Mirrors the validateGroupRef convention: this only sets/removes the
// condition; the caller is responsible for persisting status.
func (*MCPServerReconciler) validateAuthzPrimaryUpstreamProviderIgnored(mcpServer *mcpv1beta1.MCPServer) {
- provider := mcpServer.Spec.AuthzConfig.ExplicitPrimaryUpstreamProvider()
+ provider := mcpServer.Spec.AuthzConfig.DeprecatedInlinePrimaryUpstreamProvider()
conditionType := mcpv1beta1.ConditionTypeAuthzPrimaryUpstreamProviderIgnored
if provider == "" {
meta.RemoveStatusCondition(&mcpServer.Status.Conditions, conditionType)
diff --git a/cmd/thv-operator/controllers/virtualmcpserver_authz_validation_test.go b/cmd/thv-operator/controllers/virtualmcpserver_authz_validation_test.go
new file mode 100644
index 0000000000..786f03cfe1
--- /dev/null
+++ b/cmd/thv-operator/controllers/virtualmcpserver_authz_validation_test.go
@@ -0,0 +1,237 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package controllers
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/mock/gomock"
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+ mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1"
+ statusmocks "github.com/stacklok/toolhive/cmd/thv-operator/pkg/virtualmcpserverstatus/mocks"
+)
+
+// validAuthzPayload is a minimal Cedar v1 payload used in passing fixtures.
+const validAuthzPayload = `{
+ "version": "1.0",
+ "type": "cedarv1",
+ "cedar": {
+ "policies": ["permit(principal, action, resource);"],
+ "entities_json": "[]"
+ }
+}`
+
+// vmcpWithAuthzConfigMap returns a minimal VirtualMCPServer that references the named
+// authz ConfigMap. Other fields are omitted; tests only exercise the authz CM validation
+// path so most spec fields are not relevant.
+func vmcpWithAuthzConfigMap(cmName string) *mcpv1beta1.VirtualMCPServer {
+ return &mcpv1beta1.VirtualMCPServer{
+ ObjectMeta: metav1.ObjectMeta{Name: "vmcp", Namespace: "default"},
+ Spec: mcpv1beta1.VirtualMCPServerSpec{
+ GroupRef: &mcpv1beta1.MCPGroupRef{Name: "g"},
+ IncomingAuth: &mcpv1beta1.IncomingAuthConfig{
+ Type: "anonymous",
+ AuthzConfig: &mcpv1beta1.AuthzConfigRef{
+ Type: mcpv1beta1.AuthzConfigTypeConfigMap,
+ ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{Name: cmName, Key: "authz.json"},
+ },
+ },
+ },
+ }
+}
+
+// reconcilerWithObjects wires a VirtualMCPServerReconciler with a fake client containing
+// the supplied objects. The reconciler runs the namespaced-scope branch in tests.
+func reconcilerWithObjects(t *testing.T, objects ...client.Object) *VirtualMCPServerReconciler {
+ t.Helper()
+ scheme := runtime.NewScheme()
+ require.NoError(t, mcpv1beta1.AddToScheme(scheme))
+ require.NoError(t, corev1.AddToScheme(scheme))
+ c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build()
+ return &VirtualMCPServerReconciler{Client: c, Scheme: scheme}
+}
+
+func TestValidateAuthzConfigMapRef(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ vmcp *mcpv1beta1.VirtualMCPServer
+ seedConfigMap *corev1.ConfigMap
+ expectNoError bool
+ expectNotFound bool
+ expectErrMessage string
+ }{
+ {
+ name: "nil authzConfig is a no-op",
+ vmcp: &mcpv1beta1.VirtualMCPServer{ObjectMeta: metav1.ObjectMeta{Name: "v", Namespace: "default"}},
+ expectNoError: true,
+ },
+ {
+ name: "inline authzConfig is a no-op",
+ vmcp: &mcpv1beta1.VirtualMCPServer{
+ ObjectMeta: metav1.ObjectMeta{Name: "v", Namespace: "default"},
+ Spec: mcpv1beta1.VirtualMCPServerSpec{
+ IncomingAuth: &mcpv1beta1.IncomingAuthConfig{
+ AuthzConfig: &mcpv1beta1.AuthzConfigRef{
+ Type: mcpv1beta1.AuthzConfigTypeInline,
+ Inline: &mcpv1beta1.InlineAuthzConfig{Policies: []string{"permit(principal, action, resource);"}},
+ },
+ },
+ },
+ },
+ expectNoError: true,
+ },
+ {
+ name: "configMap reference resolves",
+ vmcp: vmcpWithAuthzConfigMap("authz-cm"),
+ seedConfigMap: &corev1.ConfigMap{
+ ObjectMeta: metav1.ObjectMeta{Name: "authz-cm", Namespace: "default"},
+ Data: map[string]string{"authz.json": validAuthzPayload},
+ },
+ expectNoError: true,
+ },
+ {
+ name: "missing configMap surfaces IsNotFound",
+ vmcp: vmcpWithAuthzConfigMap("authz-cm"),
+ expectNotFound: true,
+ },
+ {
+ name: "malformed payload surfaces a non-NotFound error",
+ vmcp: vmcpWithAuthzConfigMap("authz-cm"),
+ seedConfigMap: &corev1.ConfigMap{
+ ObjectMeta: metav1.ObjectMeta{Name: "authz-cm", Namespace: "default"},
+ Data: map[string]string{"authz.json": "{ not valid"},
+ },
+ expectErrMessage: "failed to parse authz config",
+ },
+ {
+ // Pre-validator and converter must agree on "valid". A payload
+ // that parses as authz.Config and passes the registered-authorizer
+ // validation but isn't Cedar-flavoured (e.g. the HTTP authorizer
+ // registered alongside Cedar) must still fail here on the vMCP
+ // path, or it would pass pre-validation and then fail opaquely at
+ // convert time with a different error.
+ name: "valid authz.Config but non-Cedar type is rejected",
+ vmcp: vmcpWithAuthzConfigMap("authz-cm"),
+ seedConfigMap: &corev1.ConfigMap{
+ ObjectMeta: metav1.ObjectMeta{Name: "authz-cm", Namespace: "default"},
+ Data: map[string]string{
+ "authz.json": `{"version":"1.0","type":"httpv1","pdp":{"http":{"url":"http://localhost:9000"},"claim_mapping":"standard"}}`,
+ },
+ },
+ expectErrMessage: "is not a Cedar config",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ var objs []client.Object
+ if tt.seedConfigMap != nil {
+ objs = append(objs, tt.seedConfigMap)
+ }
+ r := reconcilerWithObjects(t, objs...)
+ err := r.validateAuthzConfigMapRef(t.Context(), tt.vmcp)
+ switch {
+ case tt.expectNoError:
+ require.NoError(t, err)
+ case tt.expectNotFound:
+ require.Error(t, err)
+ assert.True(t, errors.IsNotFound(err),
+ "expected IsNotFound, got %T: %v", err, err)
+ case tt.expectErrMessage != "":
+ require.Error(t, err)
+ assert.False(t, errors.IsNotFound(err), "expected non-NotFound error")
+ assert.Contains(t, err.Error(), tt.expectErrMessage)
+ }
+ })
+ }
+}
+
+// TestEnsureAuthSecretsValid_AuthzConfigMapNotFound verifies that a missing authz
+// ConfigMap produces the ConditionReasonAuthzConfigMapNotFound reason on the
+// AuthConfigured condition, surfacing the diagnostic to the user as a status
+// condition rather than only as a converter error later in the reconcile.
+func TestEnsureAuthSecretsValid_AuthzConfigMapNotFound(t *testing.T) {
+ t.Parallel()
+
+ r := reconcilerWithObjects(t)
+ vmcp := vmcpWithAuthzConfigMap("missing-cm")
+
+ ctrl := gomock.NewController(t)
+ mockMgr := statusmocks.NewMockStatusManager(ctrl)
+ mockMgr.EXPECT().
+ SetAuthConfiguredCondition(
+ mcpv1beta1.ConditionReasonAuthzConfigMapNotFound,
+ gomock.Any(),
+ metav1.ConditionFalse,
+ ).Times(1)
+ mockMgr.EXPECT().SetObservedGeneration(gomock.Any()).Times(1)
+
+ err := r.ensureAuthSecretsValid(t.Context(), vmcp, mockMgr)
+ require.Error(t, err)
+ assert.True(t, errors.IsNotFound(err))
+}
+
+// TestEnsureAuthSecretsValid_AuthzConfigMapInvalid verifies that a malformed (but
+// existing) authz ConfigMap surfaces ConditionReasonAuthzConfigMapInvalid on the
+// AuthConfigured condition.
+func TestEnsureAuthSecretsValid_AuthzConfigMapInvalid(t *testing.T) {
+ t.Parallel()
+
+ cm := &corev1.ConfigMap{
+ ObjectMeta: metav1.ObjectMeta{Name: "authz-cm", Namespace: "default"},
+ Data: map[string]string{"authz.json": "{ not valid"},
+ }
+ r := reconcilerWithObjects(t, cm)
+ vmcp := vmcpWithAuthzConfigMap("authz-cm")
+
+ ctrl := gomock.NewController(t)
+ mockMgr := statusmocks.NewMockStatusManager(ctrl)
+ mockMgr.EXPECT().
+ SetAuthConfiguredCondition(
+ mcpv1beta1.ConditionReasonAuthzConfigMapInvalid,
+ gomock.Any(),
+ metav1.ConditionFalse,
+ ).Times(1)
+ mockMgr.EXPECT().SetObservedGeneration(gomock.Any()).Times(1)
+
+ err := r.ensureAuthSecretsValid(t.Context(), vmcp, mockMgr)
+ require.Error(t, err)
+ assert.False(t, errors.IsNotFound(err))
+}
+
+// TestEnsureAuthSecretsValid_AuthzConfigMapResolves verifies the happy path emits
+// the AuthValid condition when the authz ConfigMap is resolvable.
+func TestEnsureAuthSecretsValid_AuthzConfigMapResolves(t *testing.T) {
+ t.Parallel()
+
+ cm := &corev1.ConfigMap{
+ ObjectMeta: metav1.ObjectMeta{Name: "authz-cm", Namespace: "default"},
+ Data: map[string]string{"authz.json": validAuthzPayload},
+ }
+ r := reconcilerWithObjects(t, cm)
+ vmcp := vmcpWithAuthzConfigMap("authz-cm")
+
+ ctrl := gomock.NewController(t)
+ mockMgr := statusmocks.NewMockStatusManager(ctrl)
+ mockMgr.EXPECT().
+ SetAuthConfiguredCondition(
+ mcpv1beta1.ConditionReasonAuthValid,
+ "Authentication configuration is valid",
+ metav1.ConditionTrue,
+ ).Times(1)
+ mockMgr.EXPECT().SetObservedGeneration(gomock.Any()).Times(1)
+
+ require.NoError(t, r.ensureAuthSecretsValid(t.Context(), vmcp, mockMgr))
+}
diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller.go b/cmd/thv-operator/controllers/virtualmcpserver_controller.go
index 0307f14218..5904753fc9 100644
--- a/cmd/thv-operator/controllers/virtualmcpserver_controller.go
+++ b/cmd/thv-operator/controllers/virtualmcpserver_controller.go
@@ -600,7 +600,26 @@ func rejectAuthzAdmission(
// explicitly, the validator additionally rejects (a) the direct-IdP case (no
// embedded AS) because the field is meaningless without an AS, and (b) any
// name that does not resolve to one of spec.authServerConfig.upstreamProviders.
-func (*VirtualMCPServerReconciler) validateAuthzUpstreamAvailable(
+// emitPrimaryUpstreamProviderDeprecatedEvent emits a Warning event with reason
+// AuthzPrimaryUpstreamProviderDeprecated when the resolved primary upstream
+// provider value came from the deprecated
+// spec.incomingAuth.authzConfig.inline.primaryUpstreamProvider location.
+// Called from every validation branch that observes the explicit provider so
+// the kubectl-visible hint is consistent regardless of the validation outcome.
+func (r *VirtualMCPServerReconciler) emitPrimaryUpstreamProviderDeprecatedEvent(
+ vmcp *mcpv1beta1.VirtualMCPServer,
+ fromDeprecated bool,
+) {
+ if !fromDeprecated || r.Recorder == nil {
+ return
+ }
+ r.Recorder.Eventf(vmcp, nil, corev1.EventTypeWarning,
+ "AuthzPrimaryUpstreamProviderDeprecated", "ResolvePrimaryUpstreamProvider",
+ "spec.incomingAuth.authzConfig.inline.primaryUpstreamProvider is deprecated; "+
+ "move the value to spec.authServerConfig.primaryUpstreamProvider")
+}
+
+func (r *VirtualMCPServerReconciler) validateAuthzUpstreamAvailable(
ctx context.Context,
vmcp *mcpv1beta1.VirtualMCPServer,
statusManager virtualmcpserverstatus.StatusManager,
@@ -623,14 +642,18 @@ func (*VirtualMCPServerReconciler) validateAuthzUpstreamAvailable(
// admission for the same "fail loudly instead of denying every request"
// reason as the configured-AS mismatch path below.
if vmcp.Spec.AuthServerConfig == nil {
- explicitProvider := vmcp.Spec.IncomingAuth.AuthzConfig.ExplicitPrimaryUpstreamProvider()
+ explicitProvider, fromDeprecated := vmcp.ExplicitPrimaryUpstreamProvider()
if explicitProvider != "" {
+ // A user mid-migration may still have the deprecated inline field
+ // set while removing AuthServerConfig (or before configuring it).
+ // Emit the deprecation event here too so the kubectl-visible hint
+ // is consistent across both reject and accept paths.
+ r.emitPrimaryUpstreamProviderDeprecatedEvent(vmcp, fromDeprecated)
message := fmt.Sprintf(
- "spec.incomingAuth.authzConfig.inline.primaryUpstreamProvider=%q is set but "+
- "spec.authServerConfig is not configured. The field names an upstream IDP "+
- "on the embedded auth server, which is required for it to take effect. "+
- "Remove primaryUpstreamProvider, or configure spec.authServerConfig with "+
- "an upstream of that name.",
+ "primaryUpstreamProvider=%q is set but spec.authServerConfig is not configured. "+
+ "The field names an upstream IDP on the embedded auth server, which is required "+
+ "for it to take effect. Remove primaryUpstreamProvider, or configure "+
+ "spec.authServerConfig with an upstream of that name.",
explicitProvider,
)
return rejectAuthzAdmission(ctx, vmcp, statusManager,
@@ -665,11 +688,16 @@ func (*VirtualMCPServerReconciler) validateAuthzUpstreamAvailable(
)
}
- // If the user has set spec.incomingAuth.authzConfig.inline.primaryUpstreamProvider
- // explicitly, the name must resolve to one of the declared upstreams after
- // normalization on both sides. A mismatch would cause Cedar to deny every
- // request at runtime — fail loudly at admission instead.
- explicitProvider := vmcp.Spec.IncomingAuth.AuthzConfig.ExplicitPrimaryUpstreamProvider()
+ // If the user has set primaryUpstreamProvider explicitly (either on the
+ // canonical spec.authServerConfig location or on the deprecated
+ // spec.incomingAuth.authzConfig.inline location), the name must resolve to
+ // one of the declared upstreams after normalization on both sides. A
+ // mismatch would cause Cedar to deny every request at runtime — fail loudly
+ // at admission instead.
+ explicitProvider, fromDeprecated := vmcp.ExplicitPrimaryUpstreamProvider()
+ if explicitProvider != "" {
+ r.emitPrimaryUpstreamProviderDeprecatedEvent(vmcp, fromDeprecated)
+ }
if explicitProvider != "" {
resolved := authserver.ResolveUpstreamName(explicitProvider)
matched := slices.ContainsFunc(
@@ -680,10 +708,10 @@ func (*VirtualMCPServerReconciler) validateAuthzUpstreamAvailable(
)
if !matched {
message := fmt.Sprintf(
- "spec.incomingAuth.authzConfig.inline.primaryUpstreamProvider=%q does not "+
- "match any upstream declared on spec.authServerConfig.upstreamProviders. "+
- "Set primaryUpstreamProvider to one of the configured upstream names, or "+
- "leave it empty to default to the first upstream.",
+ "primaryUpstreamProvider=%q does not match any upstream declared on "+
+ "spec.authServerConfig.upstreamProviders. Set primaryUpstreamProvider "+
+ "to one of the configured upstream names, or leave it empty to default "+
+ "to the first upstream.",
explicitProvider,
)
return rejectAuthzAdmission(ctx, vmcp, statusManager,
@@ -1035,14 +1063,18 @@ func (r *VirtualMCPServerReconciler) ensureAllResources(
return ctrl.Result{}, nil
}
-// ensureAuthSecretsValid validates secret references and sets the AuthConfigured condition.
+// ensureAuthSecretsValid validates secret references and the authz ConfigMap reference
+// (when configured), and sets the AuthConfigured condition. Catches configuration errors
+// early so the user gets a status-level diagnostic instead of an opaque conversion error
+// or, worse, a silently degraded runtime.
func (r *VirtualMCPServerReconciler) ensureAuthSecretsValid(
ctx context.Context,
vmcp *mcpv1beta1.VirtualMCPServer,
statusManager virtualmcpserverstatus.StatusManager,
) error {
+ ctxLogger := log.FromContext(ctx)
+
if err := r.validateSecretReferences(ctx, vmcp); err != nil {
- ctxLogger := log.FromContext(ctx)
ctxLogger.Error(err, "Secret validation failed")
statusManager.SetAuthConfiguredCondition(
mcpv1beta1.ConditionReasonAuthInvalid,
@@ -1057,6 +1089,27 @@ func (r *VirtualMCPServerReconciler) ensureAuthSecretsValid(
return err
}
+ if err := r.validateAuthzConfigMapRef(ctx, vmcp); err != nil {
+ ctxLogger.Error(err, "Authz ConfigMap validation failed")
+ reason := mcpv1beta1.ConditionReasonAuthzConfigMapInvalid
+ eventReason := "AuthzConfigMapInvalid"
+ if errors.IsNotFound(err) {
+ reason = mcpv1beta1.ConditionReasonAuthzConfigMapNotFound
+ eventReason = "AuthzConfigMapNotFound"
+ }
+ statusManager.SetAuthConfiguredCondition(
+ reason,
+ fmt.Sprintf("Authorization ConfigMap is invalid: %v", err),
+ metav1.ConditionFalse,
+ )
+ statusManager.SetObservedGeneration(vmcp.Generation)
+ if r.Recorder != nil {
+ r.Recorder.Eventf(vmcp, nil, corev1.EventTypeWarning, eventReason, "ValidateAuthzConfigMap",
+ "Authz ConfigMap validation failed: %v", err)
+ }
+ return err
+ }
+
statusManager.SetAuthConfiguredCondition(
mcpv1beta1.ConditionReasonAuthValid,
"Authentication configuration is valid",
diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go
index 4e80568a9c..1b49635ab9 100644
--- a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go
+++ b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go
@@ -29,6 +29,7 @@ import (
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/intstr"
+ "k8s.io/client-go/tools/events"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
@@ -3538,6 +3539,9 @@ func TestDiscoveredRBACRulesIncludeMCPServerEntries(t *testing.T) {
func TestVirtualMCPServerValidateAuthzUpstreamAvailable(t *testing.T) {
t.Parallel()
+ // inlineAuthzRef is the baseline inline authz config used by tests that
+ // place the explicit primary on the canonical spec.authServerConfig
+ // location.
inlineAuthzRef := &mcpv1beta1.AuthzConfigRef{
Type: "inline",
Inline: &mcpv1beta1.InlineAuthzConfig{
@@ -3545,9 +3549,11 @@ func TestVirtualMCPServerValidateAuthzUpstreamAvailable(t *testing.T) {
},
}
- // authzRefWithPrimary builds an inline authz ref with an explicit
- // PrimaryUpstreamProvider — used to exercise the override branch.
- authzRefWithPrimary := func(primary string) *mcpv1beta1.AuthzConfigRef {
+ // authzRefWithDeprecatedInlinePrimary builds an inline authz ref that
+ // sets PrimaryUpstreamProvider on the deprecated InlineAuthzConfig field.
+ // Used to exercise the backward-compatibility fallback path on
+ // ExplicitPrimaryUpstreamProvider.
+ authzRefWithDeprecatedInlinePrimary := func(primary string) *mcpv1beta1.AuthzConfigRef {
return &mcpv1beta1.AuthzConfigRef{
Type: "inline",
Inline: &mcpv1beta1.InlineAuthzConfig{
@@ -3652,7 +3658,7 @@ func TestVirtualMCPServerValidateAuthzUpstreamAvailable(t *testing.T) {
name: "explicit primary provider matching an upstream is valid",
incomingAuth: &mcpv1beta1.IncomingAuthConfig{
Type: "oidc",
- AuthzConfig: authzRefWithPrimary("entra"),
+ AuthzConfig: inlineAuthzRef,
},
authServerConfig: &mcpv1beta1.EmbeddedAuthServerConfig{
Issuer: "https://authserver.example.com",
@@ -3660,6 +3666,7 @@ func TestVirtualMCPServerValidateAuthzUpstreamAvailable(t *testing.T) {
{Name: "okta", Type: mcpv1beta1.UpstreamProviderTypeOIDC},
{Name: "entra", Type: mcpv1beta1.UpstreamProviderTypeOIDC},
},
+ PrimaryUpstreamProvider: "entra",
},
expectedWarning: warningExpectation{expectPresent: false},
},
@@ -3669,7 +3676,7 @@ func TestVirtualMCPServerValidateAuthzUpstreamAvailable(t *testing.T) {
name: "explicit primary provider suppresses multi-upstream advisory",
incomingAuth: &mcpv1beta1.IncomingAuthConfig{
Type: "oidc",
- AuthzConfig: authzRefWithPrimary("okta"),
+ AuthzConfig: inlineAuthzRef,
},
authServerConfig: &mcpv1beta1.EmbeddedAuthServerConfig{
Issuer: "https://authserver.example.com",
@@ -3678,6 +3685,7 @@ func TestVirtualMCPServerValidateAuthzUpstreamAvailable(t *testing.T) {
{Name: "entra", Type: mcpv1beta1.UpstreamProviderTypeOIDC},
{Name: "google", Type: mcpv1beta1.UpstreamProviderTypeOIDC},
},
+ PrimaryUpstreamProvider: "okta",
},
expectedWarning: warningExpectation{expectPresent: false},
},
@@ -3688,7 +3696,7 @@ func TestVirtualMCPServerValidateAuthzUpstreamAvailable(t *testing.T) {
name: "explicit primary provider not matching any upstream is invalid",
incomingAuth: &mcpv1beta1.IncomingAuthConfig{
Type: "oidc",
- AuthzConfig: authzRefWithPrimary("ping"),
+ AuthzConfig: inlineAuthzRef,
},
authServerConfig: &mcpv1beta1.EmbeddedAuthServerConfig{
Issuer: "https://authserver.example.com",
@@ -3696,6 +3704,7 @@ func TestVirtualMCPServerValidateAuthzUpstreamAvailable(t *testing.T) {
{Name: "okta", Type: mcpv1beta1.UpstreamProviderTypeOIDC},
{Name: "entra", Type: mcpv1beta1.UpstreamProviderTypeOIDC},
},
+ PrimaryUpstreamProvider: "ping",
},
expectError: true,
expectedReason: mcpv1beta1.ConditionReasonAuthzUpstreamUnknown,
@@ -3707,11 +3716,13 @@ func TestVirtualMCPServerValidateAuthzUpstreamAvailable(t *testing.T) {
// the embedded AS — without an AS there is nothing for it to refer
// to, and the converter would otherwise forward an unresolvable
// name. Distinct condition reason from the upstream-mismatch case
- // so tooling can route the two misconfigurations separately.
+ // so tooling can route the two misconfigurations separately. With
+ // authServerConfig=nil the canonical location can't carry the
+ // field, so this case exercises the deprecated inline fallback.
name: "explicit primary provider without embedded auth server is invalid",
incomingAuth: &mcpv1beta1.IncomingAuthConfig{
Type: "oidc",
- AuthzConfig: authzRefWithPrimary("okta"),
+ AuthzConfig: authzRefWithDeprecatedInlinePrimary("okta"),
},
authServerConfig: nil,
expectError: true,
@@ -3796,6 +3807,143 @@ func TestVirtualMCPServerValidateAuthzUpstreamAvailable(t *testing.T) {
}
}
+// TestVirtualMCPServerValidateAuthzUpstreamAvailable_DeprecationEvent confirms
+// that when validateAuthzUpstreamAvailable resolves the primary upstream from
+// the deprecated spec.incomingAuth.authzConfig.inline.primaryUpstreamProvider
+// location, a Warning event is recorded with reason
+// AuthzPrimaryUpstreamProviderDeprecated. The canonical location does not emit
+// the event. The event is the only user-visible signal that the deprecated
+// field is being read, so its emission must remain test-locked.
+func TestVirtualMCPServerValidateAuthzUpstreamAvailable_DeprecationEvent(t *testing.T) {
+ t.Parallel()
+
+ inlineAuthzRefWithDeprecatedPrimary := &mcpv1beta1.AuthzConfigRef{
+ Type: "inline",
+ Inline: &mcpv1beta1.InlineAuthzConfig{
+ Policies: []string{`permit(principal, action, resource);`},
+ PrimaryUpstreamProvider: "okta",
+ },
+ }
+ inlineAuthzRef := &mcpv1beta1.AuthzConfigRef{
+ Type: "inline",
+ Inline: &mcpv1beta1.InlineAuthzConfig{
+ Policies: []string{`permit(principal, action, resource);`},
+ },
+ }
+
+ tests := []struct {
+ name string
+ incomingAuth *mcpv1beta1.IncomingAuthConfig
+ authServerConfig *mcpv1beta1.EmbeddedAuthServerConfig
+ wantEvent bool
+ wantError bool
+ }{
+ {
+ name: "deprecated inline primary emits the deprecation event",
+ incomingAuth: &mcpv1beta1.IncomingAuthConfig{
+ Type: "oidc",
+ AuthzConfig: inlineAuthzRefWithDeprecatedPrimary,
+ },
+ authServerConfig: &mcpv1beta1.EmbeddedAuthServerConfig{
+ Issuer: "https://authserver.example.com",
+ UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{
+ {Name: "okta", Type: mcpv1beta1.UpstreamProviderTypeOIDC},
+ },
+ },
+ wantEvent: true,
+ },
+ {
+ name: "canonical authServerConfig primary does not emit the event",
+ incomingAuth: &mcpv1beta1.IncomingAuthConfig{
+ Type: "oidc",
+ AuthzConfig: inlineAuthzRef,
+ },
+ authServerConfig: &mcpv1beta1.EmbeddedAuthServerConfig{
+ Issuer: "https://authserver.example.com",
+ UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{
+ {Name: "okta", Type: mcpv1beta1.UpstreamProviderTypeOIDC},
+ },
+ PrimaryUpstreamProvider: "okta",
+ },
+ wantEvent: false,
+ },
+ {
+ name: "no explicit primary does not emit the event",
+ incomingAuth: &mcpv1beta1.IncomingAuthConfig{
+ Type: "oidc",
+ AuthzConfig: inlineAuthzRef,
+ },
+ authServerConfig: &mcpv1beta1.EmbeddedAuthServerConfig{
+ Issuer: "https://authserver.example.com",
+ UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{
+ {Name: "okta", Type: mcpv1beta1.UpstreamProviderTypeOIDC},
+ },
+ },
+ wantEvent: false,
+ },
+ {
+ // Mid-migration: user removed AuthServerConfig (or hasn't added it
+ // yet) but still has the deprecated inline field set. The
+ // validator rejects (no auth server to anchor the provider
+ // against), but the deprecation hint must still fire so the user
+ // sees what to fix.
+ name: "deprecated inline primary in no-auth-server branch emits the event before reject",
+ incomingAuth: &mcpv1beta1.IncomingAuthConfig{
+ Type: "oidc",
+ AuthzConfig: inlineAuthzRefWithDeprecatedPrimary,
+ },
+ authServerConfig: nil,
+ wantEvent: true,
+ wantError: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ vmcp := &mcpv1beta1.VirtualMCPServer{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: testVmcpName,
+ Namespace: "default",
+ Generation: 1,
+ },
+ Spec: mcpv1beta1.VirtualMCPServerSpec{
+ GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName},
+ IncomingAuth: tt.incomingAuth,
+ AuthServerConfig: tt.authServerConfig,
+ },
+ }
+
+ recorder := events.NewFakeRecorder(10)
+ r := &VirtualMCPServerReconciler{Recorder: recorder}
+ statusManager := virtualmcpserverstatus.NewStatusManager(vmcp)
+ err := r.validateAuthzUpstreamAvailable(t.Context(), vmcp, statusManager)
+ if tt.wantError {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+
+ select {
+ case event := <-recorder.Events:
+ if !tt.wantEvent {
+ t.Errorf("expected no event, got %q", event)
+ return
+ }
+ assert.Contains(t, event, "Warning")
+ assert.Contains(t, event, "AuthzPrimaryUpstreamProviderDeprecated")
+ assert.Contains(t, event,
+ "spec.incomingAuth.authzConfig.inline.primaryUpstreamProvider is deprecated")
+ case <-time.After(50 * time.Millisecond):
+ if tt.wantEvent {
+ t.Errorf("expected AuthzPrimaryUpstreamProviderDeprecated event, none recorded")
+ }
+ }
+ })
+ }
+}
+
// TestVirtualMCPServerValidateAuthzUpstreamAvailable_ClearsStaleWarning verifies
// the transition case: a VMCP that was previously multi-upstream (advisory True
// on its status) is reconfigured to a single upstream, and the stale advisory
@@ -3955,8 +4103,7 @@ func TestVirtualMCPServerValidateAuthzUpstreamAvailable_ClearsStaleAuthzUnknown(
authzRef := &mcpv1beta1.AuthzConfigRef{
Type: "inline",
Inline: &mcpv1beta1.InlineAuthzConfig{
- Policies: []string{`permit(principal, action, resource);`},
- PrimaryUpstreamProvider: "okta",
+ Policies: []string{`permit(principal, action, resource);`},
},
}
@@ -3972,12 +4119,13 @@ func TestVirtualMCPServerValidateAuthzUpstreamAvailable_ClearsStaleAuthzUnknown(
Type: "oidc",
AuthzConfig: authzRef,
},
- // Spec is now valid: explicit "okta" matches a declared upstream.
+ // Spec is now valid: explicit "okta" matches the single declared upstream.
AuthServerConfig: &mcpv1beta1.EmbeddedAuthServerConfig{
Issuer: "https://authserver.example.com",
UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{
{Name: "okta", Type: mcpv1beta1.UpstreamProviderTypeOIDC},
},
+ PrimaryUpstreamProvider: "okta",
},
},
Status: mcpv1beta1.VirtualMCPServerStatus{
diff --git a/cmd/thv-operator/controllers/virtualmcpserver_deployment.go b/cmd/thv-operator/controllers/virtualmcpserver_deployment.go
index d42e6a7e63..6cf5061d4b 100644
--- a/cmd/thv-operator/controllers/virtualmcpserver_deployment.go
+++ b/cmd/thv-operator/controllers/virtualmcpserver_deployment.go
@@ -1106,6 +1106,40 @@ func (*VirtualMCPServerReconciler) validateBackendAuthSecrets(
return nil
}
+// validateAuthzConfigMapRef pre-validates the referenced authz ConfigMap when
+// spec.incomingAuth.authzConfig.type is "configMap". It uses the same shared loader
+// as the converter so the diagnostic surfaces the same parse/validation errors the
+// converter would later produce, but earlier in the reconcile and as a status condition
+// rather than as a generic conversion failure. Inline and absent authzConfig are no-ops.
+//
+// Also runs ExtractCedarAuthzOptions on the loaded payload so a configMap that
+// parses as a valid authz.Config but isn't Cedar-flavoured (wrong "type" field,
+// or a future HTTP authorizer) is rejected here with AuthzConfigMapInvalid
+// rather than passing pre-validation and then failing opaquely at convert time.
+//
+// Mirrors the pattern in mcpremoteproxy_controller.go's validateK8sRefs.
+func (r *VirtualMCPServerReconciler) validateAuthzConfigMapRef(
+ ctx context.Context,
+ vmcp *mcpv1beta1.VirtualMCPServer,
+) error {
+ if vmcp.Spec.IncomingAuth == nil ||
+ vmcp.Spec.IncomingAuth.AuthzConfig == nil ||
+ vmcp.Spec.IncomingAuth.AuthzConfig.Type != mcpv1beta1.AuthzConfigTypeConfigMap {
+ return nil
+ }
+ cfg, err := ctrlutil.LoadAuthzConfigFromConfigMap(
+ ctx, r.Client, vmcp.Namespace, vmcp.Spec.IncomingAuth.AuthzConfig,
+ )
+ if err != nil {
+ return err
+ }
+ if _, err := ctrlutil.ExtractCedarAuthzOptions(cfg); err != nil {
+ return fmt.Errorf("authz ConfigMap %s/%s is not a Cedar config: %w",
+ vmcp.Namespace, vmcp.Spec.IncomingAuth.AuthzConfig.ConfigMap.Name, err)
+ }
+ return nil
+}
+
// validateSecretKeyRef validates that a secret reference exists and contains the required key.
// This implements the validation pattern from ctrlutil.GenerateOIDCClientSecretEnvVar().
func (r *VirtualMCPServerReconciler) validateSecretKeyRef(
diff --git a/cmd/thv-operator/pkg/controllerutil/authz.go b/cmd/thv-operator/pkg/controllerutil/authz.go
index ccb5e5caaa..51fb5d2782 100644
--- a/cmd/thv-operator/pkg/controllerutil/authz.go
+++ b/cmd/thv-operator/pkg/controllerutil/authz.go
@@ -166,36 +166,169 @@ func EnsureAuthzConfigMap(
return nil
}
-func addAuthzInlineConfigOptions(
- authzRef *mcpv1beta1.AuthzConfigRef,
- options *[]runner.RunConfigBuilderOption,
-) error {
- if authzRef.Inline == nil {
- return fmt.Errorf("inline authz config type specified but inline config is nil")
+// BuildInlineCedarAuthzConfig constructs an *authz.Config from the inline
+// section of an AuthzConfigRef, threading the JWT-claim mapping fields on the
+// parent AuthzConfigRef (GroupClaimName, RoleClaimName, GroupEntityType) into
+// cedar.ConfigOptions. The returned config maintains backwards compatibility
+// with the v1.0 Cedar schema.
+//
+// This helper is exposed so callers and tests can inspect the resulting
+// *authz.Config without going through the runner builder.
+func BuildInlineCedarAuthzConfig(authzRef *mcpv1beta1.AuthzConfigRef) (*authz.Config, error) {
+ if authzRef == nil || authzRef.Inline == nil {
+ return nil, fmt.Errorf("inline authz config type specified but inline config is nil")
}
-
- policies := authzRef.Inline.Policies
- entitiesJSON := authzRef.Inline.EntitiesJSON
-
- // Create authorization config using the full config structure
- // This maintains backwards compatibility with the v1.0 schema
authzCfg, err := authz.NewConfig(cedar.Config{
Version: "v1",
Type: cedar.ConfigType,
Options: &cedar.ConfigOptions{
- Policies: policies,
- EntitiesJSON: entitiesJSON,
+ Policies: authzRef.Inline.Policies,
+ EntitiesJSON: authzRef.Inline.EntitiesJSON,
+ GroupClaimName: authzRef.GroupClaimName,
+ RoleClaimName: authzRef.RoleClaimName,
+ GroupEntityType: authzRef.GroupEntityType,
},
})
if err != nil {
- return fmt.Errorf("failed to create authz config: %w", err)
+ return nil, fmt.Errorf("failed to create authz config: %w", err)
}
+ return authzCfg, nil
+}
- // Add authorization config to options
+func addAuthzInlineConfigOptions(
+ authzRef *mcpv1beta1.AuthzConfigRef,
+ options *[]runner.RunConfigBuilderOption,
+) error {
+ authzCfg, err := BuildInlineCedarAuthzConfig(authzRef)
+ if err != nil {
+ return err
+ }
*options = append(*options, runner.WithAuthzConfig(authzCfg))
return nil
}
+// LoadAuthzConfigFromConfigMap fetches the ConfigMap referenced by authzRef, parses its
+// payload as an authz.Config (YAML or JSON), and validates the result. It is the shared
+// resolver used by both the MCPServer/MCPRemoteProxy runner path (via AddAuthzConfigOptions)
+// and the VirtualMCPServer converter.
+//
+// Failure modes (all returned as errors, never silently succeed):
+// - authzRef nil or not of type "configMap"
+// - ConfigMap reference missing name
+// - kubernetes client not configured
+// - ConfigMap not found, missing key, empty value, or malformed payload
+// - authz.Config fails validation
+//
+// The returned *authz.Config is safe to embed directly into RunConfig (via
+// runner.WithAuthzConfig) or to read field-by-field for the vMCP converter.
+func LoadAuthzConfigFromConfigMap(
+ ctx context.Context,
+ c client.Client,
+ namespace string,
+ authzRef *mcpv1beta1.AuthzConfigRef,
+) (*authz.Config, error) {
+ if authzRef == nil || authzRef.Type != mcpv1beta1.AuthzConfigTypeConfigMap {
+ return nil, fmt.Errorf("authzRef is not of type %q", mcpv1beta1.AuthzConfigTypeConfigMap)
+ }
+ if authzRef.ConfigMap == nil || authzRef.ConfigMap.Name == "" {
+ return nil, fmt.Errorf("configMap authz config type specified but reference is missing name")
+ }
+ if c == nil {
+ return nil, fmt.Errorf("kubernetes client is not configured for ConfigMap authz resolution")
+ }
+
+ key := authzRef.ConfigMap.Key
+ if key == "" {
+ key = DefaultAuthzKey
+ }
+
+ var cm corev1.ConfigMap
+ if err := c.Get(ctx, types.NamespacedName{
+ Namespace: namespace,
+ Name: authzRef.ConfigMap.Name,
+ }, &cm); err != nil {
+ return nil, fmt.Errorf("failed to get Authz ConfigMap %s/%s: %w", namespace, authzRef.ConfigMap.Name, err)
+ }
+
+ raw, ok := cm.Data[key]
+ if !ok {
+ return nil, fmt.Errorf("authz ConfigMap %s/%s is missing key %q", namespace, authzRef.ConfigMap.Name, key)
+ }
+ if len(strings.TrimSpace(raw)) == 0 {
+ return nil, fmt.Errorf("authz ConfigMap %s/%s key %q is empty", namespace, authzRef.ConfigMap.Name, key)
+ }
+
+ // YAML unmarshal also handles JSON; the explicit JSON fallback gives a clearer error
+ // message when both parsers reject the payload.
+ var cfg authz.Config
+ if err := yaml.Unmarshal([]byte(raw), &cfg); err != nil {
+ if err2 := json.Unmarshal([]byte(raw), &cfg); err2 != nil {
+ return nil, fmt.Errorf("failed to parse authz config from ConfigMap %s/%s key %q: %w; json fallback error: %w",
+ namespace, authzRef.ConfigMap.Name, key, err, err2)
+ }
+ }
+
+ if err := cfg.Validate(); err != nil {
+ return nil, fmt.Errorf("invalid authz config from ConfigMap %s/%s key %q: %w",
+ namespace, authzRef.ConfigMap.Name, key, err)
+ }
+
+ return &cfg, nil
+}
+
+// ExtractCedarAuthzOptions unwraps the Cedar-specific options embedded in an
+// authz.Config. Returns an error when cfg is nil or is not a Cedar config
+// (e.g. a future HTTP authorizer); callers that need to handle non-Cedar
+// configs gracefully should treat the error as "not Cedar, pass through".
+//
+// This wrapper exists so callers outside pkg/authz can avoid importing
+// pkg/authz/authorizers/cedar directly, keeping the Cedar dependency localised
+// to the resolver layer.
+func ExtractCedarAuthzOptions(cfg *authz.Config) (*cedar.ConfigOptions, error) {
+ if cfg == nil {
+ return nil, fmt.Errorf("authz config is nil")
+ }
+ cedarCfg, err := cedar.ExtractConfig(cfg)
+ if err != nil {
+ return nil, err
+ }
+ return cedarCfg.Options, nil
+}
+
+// ApplyClaimMappingOverrides returns a new *authz.Config with the spec-level
+// JWT-claim mapping fields (GroupClaimName, RoleClaimName, GroupEntityType)
+// from authzRef applied on top of the Cedar options inside cfg. Empty
+// spec-level fields do not override the ConfigMap-supplied values, so the
+// ConfigMap remains the data-plane source for these knobs when the spec is
+// silent.
+//
+// When cfg is not a Cedar config, or authzRef has no overrides set, cfg is
+// returned unchanged. This makes the helper safe to call unconditionally on
+// the runner path after LoadAuthzConfigFromConfigMap.
+func ApplyClaimMappingOverrides(cfg *authz.Config, authzRef *mcpv1beta1.AuthzConfigRef) (*authz.Config, error) {
+ if cfg == nil || authzRef == nil {
+ return cfg, nil
+ }
+ if authzRef.GroupClaimName == "" && authzRef.RoleClaimName == "" && authzRef.GroupEntityType == "" {
+ return cfg, nil
+ }
+ cedarCfg, err := cedar.ExtractConfig(cfg)
+ if err != nil {
+ // Non-Cedar configs have nothing to override; pass through.
+ return cfg, nil
+ }
+ if authzRef.GroupClaimName != "" {
+ cedarCfg.Options.GroupClaimName = authzRef.GroupClaimName
+ }
+ if authzRef.RoleClaimName != "" {
+ cedarCfg.Options.RoleClaimName = authzRef.RoleClaimName
+ }
+ if authzRef.GroupEntityType != "" {
+ cedarCfg.Options.GroupEntityType = authzRef.GroupEntityType
+ }
+ return authz.NewConfig(*cedarCfg)
+}
+
// AddAuthzConfigOptions adds authorization configuration options to builder options
func AddAuthzConfigOptions(
ctx context.Context,
@@ -213,59 +346,21 @@ func AddAuthzConfigOptions(
return addAuthzInlineConfigOptions(authzRef, options)
case mcpv1beta1.AuthzConfigTypeConfigMap:
- // Validate reference
- if authzRef.ConfigMap == nil || authzRef.ConfigMap.Name == "" {
- return fmt.Errorf("configMap authz config type specified but reference is missing name")
- }
- key := authzRef.ConfigMap.Key
- if key == "" {
- key = DefaultAuthzKey
- }
-
- // Ensure we have a Kubernetes client to fetch the ConfigMap
- if c == nil {
- return fmt.Errorf("kubernetes client is not configured for ConfigMap authz resolution")
+ cfg, err := LoadAuthzConfigFromConfigMap(ctx, c, namespace, authzRef)
+ if err != nil {
+ return err
}
-
- // Fetch the ConfigMap
- var cm corev1.ConfigMap
- if err := c.Get(ctx, types.NamespacedName{
- Namespace: namespace,
- Name: authzRef.ConfigMap.Name,
- }, &cm); err != nil {
- return fmt.Errorf("failed to get Authz ConfigMap %s/%s: %w", namespace, authzRef.ConfigMap.Name, err)
+ // Apply spec-over-ConfigMap precedence for JWT-claim mapping fields so
+ // MCPServer / MCPRemoteProxy users get the same override semantics the
+ // CRD docstring on AuthzConfigRef promises.
+ cfg, err = ApplyClaimMappingOverrides(cfg, authzRef)
+ if err != nil {
+ return fmt.Errorf("failed to apply claim mapping overrides: %w", err)
}
-
- raw, ok := cm.Data[key]
- if !ok {
- return fmt.Errorf("authz ConfigMap %s/%s is missing key %q", namespace, authzRef.ConfigMap.Name, key)
- }
- if len(strings.TrimSpace(raw)) == 0 {
- return fmt.Errorf("authz ConfigMap %s/%s key %q is empty", namespace, authzRef.ConfigMap.Name, key)
- }
-
- // Unmarshal into authz.Config supporting YAML or JSON
- var cfg authz.Config
- // Try YAML first (it also handles JSON)
- if err := yaml.Unmarshal([]byte(raw), &cfg); err != nil {
- // Fallback to JSON explicitly for clearer error paths
- if err2 := json.Unmarshal([]byte(raw), &cfg); err2 != nil {
- return fmt.Errorf("failed to parse authz config from ConfigMap %s/%s key %q: %w; json fallback error: %w",
- namespace, authzRef.ConfigMap.Name, key, err, err2)
- }
- }
-
- // Validate the config
- if err := cfg.Validate(); err != nil {
- return fmt.Errorf("invalid authz config from ConfigMap %s/%s key %q: %w",
- namespace, authzRef.ConfigMap.Name, key, err)
- }
-
- *options = append(*options, runner.WithAuthzConfig(&cfg))
+ *options = append(*options, runner.WithAuthzConfig(cfg))
return nil
default:
- // Unknown type
return fmt.Errorf("unknown authz config type: %s", authzRef.Type)
}
}
diff --git a/cmd/thv-operator/pkg/controllerutil/authz_test.go b/cmd/thv-operator/pkg/controllerutil/authz_test.go
index 0485ebba3e..8effa521c2 100644
--- a/cmd/thv-operator/pkg/controllerutil/authz_test.go
+++ b/cmd/thv-operator/pkg/controllerutil/authz_test.go
@@ -16,6 +16,8 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client/fake"
mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1"
+ "github.com/stacklok/toolhive/pkg/authz"
+ "github.com/stacklok/toolhive/pkg/authz/authorizers/cedar"
"github.com/stacklok/toolhive/pkg/runner"
)
@@ -685,3 +687,250 @@ func getKey(namespace, name string) struct {
Name string
}{Namespace: namespace, Name: name}
}
+
+// TestBuildInlineCedarAuthzConfig verifies that the JWT-claim mapping fields
+// declared on the parent AuthzConfigRef (GroupClaimName, RoleClaimName,
+// GroupEntityType) are threaded into the cedar.ConfigOptions of the inline
+// runner path. The CRD docstring on AuthzConfigRef promises spec-over-ConfigMap
+// override semantics for these fields; this test locks the inline half of that
+// promise on the MCPServer / MCPRemoteProxy runner code path, where they were
+// previously dropped on the floor.
+func TestBuildInlineCedarAuthzConfig(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ authzRef *mcpv1beta1.AuthzConfigRef
+ wantOpts *cedar.ConfigOptions
+ expectErr string
+ }{
+ {
+ name: "nil ref returns error",
+ authzRef: nil,
+ expectErr: "inline authz config type specified but inline config is nil",
+ },
+ {
+ name: "nil Inline subtree returns error",
+ authzRef: &mcpv1beta1.AuthzConfigRef{
+ Type: mcpv1beta1.AuthzConfigTypeInline,
+ },
+ expectErr: "inline authz config type specified but inline config is nil",
+ },
+ {
+ name: "claim-mapping fields flow into cedar options",
+ authzRef: &mcpv1beta1.AuthzConfigRef{
+ Type: mcpv1beta1.AuthzConfigTypeInline,
+ Inline: &mcpv1beta1.InlineAuthzConfig{
+ Policies: []string{`permit(principal, action, resource);`},
+ EntitiesJSON: `[{"uid":{"type":"ClaimGroup","id":"eng"}}]`,
+ },
+ GroupClaimName: "groups",
+ RoleClaimName: "roles",
+ GroupEntityType: "ClaimGroup",
+ },
+ wantOpts: &cedar.ConfigOptions{
+ Policies: []string{`permit(principal, action, resource);`},
+ EntitiesJSON: `[{"uid":{"type":"ClaimGroup","id":"eng"}}]`,
+ GroupClaimName: "groups",
+ RoleClaimName: "roles",
+ GroupEntityType: "ClaimGroup",
+ },
+ },
+ {
+ name: "unset claim-mapping fields stay empty",
+ authzRef: &mcpv1beta1.AuthzConfigRef{
+ Type: mcpv1beta1.AuthzConfigTypeInline,
+ Inline: &mcpv1beta1.InlineAuthzConfig{
+ Policies: []string{`permit(principal, action, resource);`},
+ },
+ },
+ wantOpts: &cedar.ConfigOptions{
+ Policies: []string{`permit(principal, action, resource);`},
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ cfg, err := BuildInlineCedarAuthzConfig(tt.authzRef)
+ if tt.expectErr != "" {
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tt.expectErr)
+ return
+ }
+ require.NoError(t, err)
+ got, err := ExtractCedarAuthzOptions(cfg)
+ require.NoError(t, err)
+ assert.Equal(t, tt.wantOpts.Policies, got.Policies)
+ assert.Equal(t, tt.wantOpts.EntitiesJSON, got.EntitiesJSON)
+ assert.Equal(t, tt.wantOpts.GroupClaimName, got.GroupClaimName)
+ assert.Equal(t, tt.wantOpts.RoleClaimName, got.RoleClaimName)
+ assert.Equal(t, tt.wantOpts.GroupEntityType, got.GroupEntityType)
+ })
+ }
+}
+
+// TestApplyClaimMappingOverrides verifies the spec-over-ConfigMap precedence
+// the CRD docstring on AuthzConfigRef promises for JWT-claim mapping fields.
+// The ConfigMap payload contributes a baseline; non-empty spec-level fields on
+// AuthzConfigRef override field-by-field. Empty spec-level fields preserve the
+// ConfigMap value (no accidental clobbering).
+func TestApplyClaimMappingOverrides(t *testing.T) {
+ t.Parallel()
+
+ baseCfg := func(t *testing.T, opts cedar.ConfigOptions) *authz.Config {
+ t.Helper()
+ cfg, err := authz.NewConfig(cedar.Config{
+ Version: "v1",
+ Type: cedar.ConfigType,
+ Options: &opts,
+ })
+ require.NoError(t, err)
+ return cfg
+ }
+
+ tests := []struct {
+ name string
+ cfg func(t *testing.T) *authz.Config
+ authzRef *mcpv1beta1.AuthzConfigRef
+ wantOpts cedar.ConfigOptions
+ }{
+ {
+ name: "nil ref leaves cedar options untouched",
+ cfg: func(t *testing.T) *authz.Config {
+ t.Helper()
+ return baseCfg(t, cedar.ConfigOptions{
+ Policies: []string{`permit(principal, action, resource);`},
+ GroupClaimName: "cm-groups",
+ })
+ },
+ authzRef: nil,
+ wantOpts: cedar.ConfigOptions{
+ Policies: []string{`permit(principal, action, resource);`},
+ GroupClaimName: "cm-groups",
+ },
+ },
+ {
+ name: "no overrides on ref leaves cedar options untouched",
+ cfg: func(t *testing.T) *authz.Config {
+ t.Helper()
+ return baseCfg(t, cedar.ConfigOptions{
+ Policies: []string{`permit(principal, action, resource);`},
+ GroupClaimName: "cm-groups",
+ GroupEntityType: "CMGroup",
+ })
+ },
+ authzRef: &mcpv1beta1.AuthzConfigRef{Type: mcpv1beta1.AuthzConfigTypeConfigMap},
+ wantOpts: cedar.ConfigOptions{
+ Policies: []string{`permit(principal, action, resource);`},
+ GroupClaimName: "cm-groups",
+ GroupEntityType: "CMGroup",
+ },
+ },
+ {
+ name: "spec GroupClaimName overrides ConfigMap value",
+ cfg: func(t *testing.T) *authz.Config {
+ t.Helper()
+ return baseCfg(t, cedar.ConfigOptions{
+ Policies: []string{`permit(principal, action, resource);`},
+ GroupClaimName: "cm-groups",
+ })
+ },
+ authzRef: &mcpv1beta1.AuthzConfigRef{
+ Type: mcpv1beta1.AuthzConfigTypeConfigMap,
+ GroupClaimName: "spec-groups",
+ },
+ wantOpts: cedar.ConfigOptions{
+ Policies: []string{`permit(principal, action, resource);`},
+ GroupClaimName: "spec-groups",
+ },
+ },
+ {
+ name: "all three spec fields override; unset fields keep ConfigMap value",
+ cfg: func(t *testing.T) *authz.Config {
+ t.Helper()
+ return baseCfg(t, cedar.ConfigOptions{
+ Policies: []string{`permit(principal, action, resource);`},
+ GroupClaimName: "cm-groups",
+ RoleClaimName: "cm-roles",
+ GroupEntityType: "CMGroup",
+ })
+ },
+ authzRef: &mcpv1beta1.AuthzConfigRef{
+ Type: mcpv1beta1.AuthzConfigTypeConfigMap,
+ GroupClaimName: "spec-groups",
+ GroupEntityType: "SpecGroup",
+ // RoleClaimName intentionally left unset to verify CM fallback.
+ },
+ wantOpts: cedar.ConfigOptions{
+ Policies: []string{`permit(principal, action, resource);`},
+ GroupClaimName: "spec-groups",
+ RoleClaimName: "cm-roles",
+ GroupEntityType: "SpecGroup",
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ cfg := tt.cfg(t)
+ got, err := ApplyClaimMappingOverrides(cfg, tt.authzRef)
+ require.NoError(t, err)
+ require.NotNil(t, got)
+ opts, err := ExtractCedarAuthzOptions(got)
+ require.NoError(t, err)
+ assert.Equal(t, tt.wantOpts.Policies, opts.Policies)
+ assert.Equal(t, tt.wantOpts.GroupClaimName, opts.GroupClaimName)
+ assert.Equal(t, tt.wantOpts.RoleClaimName, opts.RoleClaimName)
+ assert.Equal(t, tt.wantOpts.GroupEntityType, opts.GroupEntityType)
+ })
+ }
+}
+
+// TestApplyClaimMappingOverrides_NilConfig is a defense-in-depth check that the
+// helper does not panic on a nil cfg input. Callers on the runner path should
+// not pass nil, but the override is invoked unconditionally after the loader
+// so the contract is "safe to call".
+func TestApplyClaimMappingOverrides_NilConfig(t *testing.T) {
+ t.Parallel()
+ got, err := ApplyClaimMappingOverrides(nil, &mcpv1beta1.AuthzConfigRef{
+ Type: mcpv1beta1.AuthzConfigTypeConfigMap,
+ GroupClaimName: "spec-groups",
+ })
+ require.NoError(t, err)
+ assert.Nil(t, got)
+}
+
+// TestExtractCedarAuthzOptions covers the unwrap helper used by both the
+// runner path (via ApplyClaimMappingOverrides) and the vMCP converter. The
+// helper localises the dependency on pkg/authz/authorizers/cedar so callers
+// outside pkg/authz do not have to import it directly.
+func TestExtractCedarAuthzOptions(t *testing.T) {
+ t.Parallel()
+
+ t.Run("happy path returns the embedded cedar options", func(t *testing.T) {
+ t.Parallel()
+ cfg, err := authz.NewConfig(cedar.Config{
+ Version: "v1",
+ Type: cedar.ConfigType,
+ Options: &cedar.ConfigOptions{
+ Policies: []string{`permit(principal, action, resource);`},
+ EntitiesJSON: `[]`,
+ },
+ })
+ require.NoError(t, err)
+ opts, err := ExtractCedarAuthzOptions(cfg)
+ require.NoError(t, err)
+ require.NotNil(t, opts)
+ assert.Equal(t, []string{`permit(principal, action, resource);`}, opts.Policies)
+ })
+
+ t.Run("nil config returns error", func(t *testing.T) {
+ t.Parallel()
+ opts, err := ExtractCedarAuthzOptions(nil)
+ require.Error(t, err)
+ assert.Nil(t, opts)
+ })
+}
diff --git a/cmd/thv-operator/pkg/vmcpconfig/converter.go b/cmd/thv-operator/pkg/vmcpconfig/converter.go
index 4d309dee6f..0f96878b67 100644
--- a/cmd/thv-operator/pkg/vmcpconfig/converter.go
+++ b/cmd/thv-operator/pkg/vmcpconfig/converter.go
@@ -174,71 +174,150 @@ func (c *Converter) convertIncomingAuth(
// Convert authorization configuration
if vmcp.Spec.IncomingAuth.AuthzConfig != nil {
- // Map Kubernetes API types to vmcp config types
- // API "inline" maps to vmcp "cedar"
- authzType := vmcp.Spec.IncomingAuth.AuthzConfig.Type
- if authzType == authzLabelValueInline {
- authzType = "cedar"
+ authz, err := c.convertAuthzConfig(ctx, vmcp)
+ if err != nil {
+ return nil, err
}
+ incoming.Authz = authz
+ }
+
+ return incoming, nil
+}
- incoming.Authz = &vmcpconfig.AuthzConfig{
- Type: authzType,
+// convertAuthzConfig resolves the AuthzConfig from either the inline spec or a
+// referenced ConfigMap and applies spec-level overrides for the source-agnostic
+// Cedar JWT-claim mapping fields (GroupClaimName, RoleClaimName,
+// GroupEntityType). PrimaryUpstreamProvider lives on
+// spec.authServerConfig.primaryUpstreamProvider and is resolved separately by
+// resolvePrimaryUpstreamProvider. The ConfigMap path uses the shared loader so
+// the same fetch/parse/validate path is exercised here as in the
+// MCPServer/MCPRemoteProxy runner flow.
+func (c *Converter) convertAuthzConfig(
+ ctx context.Context,
+ vmcp *mcpv1beta1.VirtualMCPServer,
+) (*vmcpconfig.AuthzConfig, error) {
+ authzRef := vmcp.Spec.IncomingAuth.AuthzConfig
+
+ // Both "inline" and "configMap" map to vmcp "cedar"; the difference is the
+ // source the policies are loaded from. Unknown values are rejected by the
+ // default arm of the switch below.
+ authz := &vmcpconfig.AuthzConfig{Type: "cedar"}
+
+ // Pull policy content from the chosen source.
+ switch authzRef.Type {
+ case authzLabelValueInline:
+ if authzRef.Inline != nil {
+ authz.Policies = authzRef.Inline.Policies
+ authz.EntitiesJSON = authzRef.Inline.EntitiesJSON
}
- // Handle inline policies
- if vmcp.Spec.IncomingAuth.AuthzConfig.Type == authzLabelValueInline && vmcp.Spec.IncomingAuth.AuthzConfig.Inline != nil {
- incoming.Authz.Policies = vmcp.Spec.IncomingAuth.AuthzConfig.Inline.Policies
+ case mcpv1beta1.AuthzConfigTypeConfigMap:
+ loaded, err := controllerutil.LoadAuthzConfigFromConfigMap(ctx, c.k8sClient, vmcp.Namespace, authzRef)
+ if err != nil {
+ return nil, err
}
- // TODO(#5208): Load policies from ConfigMap if Type is "configMap"
-
- // When an embedded auth server with upstream providers is configured, Cedar
- // policies must evaluate claims from the upstream IDP token rather than the
- // ToolHive-issued AS token. Mirrors injectSubjectProviderIfNeeded in
- // virtualmcpserver_controller.go (outgoing auth) and
- // injectUpstreamProviderIfNeeded in pkg/runner/middleware.go (thv run path).
- // Leaving PrimaryUpstreamProvider empty (no upstreams configured AND no
- // explicit override) lets Cedar fall back to claims from the
- // ToolHive-issued token.
- //
- // When the user has set spec.incomingAuth.authzConfig.inline.primaryUpstreamProvider
- // explicitly, honor it (after normalization). Otherwise fall back to the first
- // configured upstream — matching the SubjectProviderName precedent on the
- // token-exchange and AWS-STS strategies.
- //
- // validateAuthzUpstreamAvailable is the primary user-facing fail-loud point
- // for explicit-provider misconfigurations; the defense-in-depth check below
- // ensures Convert cannot produce an unresolvable PrimaryUpstreamProvider
- // even if invoked outside the reconcile flow (CLI dry-run, webhook, test
- // harness).
- // TODO(#5208): load primaryUpstreamProvider from configMap
- if explicit := vmcp.Spec.IncomingAuth.AuthzConfig.ExplicitPrimaryUpstreamProvider(); explicit != "" {
- resolved := authserver.ResolveUpstreamName(explicit)
- if vmcp.Spec.AuthServerConfig == nil {
- return nil, fmt.Errorf(
- "authz primaryUpstreamProvider %q set without an embedded auth server "+
- "(spec.authServerConfig must be configured)", explicit)
- }
- matched := false
- for _, up := range vmcp.Spec.AuthServerConfig.UpstreamProviders {
- if authserver.ResolveUpstreamName(up.Name) == resolved {
- matched = true
- break
- }
- }
- if !matched {
- return nil, fmt.Errorf(
- "authz primaryUpstreamProvider %q does not match any upstream declared "+
- "on spec.authServerConfig.upstreamProviders", explicit)
+ opts, err := controllerutil.ExtractCedarAuthzOptions(loaded)
+ if err != nil {
+ return nil, fmt.Errorf("authz ConfigMap %s/%s is not a Cedar config: %w",
+ vmcp.Namespace, authzRef.ConfigMap.Name, err)
+ }
+ authz.Policies = opts.Policies
+ authz.EntitiesJSON = opts.EntitiesJSON
+ // 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
+ // the ConfigMap payload here.
+ authz.GroupClaimName = opts.GroupClaimName
+ authz.RoleClaimName = opts.RoleClaimName
+ authz.GroupEntityType = opts.GroupEntityType
+
+ default:
+ // Defense in depth. The CRD enum (configMap;inline) blocks unknown
+ // values at admission today, but the converter is also reachable from
+ // CLI dry-runs, webhooks, and test harnesses where a stale schema or
+ // a future authz source could slip through. Failing here keeps Cedar
+ // from being constructed with an empty policy set (which silently
+ // denies every request).
+ return nil, fmt.Errorf("unsupported authz config type %q", authzRef.Type)
+ }
+
+ // Spec-level overrides for source-agnostic fields. Spec wins over ConfigMap
+ // when both are set — the spec is the control-plane source of truth and the
+ // ConfigMap is data-plane content that may be written by an external
+ // controller.
+ if authzRef.GroupClaimName != "" {
+ authz.GroupClaimName = authzRef.GroupClaimName
+ }
+ if authzRef.RoleClaimName != "" {
+ authz.RoleClaimName = authzRef.RoleClaimName
+ }
+ if authzRef.GroupEntityType != "" {
+ authz.GroupEntityType = authzRef.GroupEntityType
+ }
+
+ if err := c.resolvePrimaryUpstreamProvider(vmcp, authz); err != nil {
+ return nil, err
+ }
+
+ return authz, nil
+}
+
+// resolvePrimaryUpstreamProvider finalises authz.PrimaryUpstreamProvider.
+// Precedence:
+//
+// 1. spec.authServerConfig.primaryUpstreamProvider (canonical home).
+// 2. spec.incomingAuth.authzConfig.inline.primaryUpstreamProvider (deprecated,
+// read for backward compatibility).
+// 3. First entry of spec.authServerConfig.upstreamProviders, auto-selected.
+//
+// The resolved value is validated against the declared embedded auth server
+// upstreams; an unresolvable value is rejected.
+//
+// validateAuthzUpstreamAvailable in virtualmcpserver_controller.go is the
+// primary user-facing fail-loud point for explicit-provider misconfigurations
+// and also emits the deprecation Warning event. The defense-in-depth check
+// here ensures Convert cannot produce an unresolvable PrimaryUpstreamProvider
+// even if invoked outside the reconcile flow (CLI dry-run, webhook, test
+// harness).
+//
+// Leaving PrimaryUpstreamProvider empty (no upstreams configured AND no
+// explicit override) lets Cedar fall back to claims from the ToolHive-issued
+// token, which matches the historical behaviour before embedded auth servers
+// were supported.
+func (*Converter) resolvePrimaryUpstreamProvider(
+ vmcp *mcpv1beta1.VirtualMCPServer,
+ authz *vmcpconfig.AuthzConfig,
+) error {
+ explicit, _ := vmcp.ExplicitPrimaryUpstreamProvider()
+ if explicit != "" {
+ resolved := authserver.ResolveUpstreamName(explicit)
+ if vmcp.Spec.AuthServerConfig == nil {
+ return fmt.Errorf(
+ "authz primaryUpstreamProvider %q set without an embedded auth server "+
+ "(spec.authServerConfig must be configured)", explicit)
+ }
+ matched := false
+ for _, up := range vmcp.Spec.AuthServerConfig.UpstreamProviders {
+ if authserver.ResolveUpstreamName(up.Name) == resolved {
+ matched = true
+ break
}
- incoming.Authz.PrimaryUpstreamProvider = resolved
- } else if vmcp.Spec.AuthServerConfig != nil && len(vmcp.Spec.AuthServerConfig.UpstreamProviders) > 0 {
- incoming.Authz.PrimaryUpstreamProvider = authserver.ResolveUpstreamName(
- vmcp.Spec.AuthServerConfig.UpstreamProviders[0].Name,
- )
}
+ if !matched {
+ return fmt.Errorf(
+ "authz primaryUpstreamProvider %q does not match any upstream declared "+
+ "on spec.authServerConfig.upstreamProviders", explicit)
+ }
+ authz.PrimaryUpstreamProvider = resolved
+ return nil
}
- return incoming, nil
+ if vmcp.Spec.AuthServerConfig != nil && len(vmcp.Spec.AuthServerConfig.UpstreamProviders) > 0 {
+ authz.PrimaryUpstreamProvider = authserver.ResolveUpstreamName(
+ vmcp.Spec.AuthServerConfig.UpstreamProviders[0].Name,
+ )
+ }
+ return nil
}
// resolveOIDCConfig resolves OIDC configuration from an MCPOIDCConfig reference.
diff --git a/cmd/thv-operator/pkg/vmcpconfig/converter_authz_configmap_test.go b/cmd/thv-operator/pkg/vmcpconfig/converter_authz_configmap_test.go
new file mode 100644
index 0000000000..442f72fc1b
--- /dev/null
+++ b/cmd/thv-operator/pkg/vmcpconfig/converter_authz_configmap_test.go
@@ -0,0 +1,370 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package vmcpconfig
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/go-logr/logr"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+ "sigs.k8s.io/controller-runtime/pkg/log"
+
+ mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1"
+ vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config"
+)
+
+// newAuthzConfigMap builds a ConfigMap whose data[key] contains the given Cedar-v1 payload.
+func newAuthzConfigMap(name, namespace, key, payload string) *corev1.ConfigMap {
+ return &corev1.ConfigMap{
+ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
+ Data: map[string]string{key: payload},
+ }
+}
+
+// authServerCfg captures the embedded auth server configuration a test wants to
+// attach to its VirtualMCPServer fixture. Empty Upstreams leaves AuthServerConfig
+// unset on the spec.
+type authServerCfg struct {
+ upstreams []string
+ primary string
+}
+
+// newAuthzVmcpForConfigMap builds a VirtualMCPServer that references the named authz
+// ConfigMap. The optional authServerCfg controls whether the spec carries an
+// EmbeddedAuthServerConfig and which upstream is named as primary.
+func newAuthzVmcpForConfigMap(
+ cmName, cmKey string,
+ auth authServerCfg,
+ authzRefMutate func(r *mcpv1beta1.AuthzConfigRef),
+) *mcpv1beta1.VirtualMCPServer {
+ authzRef := &mcpv1beta1.AuthzConfigRef{
+ Type: mcpv1beta1.AuthzConfigTypeConfigMap,
+ ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{
+ Name: cmName,
+ Key: cmKey,
+ },
+ }
+ if authzRefMutate != nil {
+ authzRefMutate(authzRef)
+ }
+
+ vmcp := &mcpv1beta1.VirtualMCPServer{
+ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"},
+ Spec: mcpv1beta1.VirtualMCPServerSpec{
+ GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"},
+ IncomingAuth: &mcpv1beta1.IncomingAuthConfig{
+ Type: "anonymous",
+ AuthzConfig: authzRef,
+ },
+ },
+ }
+ if len(auth.upstreams) > 0 {
+ ups := make([]mcpv1beta1.UpstreamProviderConfig, 0, len(auth.upstreams))
+ for _, name := range auth.upstreams {
+ ups = append(ups, mcpv1beta1.UpstreamProviderConfig{Name: name})
+ }
+ vmcp.Spec.AuthServerConfig = &mcpv1beta1.EmbeddedAuthServerConfig{
+ UpstreamProviders: ups,
+ PrimaryUpstreamProvider: auth.primary,
+ }
+ }
+ return vmcp
+}
+
+// mustJSON encodes the value to a JSON string; failures panic since these are static
+// payloads in tests.
+func mustJSON(v any) string {
+ b, err := json.Marshal(v)
+ if err != nil {
+ panic(err)
+ }
+ return string(b)
+}
+
+// newCedarV1Payload returns the canonical Cedar v1 JSON payload, optionally overriding
+// individual fields via the mutator. Defaults match the enterprise authz.json shape.
+func newCedarV1Payload(mutate func(m map[string]any)) string {
+ m := map[string]any{
+ "version": "1.0",
+ "type": "cedarv1",
+ "cedar": map[string]any{
+ "policies": []string{`permit(principal, action, resource);`},
+ "entities_json": `[]`,
+ },
+ }
+ if mutate != nil {
+ mutate(m)
+ }
+ return mustJSON(m)
+}
+
+// converterWithObjects wires a Converter with both the VirtualMCPServer's runtime
+// dependencies (OIDC resolver) and arbitrary k8s objects (ConfigMaps for authz).
+func converterWithObjects(t *testing.T, objects ...client.Object) *Converter {
+ t.Helper()
+ scheme := runtime.NewScheme()
+ require.NoError(t, mcpv1beta1.AddToScheme(scheme))
+ require.NoError(t, corev1.AddToScheme(scheme))
+ k8sClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build()
+ converter, err := NewConverter(newNoOpMockResolver(t), k8sClient)
+ require.NoError(t, err)
+ return converter
+}
+
+// TestConvertAuthzConfig_ConfigMapPath verifies the converter resolves the authz
+// ConfigMap via the shared loader and surfaces all Cedar fields end-to-end.
+func TestConvertAuthzConfig_ConfigMapPath(t *testing.T) {
+ t.Parallel()
+
+ const cmName = "authz-cm"
+ const cmKey = "authz.json"
+ const ns = "default"
+
+ tests := []struct {
+ name string
+ payload func() string // payload written to the configMap
+ mutateAuthzRef func(r *mcpv1beta1.AuthzConfigRef)
+ authServer authServerCfg
+ expectErr string
+ validate func(t *testing.T, authz *vmcpconfig.AuthzConfig)
+ }{
+ {
+ name: "success: full payload round-trips into vmcp config",
+ payload: func() string {
+ return newCedarV1Payload(func(m map[string]any) {
+ m["cedar"] = map[string]any{
+ "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",
+ }
+ })
+ },
+ validate: func(t *testing.T, authz *vmcpconfig.AuthzConfig) {
+ t.Helper()
+ require.Equal(t, "cedar", authz.Type)
+ require.Len(t, authz.Policies, 1)
+ assert.Contains(t, authz.Policies[0], `ClaimGroup::"engineering"`)
+ assert.Contains(t, authz.EntitiesJSON, `"ClaimGroup"`)
+ assert.Equal(t, "groups", authz.GroupClaimName)
+ assert.Equal(t, "roles", authz.RoleClaimName)
+ assert.Equal(t, "ClaimGroup", authz.GroupEntityType)
+ },
+ },
+ {
+ name: "spec-level override wins over ConfigMap value",
+ payload: func() string {
+ return newCedarV1Payload(func(m map[string]any) {
+ m["cedar"] = map[string]any{
+ "policies": []string{`permit(principal, action, resource);`},
+ "entities_json": `[]`,
+ "group_claim_name": "cm-groups",
+ "role_claim_name": "cm-roles",
+ "group_entity_type": "CMGroup",
+ }
+ })
+ },
+ mutateAuthzRef: func(r *mcpv1beta1.AuthzConfigRef) {
+ r.GroupClaimName = "spec-groups"
+ r.GroupEntityType = "SpecGroup"
+ // RoleClaimName intentionally left unset on spec to assert ConfigMap fallback survives.
+ },
+ validate: func(t *testing.T, authz *vmcpconfig.AuthzConfig) {
+ t.Helper()
+ assert.Equal(t, "spec-groups", authz.GroupClaimName)
+ assert.Equal(t, "cm-roles", authz.RoleClaimName)
+ assert.Equal(t, "SpecGroup", authz.GroupEntityType)
+ },
+ },
+ {
+ // primaryUpstreamProvider now lives on spec.authServerConfig, not in
+ // the ConfigMap payload. A primary_upstream_provider entry in the
+ // payload is silently ignored: authz.PrimaryUpstreamProvider takes
+ // the spec value (first upstream auto-selected when spec is empty).
+ name: "primary_upstream_provider in ConfigMap payload is ignored",
+ payload: func() string {
+ return newCedarV1Payload(func(m map[string]any) {
+ m["cedar"] = map[string]any{
+ "policies": []string{`permit(principal, action, resource);`},
+ "entities_json": `[]`,
+ "primary_upstream_provider": "google",
+ }
+ })
+ },
+ authServer: authServerCfg{upstreams: []string{"okta"}},
+ validate: func(t *testing.T, authz *vmcpconfig.AuthzConfig) {
+ t.Helper()
+ assert.Equal(t, "okta", authz.PrimaryUpstreamProvider)
+ },
+ },
+ {
+ // spec.authServerConfig.primaryUpstreamProvider is the only source
+ // for the canonical primary value when the converter is on the
+ // configMap path.
+ name: "spec.authServerConfig.primaryUpstreamProvider is honored on configMap path",
+ payload: func() string {
+ return newCedarV1Payload(nil)
+ },
+ authServer: authServerCfg{upstreams: []string{"okta", "github"}, primary: "github"},
+ validate: func(t *testing.T, authz *vmcpconfig.AuthzConfig) {
+ t.Helper()
+ assert.Equal(t, "github", authz.PrimaryUpstreamProvider)
+ },
+ },
+ {
+ name: "missing configMap returns error",
+ payload: nil, // do not create the configMap
+ expectErr: `failed to get Authz ConfigMap default/authz-cm`,
+ },
+ {
+ name: "missing key returns error",
+ payload: func() string {
+ // create the configMap but under a different key
+ return ""
+ },
+ expectErr: `is missing key "authz.json"`,
+ },
+ {
+ name: "empty value returns error",
+ payload: func() string {
+ return " "
+ },
+ expectErr: `is empty`,
+ },
+ {
+ name: "malformed payload returns error",
+ payload: func() string {
+ return "{ this is not valid json or yaml"
+ },
+ expectErr: `failed to parse authz config from ConfigMap`,
+ },
+ {
+ // spec.authServerConfig.primaryUpstreamProvider that doesn't match
+ // any declared upstream is rejected at convert time.
+ name: "spec primary provider not matching any upstream is rejected",
+ payload: func() string {
+ return newCedarV1Payload(nil)
+ },
+ authServer: authServerCfg{upstreams: []string{"okta"}, primary: "google"},
+ expectErr: `does not match any upstream declared on spec.authServerConfig.upstreamProviders`,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ var objs []client.Object
+ if tt.payload != nil {
+ key := cmKey
+ payload := tt.payload()
+ // "missing key" case: store under a different key.
+ if tt.name == "missing key returns error" {
+ key = "other.json"
+ payload = newCedarV1Payload(nil)
+ }
+ objs = append(objs, newAuthzConfigMap(cmName, ns, key, payload))
+ }
+ vmcp := newAuthzVmcpForConfigMap(cmName, cmKey, tt.authServer, tt.mutateAuthzRef)
+ ctx := log.IntoContext(t.Context(), logr.Discard())
+ converter := converterWithObjects(t, objs...)
+
+ cfg, _, err := converter.Convert(ctx, vmcp, nil)
+ if tt.expectErr != "" {
+ require.Error(t, err)
+ assert.True(t, strings.Contains(err.Error(), tt.expectErr),
+ "expected error containing %q, got %q", tt.expectErr, err.Error())
+ return
+ }
+ require.NoError(t, err)
+ require.NotNil(t, cfg.IncomingAuth)
+ require.NotNil(t, cfg.IncomingAuth.Authz)
+ if tt.validate != nil {
+ tt.validate(t, (*vmcpconfig.AuthzConfig)(cfg.IncomingAuth.Authz))
+ }
+ })
+ }
+}
+
+// TestConvertAuthzConfig_InlinePath_NewFieldsSourcedFromAuthzConfigRef confirms that
+// after the schema lift the source-agnostic Cedar settings (group claim name etc.)
+// flow from AuthzConfigRef directly into the vmcp config for inline-mode users.
+func TestConvertAuthzConfig_InlinePath_NewFieldsSourcedFromAuthzConfigRef(t *testing.T) {
+ t.Parallel()
+
+ vmcp := &mcpv1beta1.VirtualMCPServer{
+ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"},
+ Spec: mcpv1beta1.VirtualMCPServerSpec{
+ GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"},
+ IncomingAuth: &mcpv1beta1.IncomingAuthConfig{
+ Type: "anonymous",
+ AuthzConfig: &mcpv1beta1.AuthzConfigRef{
+ Type: mcpv1beta1.AuthzConfigTypeInline,
+ Inline: &mcpv1beta1.InlineAuthzConfig{
+ Policies: []string{`permit(principal, action, resource);`},
+ EntitiesJSON: `[{"uid":{"type":"ClaimGroup","id":"engineering"}}]`,
+ },
+ GroupClaimName: "groups",
+ RoleClaimName: "roles",
+ GroupEntityType: "ClaimGroup",
+ },
+ },
+ },
+ }
+
+ ctx := log.IntoContext(t.Context(), logr.Discard())
+ converter := converterWithObjects(t)
+ cfg, _, err := converter.Convert(ctx, vmcp, nil)
+ require.NoError(t, err)
+ require.NotNil(t, cfg.IncomingAuth)
+ require.NotNil(t, cfg.IncomingAuth.Authz)
+
+ authz := cfg.IncomingAuth.Authz
+ assert.Equal(t, "cedar", authz.Type)
+ assert.Equal(t, []string{`permit(principal, action, resource);`}, authz.Policies)
+ assert.Equal(t, `[{"uid":{"type":"ClaimGroup","id":"engineering"}}]`, authz.EntitiesJSON)
+ assert.Equal(t, "groups", authz.GroupClaimName)
+ assert.Equal(t, "roles", authz.RoleClaimName)
+ assert.Equal(t, "ClaimGroup", authz.GroupEntityType)
+}
+
+// TestConvertAuthzConfig_UnknownTypeIsRejected covers the default arm of the
+// type switch in convertAuthzConfig. The CRD enum already blocks unknown
+// values at admission, but the converter is also reachable from CLI dry-runs,
+// webhooks, and test harnesses where a stale schema could slip an unknown
+// type through. Returning an error keeps Cedar from being constructed with an
+// empty policy set, which would silently deny every request.
+func TestConvertAuthzConfig_UnknownTypeIsRejected(t *testing.T) {
+ t.Parallel()
+
+ vmcp := &mcpv1beta1.VirtualMCPServer{
+ ObjectMeta: metav1.ObjectMeta{Name: "test-vmcp", Namespace: "default"},
+ Spec: mcpv1beta1.VirtualMCPServerSpec{
+ GroupRef: &mcpv1beta1.MCPGroupRef{Name: "test-group"},
+ IncomingAuth: &mcpv1beta1.IncomingAuthConfig{
+ Type: "anonymous",
+ AuthzConfig: &mcpv1beta1.AuthzConfigRef{
+ Type: "future-authorizer",
+ },
+ },
+ },
+ }
+
+ ctx := log.IntoContext(t.Context(), logr.Discard())
+ converter := converterWithObjects(t)
+ _, _, err := converter.Convert(ctx, vmcp, nil)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), `unsupported authz config type "future-authorizer"`)
+}
diff --git a/cmd/thv-operator/pkg/vmcpconfig/converter_test.go b/cmd/thv-operator/pkg/vmcpconfig/converter_test.go
index 19ec8bc77d..52efe5c42d 100644
--- a/cmd/thv-operator/pkg/vmcpconfig/converter_test.go
+++ b/cmd/thv-operator/pkg/vmcpconfig/converter_test.go
@@ -1944,21 +1944,17 @@ func TestConverter_TelemetryConfigRef(t *testing.T) {
// evaluates claims from the upstream IDP token rather than the ToolHive-issued
// AS token. Without this, policies referencing upstream claims (e.g. "department")
// fail at runtime because Cedar reads the wrong token. Also verifies that the
-// user-supplied spec.incomingAuth.authzConfig.inline.primaryUpstreamProvider
-// overrides the auto-selected first upstream when set.
+// user-supplied spec.authServerConfig.primaryUpstreamProvider overrides the
+// auto-selected first upstream when set.
func TestConvertIncomingAuth_PrimaryUpstreamProvider(t *testing.T) {
t.Parallel()
- authzWith := func(primary string) *mcpv1beta1.AuthzConfigRef {
- return &mcpv1beta1.AuthzConfigRef{
- Type: "inline",
- Inline: &mcpv1beta1.InlineAuthzConfig{
- Policies: []string{`permit(principal, action, resource);`},
- PrimaryUpstreamProvider: primary,
- },
- }
+ inlineAuthzRef := &mcpv1beta1.AuthzConfigRef{
+ Type: "inline",
+ Inline: &mcpv1beta1.InlineAuthzConfig{
+ Policies: []string{`permit(principal, action, resource);`},
+ },
}
- inlineAuthzRef := authzWith("")
tests := []struct {
name string
@@ -2047,8 +2043,9 @@ func TestConvertIncomingAuth_PrimaryUpstreamProvider(t *testing.T) {
UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{
{Name: "okta", Type: mcpv1beta1.UpstreamProviderTypeOIDC},
},
+ PrimaryUpstreamProvider: "okta",
},
- authzConfig: authzWith("okta"),
+ authzConfig: inlineAuthzRef,
expectedProvider: "okta",
},
{
@@ -2062,8 +2059,9 @@ func TestConvertIncomingAuth_PrimaryUpstreamProvider(t *testing.T) {
{Name: "github", Type: mcpv1beta1.UpstreamProviderTypeOAuth2},
{Name: "google", Type: mcpv1beta1.UpstreamProviderTypeOIDC},
},
+ PrimaryUpstreamProvider: "github",
},
- authzConfig: authzWith("github"),
+ authzConfig: inlineAuthzRef,
expectedProvider: "github",
},
{
@@ -2080,8 +2078,9 @@ func TestConvertIncomingAuth_PrimaryUpstreamProvider(t *testing.T) {
UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{
{Name: "", Type: mcpv1beta1.UpstreamProviderTypeOIDC},
},
+ PrimaryUpstreamProvider: "default",
},
- authzConfig: authzWith("default"),
+ authzConfig: inlineAuthzRef,
expectedProvider: "default",
},
{
@@ -2089,11 +2088,19 @@ func TestConvertIncomingAuth_PrimaryUpstreamProvider(t *testing.T) {
// (CLI dry-run, webhook, test harness), Convert refuses to produce
// an unresolvable PrimaryUpstreamProvider. The validator rejection
// is the primary user-facing fail-loud point; this case locks the
- // converter-side defense in.
+ // converter-side defense in. The provider is on the deprecated
+ // location to keep the rejection wired through ExplicitPrimaryUpstream
+ // Provider's fallback path.
name: "explicit primary provider without auth server is rejected",
authServerConfig: nil,
- authzConfig: authzWith("okta"),
- expectError: true,
+ authzConfig: &mcpv1beta1.AuthzConfigRef{
+ Type: "inline",
+ Inline: &mcpv1beta1.InlineAuthzConfig{
+ Policies: []string{`permit(principal, action, resource);`},
+ PrimaryUpstreamProvider: "okta",
+ },
+ },
+ expectError: true,
},
{
name: "explicit primary provider that doesn't match any upstream is rejected",
@@ -2102,10 +2109,54 @@ func TestConvertIncomingAuth_PrimaryUpstreamProvider(t *testing.T) {
UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{
{Name: "okta", Type: mcpv1beta1.UpstreamProviderTypeOIDC},
},
+ PrimaryUpstreamProvider: "ping",
},
- authzConfig: authzWith("ping"),
+ authzConfig: inlineAuthzRef,
expectError: true,
},
+ {
+ // Backward-compatibility: the deprecated inline field is read when
+ // the canonical location is empty, with no auth server set this is
+ // rejected by the defense-in-depth check above; this case validates
+ // the deprecated value flows through when the canonical is empty and
+ // matches a declared upstream.
+ name: "deprecated inline primary provider is honored when canonical is empty",
+ authServerConfig: &mcpv1beta1.EmbeddedAuthServerConfig{
+ Issuer: "https://authserver.example.com",
+ UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{
+ {Name: "okta", Type: mcpv1beta1.UpstreamProviderTypeOIDC},
+ {Name: "github", Type: mcpv1beta1.UpstreamProviderTypeOAuth2},
+ },
+ },
+ authzConfig: &mcpv1beta1.AuthzConfigRef{
+ Type: "inline",
+ Inline: &mcpv1beta1.InlineAuthzConfig{
+ Policies: []string{`permit(principal, action, resource);`},
+ PrimaryUpstreamProvider: "github",
+ },
+ },
+ expectedProvider: "github",
+ },
+ {
+ // Canonical location overrides the deprecated one when both are set.
+ name: "canonical primary provider overrides deprecated inline value",
+ authServerConfig: &mcpv1beta1.EmbeddedAuthServerConfig{
+ Issuer: "https://authserver.example.com",
+ UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{
+ {Name: "okta", Type: mcpv1beta1.UpstreamProviderTypeOIDC},
+ {Name: "github", Type: mcpv1beta1.UpstreamProviderTypeOAuth2},
+ },
+ PrimaryUpstreamProvider: "github",
+ },
+ authzConfig: &mcpv1beta1.AuthzConfigRef{
+ Type: "inline",
+ Inline: &mcpv1beta1.InlineAuthzConfig{
+ Policies: []string{`permit(principal, action, resource);`},
+ PrimaryUpstreamProvider: "okta",
+ },
+ },
+ expectedProvider: "github",
+ },
}
for _, tt := range tests {
diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml
index 52dcc4b8cd..a33c6b802d 100644
--- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml
+++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml
@@ -267,6 +267,26 @@ spec:
Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash (per RFC 8414).
pattern: ^https?://[^\s?#]+[^/\s?#]$
type: string
+ primaryUpstreamProvider:
+ description: |-
+ PrimaryUpstreamProvider names the upstream IDP whose access token Cedar
+ should read claims from when authorising a request. Must match the name
+ of one of the entries in UpstreamProviders. When empty, the controller
+ auto-selects the first entry of UpstreamProviders.
+
+ Only meaningful on VirtualMCPServer, where multiple upstream providers
+ can be configured and Cedar needs to pick which token's claims to
+ evaluate. The VirtualMCPServer controller validates this field against
+ UpstreamProviders at admission and rejects unresolvable values.
+
+ On MCPServer and MCPRemoteProxy this field is structurally present (the
+ EmbeddedAuthServerConfig struct is shared) but has no runtime effect:
+ those CRDs are restricted to a single upstream so there is no choice to
+ make. Setting it on those CRDs is silently ignored.
+ maxLength: 63
+ minLength: 1
+ pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
+ type: string
signingKeySecretRefs:
description: |-
SigningKeySecretRefs references Kubernetes Secrets containing signing keys for JWT operations.
@@ -1509,6 +1529,26 @@ spec:
Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash (per RFC 8414).
pattern: ^https?://[^\s?#]+[^/\s?#]$
type: string
+ primaryUpstreamProvider:
+ description: |-
+ PrimaryUpstreamProvider names the upstream IDP whose access token Cedar
+ should read claims from when authorising a request. Must match the name
+ of one of the entries in UpstreamProviders. When empty, the controller
+ auto-selects the first entry of UpstreamProviders.
+
+ Only meaningful on VirtualMCPServer, where multiple upstream providers
+ can be configured and Cedar needs to pick which token's claims to
+ evaluate. The VirtualMCPServer controller validates this field against
+ UpstreamProviders at admission and rejects unresolvable values.
+
+ On MCPServer and MCPRemoteProxy this field is structurally present (the
+ EmbeddedAuthServerConfig struct is shared) but has no runtime effect:
+ those CRDs are restricted to a single upstream so there is no choice to
+ make. Setting it on those CRDs is silently ignored.
+ maxLength: 63
+ minLength: 1
+ pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
+ type: string
signingKeySecretRefs:
description: |-
SigningKeySecretRefs references Kubernetes Secrets containing signing keys for JWT operations.
diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml
index 21e6ee87a7..c672f1cd84 100644
--- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml
+++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpremoteproxies.yaml
@@ -114,6 +114,28 @@ spec:
required:
- name
type: object
+ groupClaimName:
+ description: |-
+ 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
+ Type is "configMap", a group_claim_name entry in the referenced ConfigMap
+ is overridden by this field if both are set.
+ maxLength: 253
+ type: string
+ groupEntityType:
+ description: |-
+ GroupEntityType is the Cedar entity type name used for principal parent
+ UIDs synthesised from JWT group/role claims. Defaults to "THVGroup" when
+ empty. Must match the entity type used in the static entity store for
+ transitive `in` checks (e.g. `ClaimGroup → PlatformRole`) to resolve.
+ Namespaced names (`Foo::Bar`) are not yet supported. When Type is
+ "configMap", a group_entity_type entry in the referenced ConfigMap is
+ overridden by this field if both are set.
+ maxLength: 63
+ pattern: ^[A-Za-z_][A-Za-z0-9_]*$
+ type: string
inline:
description: |-
Inline contains direct authorization configuration
@@ -121,8 +143,10 @@ spec:
properties:
entitiesJson:
default: '[]'
- description: EntitiesJSON is a JSON string representing Cedar
- entities
+ description: |-
+ EntitiesJSON is a JSON string representing Cedar entities. Required when
+ transitive policies (e.g. `ClaimGroup → PlatformRole`) need a static
+ entity store; defaults to "[]".
type: string
policies:
description: Policies is a list of Cedar policy strings
@@ -133,17 +157,20 @@ spec:
x-kubernetes-list-type: atomic
primaryUpstreamProvider:
description: |-
- PrimaryUpstreamProvider names the upstream IDP whose access token's claims
- Cedar should evaluate. Currently honored only when the parent
- AuthzConfigRef.Type is "inline"; configMap-sourced policies will support
- this in a future release (see #5208). Only meaningful for VirtualMCPServer
- with an embedded auth server. When empty and an embedded auth server has
- upstreams configured, the controller defaults to the first upstream
- provider. The name must match one of the upstreams declared on
- spec.authServerConfig.upstreamProviders; otherwise the VirtualMCPServer is
- rejected with AuthServerConfigValidated=False. MCPServer and MCPRemoteProxy
- have no embedded auth server; setting this field on those CRs surfaces an
- AuthzPrimaryUpstreamProviderIgnored advisory condition on the resource.
+ PrimaryUpstreamProvider names the upstream IDP whose access token's
+ claims Cedar should evaluate.
+
+ Deprecated: on VirtualMCPServer this field has moved to
+ spec.authServerConfig.primaryUpstreamProvider. The old location is
+ still read for one release for backward compatibility; the
+ VirtualMCPServer controller emits an AuthzPrimaryUpstreamProviderDeprecated
+ Warning event whenever it is consumed, and removal is planned for the
+ release after the deprecation cycle.
+
+ On MCPServer and MCPRemoteProxy this field has always been a structural
+ no-op (those CRDs do not run an embedded auth server). Setting it
+ continues to surface the AuthzPrimaryUpstreamProviderIgnored advisory
+ condition; the deprecation does not change that behaviour.
maxLength: 63
minLength: 1
pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
@@ -151,6 +178,15 @@ spec:
required:
- policies
type: object
+ roleClaimName:
+ description: |-
+ 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 GroupEntityType. When Type is
+ "configMap", a role_claim_name entry in the referenced ConfigMap is
+ overridden by this field if both are set.
+ maxLength: 253
+ type: string
type:
default: configMap
description: Type is the type of authorization configuration
@@ -697,6 +733,28 @@ spec:
required:
- name
type: object
+ groupClaimName:
+ description: |-
+ 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
+ Type is "configMap", a group_claim_name entry in the referenced ConfigMap
+ is overridden by this field if both are set.
+ maxLength: 253
+ type: string
+ groupEntityType:
+ description: |-
+ GroupEntityType is the Cedar entity type name used for principal parent
+ UIDs synthesised from JWT group/role claims. Defaults to "THVGroup" when
+ empty. Must match the entity type used in the static entity store for
+ transitive `in` checks (e.g. `ClaimGroup → PlatformRole`) to resolve.
+ Namespaced names (`Foo::Bar`) are not yet supported. When Type is
+ "configMap", a group_entity_type entry in the referenced ConfigMap is
+ overridden by this field if both are set.
+ maxLength: 63
+ pattern: ^[A-Za-z_][A-Za-z0-9_]*$
+ type: string
inline:
description: |-
Inline contains direct authorization configuration
@@ -704,8 +762,10 @@ spec:
properties:
entitiesJson:
default: '[]'
- description: EntitiesJSON is a JSON string representing Cedar
- entities
+ description: |-
+ EntitiesJSON is a JSON string representing Cedar entities. Required when
+ transitive policies (e.g. `ClaimGroup → PlatformRole`) need a static
+ entity store; defaults to "[]".
type: string
policies:
description: Policies is a list of Cedar policy strings
@@ -716,17 +776,20 @@ spec:
x-kubernetes-list-type: atomic
primaryUpstreamProvider:
description: |-
- PrimaryUpstreamProvider names the upstream IDP whose access token's claims
- Cedar should evaluate. Currently honored only when the parent
- AuthzConfigRef.Type is "inline"; configMap-sourced policies will support
- this in a future release (see #5208). Only meaningful for VirtualMCPServer
- with an embedded auth server. When empty and an embedded auth server has
- upstreams configured, the controller defaults to the first upstream
- provider. The name must match one of the upstreams declared on
- spec.authServerConfig.upstreamProviders; otherwise the VirtualMCPServer is
- rejected with AuthServerConfigValidated=False. MCPServer and MCPRemoteProxy
- have no embedded auth server; setting this field on those CRs surfaces an
- AuthzPrimaryUpstreamProviderIgnored advisory condition on the resource.
+ PrimaryUpstreamProvider names the upstream IDP whose access token's
+ claims Cedar should evaluate.
+
+ Deprecated: on VirtualMCPServer this field has moved to
+ spec.authServerConfig.primaryUpstreamProvider. The old location is
+ still read for one release for backward compatibility; the
+ VirtualMCPServer controller emits an AuthzPrimaryUpstreamProviderDeprecated
+ Warning event whenever it is consumed, and removal is planned for the
+ release after the deprecation cycle.
+
+ On MCPServer and MCPRemoteProxy this field has always been a structural
+ no-op (those CRDs do not run an embedded auth server). Setting it
+ continues to surface the AuthzPrimaryUpstreamProviderIgnored advisory
+ condition; the deprecation does not change that behaviour.
maxLength: 63
minLength: 1
pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
@@ -734,6 +797,15 @@ spec:
required:
- policies
type: object
+ roleClaimName:
+ description: |-
+ 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 GroupEntityType. When Type is
+ "configMap", a role_claim_name entry in the referenced ConfigMap is
+ overridden by this field if both are set.
+ maxLength: 253
+ type: string
type:
default: configMap
description: Type is the type of authorization configuration
diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml
index 04179042e7..051d6b0a9d 100644
--- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml
+++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpservers.yaml
@@ -121,6 +121,28 @@ spec:
required:
- name
type: object
+ groupClaimName:
+ description: |-
+ 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
+ Type is "configMap", a group_claim_name entry in the referenced ConfigMap
+ is overridden by this field if both are set.
+ maxLength: 253
+ type: string
+ groupEntityType:
+ description: |-
+ GroupEntityType is the Cedar entity type name used for principal parent
+ UIDs synthesised from JWT group/role claims. Defaults to "THVGroup" when
+ empty. Must match the entity type used in the static entity store for
+ transitive `in` checks (e.g. `ClaimGroup → PlatformRole`) to resolve.
+ Namespaced names (`Foo::Bar`) are not yet supported. When Type is
+ "configMap", a group_entity_type entry in the referenced ConfigMap is
+ overridden by this field if both are set.
+ maxLength: 63
+ pattern: ^[A-Za-z_][A-Za-z0-9_]*$
+ type: string
inline:
description: |-
Inline contains direct authorization configuration
@@ -128,8 +150,10 @@ spec:
properties:
entitiesJson:
default: '[]'
- description: EntitiesJSON is a JSON string representing Cedar
- entities
+ description: |-
+ EntitiesJSON is a JSON string representing Cedar entities. Required when
+ transitive policies (e.g. `ClaimGroup → PlatformRole`) need a static
+ entity store; defaults to "[]".
type: string
policies:
description: Policies is a list of Cedar policy strings
@@ -140,17 +164,20 @@ spec:
x-kubernetes-list-type: atomic
primaryUpstreamProvider:
description: |-
- PrimaryUpstreamProvider names the upstream IDP whose access token's claims
- Cedar should evaluate. Currently honored only when the parent
- AuthzConfigRef.Type is "inline"; configMap-sourced policies will support
- this in a future release (see #5208). Only meaningful for VirtualMCPServer
- with an embedded auth server. When empty and an embedded auth server has
- upstreams configured, the controller defaults to the first upstream
- provider. The name must match one of the upstreams declared on
- spec.authServerConfig.upstreamProviders; otherwise the VirtualMCPServer is
- rejected with AuthServerConfigValidated=False. MCPServer and MCPRemoteProxy
- have no embedded auth server; setting this field on those CRs surfaces an
- AuthzPrimaryUpstreamProviderIgnored advisory condition on the resource.
+ PrimaryUpstreamProvider names the upstream IDP whose access token's
+ claims Cedar should evaluate.
+
+ Deprecated: on VirtualMCPServer this field has moved to
+ spec.authServerConfig.primaryUpstreamProvider. The old location is
+ still read for one release for backward compatibility; the
+ VirtualMCPServer controller emits an AuthzPrimaryUpstreamProviderDeprecated
+ Warning event whenever it is consumed, and removal is planned for the
+ release after the deprecation cycle.
+
+ On MCPServer and MCPRemoteProxy this field has always been a structural
+ no-op (those CRDs do not run an embedded auth server). Setting it
+ continues to surface the AuthzPrimaryUpstreamProviderIgnored advisory
+ condition; the deprecation does not change that behaviour.
maxLength: 63
minLength: 1
pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
@@ -158,6 +185,15 @@ spec:
required:
- policies
type: object
+ roleClaimName:
+ description: |-
+ 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 GroupEntityType. When Type is
+ "configMap", a role_claim_name entry in the referenced ConfigMap is
+ overridden by this field if both are set.
+ maxLength: 253
+ type: string
type:
default: configMap
description: Type is the type of authorization configuration
@@ -1002,6 +1038,28 @@ spec:
required:
- name
type: object
+ groupClaimName:
+ description: |-
+ 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
+ Type is "configMap", a group_claim_name entry in the referenced ConfigMap
+ is overridden by this field if both are set.
+ maxLength: 253
+ type: string
+ groupEntityType:
+ description: |-
+ GroupEntityType is the Cedar entity type name used for principal parent
+ UIDs synthesised from JWT group/role claims. Defaults to "THVGroup" when
+ empty. Must match the entity type used in the static entity store for
+ transitive `in` checks (e.g. `ClaimGroup → PlatformRole`) to resolve.
+ Namespaced names (`Foo::Bar`) are not yet supported. When Type is
+ "configMap", a group_entity_type entry in the referenced ConfigMap is
+ overridden by this field if both are set.
+ maxLength: 63
+ pattern: ^[A-Za-z_][A-Za-z0-9_]*$
+ type: string
inline:
description: |-
Inline contains direct authorization configuration
@@ -1009,8 +1067,10 @@ spec:
properties:
entitiesJson:
default: '[]'
- description: EntitiesJSON is a JSON string representing Cedar
- entities
+ description: |-
+ EntitiesJSON is a JSON string representing Cedar entities. Required when
+ transitive policies (e.g. `ClaimGroup → PlatformRole`) need a static
+ entity store; defaults to "[]".
type: string
policies:
description: Policies is a list of Cedar policy strings
@@ -1021,17 +1081,20 @@ spec:
x-kubernetes-list-type: atomic
primaryUpstreamProvider:
description: |-
- PrimaryUpstreamProvider names the upstream IDP whose access token's claims
- Cedar should evaluate. Currently honored only when the parent
- AuthzConfigRef.Type is "inline"; configMap-sourced policies will support
- this in a future release (see #5208). Only meaningful for VirtualMCPServer
- with an embedded auth server. When empty and an embedded auth server has
- upstreams configured, the controller defaults to the first upstream
- provider. The name must match one of the upstreams declared on
- spec.authServerConfig.upstreamProviders; otherwise the VirtualMCPServer is
- rejected with AuthServerConfigValidated=False. MCPServer and MCPRemoteProxy
- have no embedded auth server; setting this field on those CRs surfaces an
- AuthzPrimaryUpstreamProviderIgnored advisory condition on the resource.
+ PrimaryUpstreamProvider names the upstream IDP whose access token's
+ claims Cedar should evaluate.
+
+ Deprecated: on VirtualMCPServer this field has moved to
+ spec.authServerConfig.primaryUpstreamProvider. The old location is
+ still read for one release for backward compatibility; the
+ VirtualMCPServer controller emits an AuthzPrimaryUpstreamProviderDeprecated
+ Warning event whenever it is consumed, and removal is planned for the
+ release after the deprecation cycle.
+
+ On MCPServer and MCPRemoteProxy this field has always been a structural
+ no-op (those CRDs do not run an embedded auth server). Setting it
+ continues to surface the AuthzPrimaryUpstreamProviderIgnored advisory
+ condition; the deprecation does not change that behaviour.
maxLength: 63
minLength: 1
pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
@@ -1039,6 +1102,15 @@ spec:
required:
- policies
type: object
+ roleClaimName:
+ description: |-
+ 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 GroupEntityType. When Type is
+ "configMap", a role_claim_name entry in the referenced ConfigMap is
+ overridden by this field if both are set.
+ maxLength: 253
+ type: string
type:
default: configMap
description: Type is the type of authorization configuration
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 8a9a6e217a..4f639ef1ee 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
@@ -140,6 +140,26 @@ spec:
Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash (per RFC 8414).
pattern: ^https?://[^\s?#]+[^/\s?#]$
type: string
+ primaryUpstreamProvider:
+ description: |-
+ PrimaryUpstreamProvider names the upstream IDP whose access token Cedar
+ should read claims from when authorising a request. Must match the name
+ of one of the entries in UpstreamProviders. When empty, the controller
+ auto-selects the first entry of UpstreamProviders.
+
+ Only meaningful on VirtualMCPServer, where multiple upstream providers
+ can be configured and Cedar needs to pick which token's claims to
+ evaluate. The VirtualMCPServer controller validates this field against
+ UpstreamProviders at admission and rejects unresolvable values.
+
+ On MCPServer and MCPRemoteProxy this field is structurally present (the
+ EmbeddedAuthServerConfig struct is shared) but has no runtime effect:
+ those CRDs are restricted to a single upstream so there is no choice to
+ make. Setting it on those CRDs is silently ignored.
+ maxLength: 63
+ minLength: 1
+ pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
+ type: string
signingKeySecretRefs:
description: |-
SigningKeySecretRefs references Kubernetes Secrets containing signing keys for JWT operations.
@@ -1423,6 +1443,29 @@ spec:
authz:
description: Authz contains authorization configuration (optional).
properties:
+ entitiesJson:
+ description: |-
+ 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.
+ type: string
+ groupClaimName:
+ description: |-
+ 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.
+ type: string
+ groupEntityType:
+ description: |-
+ GroupEntityType is the Cedar entity type name used for principal parent
+ UIDs synthesised from JWT group/role claims. Defaults to "THVGroup" when
+ empty. Must match the entity type used in EntitiesJSON for transitive
+ `in` checks to resolve. Namespaced names (`Foo::Bar`) are not yet supported.
+ type: string
policies:
description: Policies contains Cedar policy definitions
(when Type = "cedar").
@@ -1437,6 +1480,13 @@ spec:
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.
type: string
+ roleClaimName:
+ description: |-
+ 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.
+ type: string
type:
description: 'Type is the authz type: "cedar", "none"'
type: string
@@ -2386,6 +2436,28 @@ spec:
required:
- name
type: object
+ groupClaimName:
+ description: |-
+ 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
+ Type is "configMap", a group_claim_name entry in the referenced ConfigMap
+ is overridden by this field if both are set.
+ maxLength: 253
+ type: string
+ groupEntityType:
+ description: |-
+ GroupEntityType is the Cedar entity type name used for principal parent
+ UIDs synthesised from JWT group/role claims. Defaults to "THVGroup" when
+ empty. Must match the entity type used in the static entity store for
+ transitive `in` checks (e.g. `ClaimGroup → PlatformRole`) to resolve.
+ Namespaced names (`Foo::Bar`) are not yet supported. When Type is
+ "configMap", a group_entity_type entry in the referenced ConfigMap is
+ overridden by this field if both are set.
+ maxLength: 63
+ pattern: ^[A-Za-z_][A-Za-z0-9_]*$
+ type: string
inline:
description: |-
Inline contains direct authorization configuration
@@ -2393,8 +2465,10 @@ spec:
properties:
entitiesJson:
default: '[]'
- description: EntitiesJSON is a JSON string representing
- Cedar entities
+ description: |-
+ EntitiesJSON is a JSON string representing Cedar entities. Required when
+ transitive policies (e.g. `ClaimGroup → PlatformRole`) need a static
+ entity store; defaults to "[]".
type: string
policies:
description: Policies is a list of Cedar policy strings
@@ -2405,17 +2479,20 @@ spec:
x-kubernetes-list-type: atomic
primaryUpstreamProvider:
description: |-
- PrimaryUpstreamProvider names the upstream IDP whose access token's claims
- Cedar should evaluate. Currently honored only when the parent
- AuthzConfigRef.Type is "inline"; configMap-sourced policies will support
- this in a future release (see #5208). Only meaningful for VirtualMCPServer
- with an embedded auth server. When empty and an embedded auth server has
- upstreams configured, the controller defaults to the first upstream
- provider. The name must match one of the upstreams declared on
- spec.authServerConfig.upstreamProviders; otherwise the VirtualMCPServer is
- rejected with AuthServerConfigValidated=False. MCPServer and MCPRemoteProxy
- have no embedded auth server; setting this field on those CRs surfaces an
- AuthzPrimaryUpstreamProviderIgnored advisory condition on the resource.
+ PrimaryUpstreamProvider names the upstream IDP whose access token's
+ claims Cedar should evaluate.
+
+ Deprecated: on VirtualMCPServer this field has moved to
+ spec.authServerConfig.primaryUpstreamProvider. The old location is
+ still read for one release for backward compatibility; the
+ VirtualMCPServer controller emits an AuthzPrimaryUpstreamProviderDeprecated
+ Warning event whenever it is consumed, and removal is planned for the
+ release after the deprecation cycle.
+
+ On MCPServer and MCPRemoteProxy this field has always been a structural
+ no-op (those CRDs do not run an embedded auth server). Setting it
+ continues to surface the AuthzPrimaryUpstreamProviderIgnored advisory
+ condition; the deprecation does not change that behaviour.
maxLength: 63
minLength: 1
pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
@@ -2423,6 +2500,15 @@ spec:
required:
- policies
type: object
+ roleClaimName:
+ description: |-
+ 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 GroupEntityType. When Type is
+ "configMap", a role_claim_name entry in the referenced ConfigMap is
+ overridden by this field if both are set.
+ maxLength: 253
+ type: string
type:
default: configMap
description: Type is the type of authorization configuration
@@ -2978,6 +3064,26 @@ spec:
Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash (per RFC 8414).
pattern: ^https?://[^\s?#]+[^/\s?#]$
type: string
+ primaryUpstreamProvider:
+ description: |-
+ PrimaryUpstreamProvider names the upstream IDP whose access token Cedar
+ should read claims from when authorising a request. Must match the name
+ of one of the entries in UpstreamProviders. When empty, the controller
+ auto-selects the first entry of UpstreamProviders.
+
+ Only meaningful on VirtualMCPServer, where multiple upstream providers
+ can be configured and Cedar needs to pick which token's claims to
+ evaluate. The VirtualMCPServer controller validates this field against
+ UpstreamProviders at admission and rejects unresolvable values.
+
+ On MCPServer and MCPRemoteProxy this field is structurally present (the
+ EmbeddedAuthServerConfig struct is shared) but has no runtime effect:
+ those CRDs are restricted to a single upstream so there is no choice to
+ make. Setting it on those CRDs is silently ignored.
+ maxLength: 63
+ minLength: 1
+ pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
+ type: string
signingKeySecretRefs:
description: |-
SigningKeySecretRefs references Kubernetes Secrets containing signing keys for JWT operations.
@@ -4261,6 +4367,29 @@ spec:
authz:
description: Authz contains authorization configuration (optional).
properties:
+ entitiesJson:
+ description: |-
+ 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.
+ type: string
+ groupClaimName:
+ description: |-
+ 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.
+ type: string
+ groupEntityType:
+ description: |-
+ GroupEntityType is the Cedar entity type name used for principal parent
+ UIDs synthesised from JWT group/role claims. Defaults to "THVGroup" when
+ empty. Must match the entity type used in EntitiesJSON for transitive
+ `in` checks to resolve. Namespaced names (`Foo::Bar`) are not yet supported.
+ type: string
policies:
description: Policies contains Cedar policy definitions
(when Type = "cedar").
@@ -4275,6 +4404,13 @@ spec:
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.
type: string
+ roleClaimName:
+ description: |-
+ 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.
+ type: string
type:
description: 'Type is the authz type: "cedar", "none"'
type: string
@@ -5224,6 +5360,28 @@ spec:
required:
- name
type: object
+ groupClaimName:
+ description: |-
+ 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
+ Type is "configMap", a group_claim_name entry in the referenced ConfigMap
+ is overridden by this field if both are set.
+ maxLength: 253
+ type: string
+ groupEntityType:
+ description: |-
+ GroupEntityType is the Cedar entity type name used for principal parent
+ UIDs synthesised from JWT group/role claims. Defaults to "THVGroup" when
+ empty. Must match the entity type used in the static entity store for
+ transitive `in` checks (e.g. `ClaimGroup → PlatformRole`) to resolve.
+ Namespaced names (`Foo::Bar`) are not yet supported. When Type is
+ "configMap", a group_entity_type entry in the referenced ConfigMap is
+ overridden by this field if both are set.
+ maxLength: 63
+ pattern: ^[A-Za-z_][A-Za-z0-9_]*$
+ type: string
inline:
description: |-
Inline contains direct authorization configuration
@@ -5231,8 +5389,10 @@ spec:
properties:
entitiesJson:
default: '[]'
- description: EntitiesJSON is a JSON string representing
- Cedar entities
+ description: |-
+ EntitiesJSON is a JSON string representing Cedar entities. Required when
+ transitive policies (e.g. `ClaimGroup → PlatformRole`) need a static
+ entity store; defaults to "[]".
type: string
policies:
description: Policies is a list of Cedar policy strings
@@ -5243,17 +5403,20 @@ spec:
x-kubernetes-list-type: atomic
primaryUpstreamProvider:
description: |-
- PrimaryUpstreamProvider names the upstream IDP whose access token's claims
- Cedar should evaluate. Currently honored only when the parent
- AuthzConfigRef.Type is "inline"; configMap-sourced policies will support
- this in a future release (see #5208). Only meaningful for VirtualMCPServer
- with an embedded auth server. When empty and an embedded auth server has
- upstreams configured, the controller defaults to the first upstream
- provider. The name must match one of the upstreams declared on
- spec.authServerConfig.upstreamProviders; otherwise the VirtualMCPServer is
- rejected with AuthServerConfigValidated=False. MCPServer and MCPRemoteProxy
- have no embedded auth server; setting this field on those CRs surfaces an
- AuthzPrimaryUpstreamProviderIgnored advisory condition on the resource.
+ PrimaryUpstreamProvider names the upstream IDP whose access token's
+ claims Cedar should evaluate.
+
+ Deprecated: on VirtualMCPServer this field has moved to
+ spec.authServerConfig.primaryUpstreamProvider. The old location is
+ still read for one release for backward compatibility; the
+ VirtualMCPServer controller emits an AuthzPrimaryUpstreamProviderDeprecated
+ Warning event whenever it is consumed, and removal is planned for the
+ release after the deprecation cycle.
+
+ On MCPServer and MCPRemoteProxy this field has always been a structural
+ no-op (those CRDs do not run an embedded auth server). Setting it
+ continues to surface the AuthzPrimaryUpstreamProviderIgnored advisory
+ condition; the deprecation does not change that behaviour.
maxLength: 63
minLength: 1
pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
@@ -5261,6 +5424,15 @@ spec:
required:
- policies
type: object
+ roleClaimName:
+ description: |-
+ 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 GroupEntityType. When Type is
+ "configMap", a role_claim_name entry in the referenced ConfigMap is
+ overridden by this field if both are set.
+ maxLength: 253
+ type: string
type:
default: configMap
description: Type is the type of authorization configuration
diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml
index 7242f0d237..9298b59208 100644
--- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml
+++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml
@@ -270,6 +270,26 @@ spec:
Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash (per RFC 8414).
pattern: ^https?://[^\s?#]+[^/\s?#]$
type: string
+ primaryUpstreamProvider:
+ description: |-
+ PrimaryUpstreamProvider names the upstream IDP whose access token Cedar
+ should read claims from when authorising a request. Must match the name
+ of one of the entries in UpstreamProviders. When empty, the controller
+ auto-selects the first entry of UpstreamProviders.
+
+ Only meaningful on VirtualMCPServer, where multiple upstream providers
+ can be configured and Cedar needs to pick which token's claims to
+ evaluate. The VirtualMCPServer controller validates this field against
+ UpstreamProviders at admission and rejects unresolvable values.
+
+ On MCPServer and MCPRemoteProxy this field is structurally present (the
+ EmbeddedAuthServerConfig struct is shared) but has no runtime effect:
+ those CRDs are restricted to a single upstream so there is no choice to
+ make. Setting it on those CRDs is silently ignored.
+ maxLength: 63
+ minLength: 1
+ pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
+ type: string
signingKeySecretRefs:
description: |-
SigningKeySecretRefs references Kubernetes Secrets containing signing keys for JWT operations.
@@ -1512,6 +1532,26 @@ spec:
Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash (per RFC 8414).
pattern: ^https?://[^\s?#]+[^/\s?#]$
type: string
+ primaryUpstreamProvider:
+ description: |-
+ PrimaryUpstreamProvider names the upstream IDP whose access token Cedar
+ should read claims from when authorising a request. Must match the name
+ of one of the entries in UpstreamProviders. When empty, the controller
+ auto-selects the first entry of UpstreamProviders.
+
+ Only meaningful on VirtualMCPServer, where multiple upstream providers
+ can be configured and Cedar needs to pick which token's claims to
+ evaluate. The VirtualMCPServer controller validates this field against
+ UpstreamProviders at admission and rejects unresolvable values.
+
+ On MCPServer and MCPRemoteProxy this field is structurally present (the
+ EmbeddedAuthServerConfig struct is shared) but has no runtime effect:
+ those CRDs are restricted to a single upstream so there is no choice to
+ make. Setting it on those CRDs is silently ignored.
+ maxLength: 63
+ minLength: 1
+ pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
+ type: string
signingKeySecretRefs:
description: |-
SigningKeySecretRefs references Kubernetes Secrets containing signing keys for JWT operations.
diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml
index b029787947..7e2707ae27 100644
--- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml
+++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml
@@ -117,6 +117,28 @@ spec:
required:
- name
type: object
+ groupClaimName:
+ description: |-
+ 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
+ Type is "configMap", a group_claim_name entry in the referenced ConfigMap
+ is overridden by this field if both are set.
+ maxLength: 253
+ type: string
+ groupEntityType:
+ description: |-
+ GroupEntityType is the Cedar entity type name used for principal parent
+ UIDs synthesised from JWT group/role claims. Defaults to "THVGroup" when
+ empty. Must match the entity type used in the static entity store for
+ transitive `in` checks (e.g. `ClaimGroup → PlatformRole`) to resolve.
+ Namespaced names (`Foo::Bar`) are not yet supported. When Type is
+ "configMap", a group_entity_type entry in the referenced ConfigMap is
+ overridden by this field if both are set.
+ maxLength: 63
+ pattern: ^[A-Za-z_][A-Za-z0-9_]*$
+ type: string
inline:
description: |-
Inline contains direct authorization configuration
@@ -124,8 +146,10 @@ spec:
properties:
entitiesJson:
default: '[]'
- description: EntitiesJSON is a JSON string representing Cedar
- entities
+ description: |-
+ EntitiesJSON is a JSON string representing Cedar entities. Required when
+ transitive policies (e.g. `ClaimGroup → PlatformRole`) need a static
+ entity store; defaults to "[]".
type: string
policies:
description: Policies is a list of Cedar policy strings
@@ -136,17 +160,20 @@ spec:
x-kubernetes-list-type: atomic
primaryUpstreamProvider:
description: |-
- PrimaryUpstreamProvider names the upstream IDP whose access token's claims
- Cedar should evaluate. Currently honored only when the parent
- AuthzConfigRef.Type is "inline"; configMap-sourced policies will support
- this in a future release (see #5208). Only meaningful for VirtualMCPServer
- with an embedded auth server. When empty and an embedded auth server has
- upstreams configured, the controller defaults to the first upstream
- provider. The name must match one of the upstreams declared on
- spec.authServerConfig.upstreamProviders; otherwise the VirtualMCPServer is
- rejected with AuthServerConfigValidated=False. MCPServer and MCPRemoteProxy
- have no embedded auth server; setting this field on those CRs surfaces an
- AuthzPrimaryUpstreamProviderIgnored advisory condition on the resource.
+ PrimaryUpstreamProvider names the upstream IDP whose access token's
+ claims Cedar should evaluate.
+
+ Deprecated: on VirtualMCPServer this field has moved to
+ spec.authServerConfig.primaryUpstreamProvider. The old location is
+ still read for one release for backward compatibility; the
+ VirtualMCPServer controller emits an AuthzPrimaryUpstreamProviderDeprecated
+ Warning event whenever it is consumed, and removal is planned for the
+ release after the deprecation cycle.
+
+ On MCPServer and MCPRemoteProxy this field has always been a structural
+ no-op (those CRDs do not run an embedded auth server). Setting it
+ continues to surface the AuthzPrimaryUpstreamProviderIgnored advisory
+ condition; the deprecation does not change that behaviour.
maxLength: 63
minLength: 1
pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
@@ -154,6 +181,15 @@ spec:
required:
- policies
type: object
+ roleClaimName:
+ description: |-
+ 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 GroupEntityType. When Type is
+ "configMap", a role_claim_name entry in the referenced ConfigMap is
+ overridden by this field if both are set.
+ maxLength: 253
+ type: string
type:
default: configMap
description: Type is the type of authorization configuration
@@ -700,6 +736,28 @@ spec:
required:
- name
type: object
+ groupClaimName:
+ description: |-
+ 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
+ Type is "configMap", a group_claim_name entry in the referenced ConfigMap
+ is overridden by this field if both are set.
+ maxLength: 253
+ type: string
+ groupEntityType:
+ description: |-
+ GroupEntityType is the Cedar entity type name used for principal parent
+ UIDs synthesised from JWT group/role claims. Defaults to "THVGroup" when
+ empty. Must match the entity type used in the static entity store for
+ transitive `in` checks (e.g. `ClaimGroup → PlatformRole`) to resolve.
+ Namespaced names (`Foo::Bar`) are not yet supported. When Type is
+ "configMap", a group_entity_type entry in the referenced ConfigMap is
+ overridden by this field if both are set.
+ maxLength: 63
+ pattern: ^[A-Za-z_][A-Za-z0-9_]*$
+ type: string
inline:
description: |-
Inline contains direct authorization configuration
@@ -707,8 +765,10 @@ spec:
properties:
entitiesJson:
default: '[]'
- description: EntitiesJSON is a JSON string representing Cedar
- entities
+ description: |-
+ EntitiesJSON is a JSON string representing Cedar entities. Required when
+ transitive policies (e.g. `ClaimGroup → PlatformRole`) need a static
+ entity store; defaults to "[]".
type: string
policies:
description: Policies is a list of Cedar policy strings
@@ -719,17 +779,20 @@ spec:
x-kubernetes-list-type: atomic
primaryUpstreamProvider:
description: |-
- PrimaryUpstreamProvider names the upstream IDP whose access token's claims
- Cedar should evaluate. Currently honored only when the parent
- AuthzConfigRef.Type is "inline"; configMap-sourced policies will support
- this in a future release (see #5208). Only meaningful for VirtualMCPServer
- with an embedded auth server. When empty and an embedded auth server has
- upstreams configured, the controller defaults to the first upstream
- provider. The name must match one of the upstreams declared on
- spec.authServerConfig.upstreamProviders; otherwise the VirtualMCPServer is
- rejected with AuthServerConfigValidated=False. MCPServer and MCPRemoteProxy
- have no embedded auth server; setting this field on those CRs surfaces an
- AuthzPrimaryUpstreamProviderIgnored advisory condition on the resource.
+ PrimaryUpstreamProvider names the upstream IDP whose access token's
+ claims Cedar should evaluate.
+
+ Deprecated: on VirtualMCPServer this field has moved to
+ spec.authServerConfig.primaryUpstreamProvider. The old location is
+ still read for one release for backward compatibility; the
+ VirtualMCPServer controller emits an AuthzPrimaryUpstreamProviderDeprecated
+ Warning event whenever it is consumed, and removal is planned for the
+ release after the deprecation cycle.
+
+ On MCPServer and MCPRemoteProxy this field has always been a structural
+ no-op (those CRDs do not run an embedded auth server). Setting it
+ continues to surface the AuthzPrimaryUpstreamProviderIgnored advisory
+ condition; the deprecation does not change that behaviour.
maxLength: 63
minLength: 1
pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
@@ -737,6 +800,15 @@ spec:
required:
- policies
type: object
+ roleClaimName:
+ description: |-
+ 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 GroupEntityType. When Type is
+ "configMap", a role_claim_name entry in the referenced ConfigMap is
+ overridden by this field if both are set.
+ maxLength: 253
+ type: string
type:
default: configMap
description: Type is the type of authorization configuration
diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml
index 573c6e83bf..67e1227a8c 100644
--- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml
+++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml
@@ -124,6 +124,28 @@ spec:
required:
- name
type: object
+ groupClaimName:
+ description: |-
+ 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
+ Type is "configMap", a group_claim_name entry in the referenced ConfigMap
+ is overridden by this field if both are set.
+ maxLength: 253
+ type: string
+ groupEntityType:
+ description: |-
+ GroupEntityType is the Cedar entity type name used for principal parent
+ UIDs synthesised from JWT group/role claims. Defaults to "THVGroup" when
+ empty. Must match the entity type used in the static entity store for
+ transitive `in` checks (e.g. `ClaimGroup → PlatformRole`) to resolve.
+ Namespaced names (`Foo::Bar`) are not yet supported. When Type is
+ "configMap", a group_entity_type entry in the referenced ConfigMap is
+ overridden by this field if both are set.
+ maxLength: 63
+ pattern: ^[A-Za-z_][A-Za-z0-9_]*$
+ type: string
inline:
description: |-
Inline contains direct authorization configuration
@@ -131,8 +153,10 @@ spec:
properties:
entitiesJson:
default: '[]'
- description: EntitiesJSON is a JSON string representing Cedar
- entities
+ description: |-
+ EntitiesJSON is a JSON string representing Cedar entities. Required when
+ transitive policies (e.g. `ClaimGroup → PlatformRole`) need a static
+ entity store; defaults to "[]".
type: string
policies:
description: Policies is a list of Cedar policy strings
@@ -143,17 +167,20 @@ spec:
x-kubernetes-list-type: atomic
primaryUpstreamProvider:
description: |-
- PrimaryUpstreamProvider names the upstream IDP whose access token's claims
- Cedar should evaluate. Currently honored only when the parent
- AuthzConfigRef.Type is "inline"; configMap-sourced policies will support
- this in a future release (see #5208). Only meaningful for VirtualMCPServer
- with an embedded auth server. When empty and an embedded auth server has
- upstreams configured, the controller defaults to the first upstream
- provider. The name must match one of the upstreams declared on
- spec.authServerConfig.upstreamProviders; otherwise the VirtualMCPServer is
- rejected with AuthServerConfigValidated=False. MCPServer and MCPRemoteProxy
- have no embedded auth server; setting this field on those CRs surfaces an
- AuthzPrimaryUpstreamProviderIgnored advisory condition on the resource.
+ PrimaryUpstreamProvider names the upstream IDP whose access token's
+ claims Cedar should evaluate.
+
+ Deprecated: on VirtualMCPServer this field has moved to
+ spec.authServerConfig.primaryUpstreamProvider. The old location is
+ still read for one release for backward compatibility; the
+ VirtualMCPServer controller emits an AuthzPrimaryUpstreamProviderDeprecated
+ Warning event whenever it is consumed, and removal is planned for the
+ release after the deprecation cycle.
+
+ On MCPServer and MCPRemoteProxy this field has always been a structural
+ no-op (those CRDs do not run an embedded auth server). Setting it
+ continues to surface the AuthzPrimaryUpstreamProviderIgnored advisory
+ condition; the deprecation does not change that behaviour.
maxLength: 63
minLength: 1
pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
@@ -161,6 +188,15 @@ spec:
required:
- policies
type: object
+ roleClaimName:
+ description: |-
+ 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 GroupEntityType. When Type is
+ "configMap", a role_claim_name entry in the referenced ConfigMap is
+ overridden by this field if both are set.
+ maxLength: 253
+ type: string
type:
default: configMap
description: Type is the type of authorization configuration
@@ -1005,6 +1041,28 @@ spec:
required:
- name
type: object
+ groupClaimName:
+ description: |-
+ 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
+ Type is "configMap", a group_claim_name entry in the referenced ConfigMap
+ is overridden by this field if both are set.
+ maxLength: 253
+ type: string
+ groupEntityType:
+ description: |-
+ GroupEntityType is the Cedar entity type name used for principal parent
+ UIDs synthesised from JWT group/role claims. Defaults to "THVGroup" when
+ empty. Must match the entity type used in the static entity store for
+ transitive `in` checks (e.g. `ClaimGroup → PlatformRole`) to resolve.
+ Namespaced names (`Foo::Bar`) are not yet supported. When Type is
+ "configMap", a group_entity_type entry in the referenced ConfigMap is
+ overridden by this field if both are set.
+ maxLength: 63
+ pattern: ^[A-Za-z_][A-Za-z0-9_]*$
+ type: string
inline:
description: |-
Inline contains direct authorization configuration
@@ -1012,8 +1070,10 @@ spec:
properties:
entitiesJson:
default: '[]'
- description: EntitiesJSON is a JSON string representing Cedar
- entities
+ description: |-
+ EntitiesJSON is a JSON string representing Cedar entities. Required when
+ transitive policies (e.g. `ClaimGroup → PlatformRole`) need a static
+ entity store; defaults to "[]".
type: string
policies:
description: Policies is a list of Cedar policy strings
@@ -1024,17 +1084,20 @@ spec:
x-kubernetes-list-type: atomic
primaryUpstreamProvider:
description: |-
- PrimaryUpstreamProvider names the upstream IDP whose access token's claims
- Cedar should evaluate. Currently honored only when the parent
- AuthzConfigRef.Type is "inline"; configMap-sourced policies will support
- this in a future release (see #5208). Only meaningful for VirtualMCPServer
- with an embedded auth server. When empty and an embedded auth server has
- upstreams configured, the controller defaults to the first upstream
- provider. The name must match one of the upstreams declared on
- spec.authServerConfig.upstreamProviders; otherwise the VirtualMCPServer is
- rejected with AuthServerConfigValidated=False. MCPServer and MCPRemoteProxy
- have no embedded auth server; setting this field on those CRs surfaces an
- AuthzPrimaryUpstreamProviderIgnored advisory condition on the resource.
+ PrimaryUpstreamProvider names the upstream IDP whose access token's
+ claims Cedar should evaluate.
+
+ Deprecated: on VirtualMCPServer this field has moved to
+ spec.authServerConfig.primaryUpstreamProvider. The old location is
+ still read for one release for backward compatibility; the
+ VirtualMCPServer controller emits an AuthzPrimaryUpstreamProviderDeprecated
+ Warning event whenever it is consumed, and removal is planned for the
+ release after the deprecation cycle.
+
+ On MCPServer and MCPRemoteProxy this field has always been a structural
+ no-op (those CRDs do not run an embedded auth server). Setting it
+ continues to surface the AuthzPrimaryUpstreamProviderIgnored advisory
+ condition; the deprecation does not change that behaviour.
maxLength: 63
minLength: 1
pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
@@ -1042,6 +1105,15 @@ spec:
required:
- policies
type: object
+ roleClaimName:
+ description: |-
+ 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 GroupEntityType. When Type is
+ "configMap", a role_claim_name entry in the referenced ConfigMap is
+ overridden by this field if both are set.
+ maxLength: 253
+ type: string
type:
default: configMap
description: Type is the type of authorization configuration
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 232121fb53..cedf9c2334 100644
--- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml
+++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml
@@ -143,6 +143,26 @@ spec:
Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash (per RFC 8414).
pattern: ^https?://[^\s?#]+[^/\s?#]$
type: string
+ primaryUpstreamProvider:
+ description: |-
+ PrimaryUpstreamProvider names the upstream IDP whose access token Cedar
+ should read claims from when authorising a request. Must match the name
+ of one of the entries in UpstreamProviders. When empty, the controller
+ auto-selects the first entry of UpstreamProviders.
+
+ Only meaningful on VirtualMCPServer, where multiple upstream providers
+ can be configured and Cedar needs to pick which token's claims to
+ evaluate. The VirtualMCPServer controller validates this field against
+ UpstreamProviders at admission and rejects unresolvable values.
+
+ On MCPServer and MCPRemoteProxy this field is structurally present (the
+ EmbeddedAuthServerConfig struct is shared) but has no runtime effect:
+ those CRDs are restricted to a single upstream so there is no choice to
+ make. Setting it on those CRDs is silently ignored.
+ maxLength: 63
+ minLength: 1
+ pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
+ type: string
signingKeySecretRefs:
description: |-
SigningKeySecretRefs references Kubernetes Secrets containing signing keys for JWT operations.
@@ -1426,6 +1446,29 @@ spec:
authz:
description: Authz contains authorization configuration (optional).
properties:
+ entitiesJson:
+ description: |-
+ 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.
+ type: string
+ groupClaimName:
+ description: |-
+ 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.
+ type: string
+ groupEntityType:
+ description: |-
+ GroupEntityType is the Cedar entity type name used for principal parent
+ UIDs synthesised from JWT group/role claims. Defaults to "THVGroup" when
+ empty. Must match the entity type used in EntitiesJSON for transitive
+ `in` checks to resolve. Namespaced names (`Foo::Bar`) are not yet supported.
+ type: string
policies:
description: Policies contains Cedar policy definitions
(when Type = "cedar").
@@ -1440,6 +1483,13 @@ spec:
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.
type: string
+ roleClaimName:
+ description: |-
+ 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.
+ type: string
type:
description: 'Type is the authz type: "cedar", "none"'
type: string
@@ -2389,6 +2439,28 @@ spec:
required:
- name
type: object
+ groupClaimName:
+ description: |-
+ 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
+ Type is "configMap", a group_claim_name entry in the referenced ConfigMap
+ is overridden by this field if both are set.
+ maxLength: 253
+ type: string
+ groupEntityType:
+ description: |-
+ GroupEntityType is the Cedar entity type name used for principal parent
+ UIDs synthesised from JWT group/role claims. Defaults to "THVGroup" when
+ empty. Must match the entity type used in the static entity store for
+ transitive `in` checks (e.g. `ClaimGroup → PlatformRole`) to resolve.
+ Namespaced names (`Foo::Bar`) are not yet supported. When Type is
+ "configMap", a group_entity_type entry in the referenced ConfigMap is
+ overridden by this field if both are set.
+ maxLength: 63
+ pattern: ^[A-Za-z_][A-Za-z0-9_]*$
+ type: string
inline:
description: |-
Inline contains direct authorization configuration
@@ -2396,8 +2468,10 @@ spec:
properties:
entitiesJson:
default: '[]'
- description: EntitiesJSON is a JSON string representing
- Cedar entities
+ description: |-
+ EntitiesJSON is a JSON string representing Cedar entities. Required when
+ transitive policies (e.g. `ClaimGroup → PlatformRole`) need a static
+ entity store; defaults to "[]".
type: string
policies:
description: Policies is a list of Cedar policy strings
@@ -2408,17 +2482,20 @@ spec:
x-kubernetes-list-type: atomic
primaryUpstreamProvider:
description: |-
- PrimaryUpstreamProvider names the upstream IDP whose access token's claims
- Cedar should evaluate. Currently honored only when the parent
- AuthzConfigRef.Type is "inline"; configMap-sourced policies will support
- this in a future release (see #5208). Only meaningful for VirtualMCPServer
- with an embedded auth server. When empty and an embedded auth server has
- upstreams configured, the controller defaults to the first upstream
- provider. The name must match one of the upstreams declared on
- spec.authServerConfig.upstreamProviders; otherwise the VirtualMCPServer is
- rejected with AuthServerConfigValidated=False. MCPServer and MCPRemoteProxy
- have no embedded auth server; setting this field on those CRs surfaces an
- AuthzPrimaryUpstreamProviderIgnored advisory condition on the resource.
+ PrimaryUpstreamProvider names the upstream IDP whose access token's
+ claims Cedar should evaluate.
+
+ Deprecated: on VirtualMCPServer this field has moved to
+ spec.authServerConfig.primaryUpstreamProvider. The old location is
+ still read for one release for backward compatibility; the
+ VirtualMCPServer controller emits an AuthzPrimaryUpstreamProviderDeprecated
+ Warning event whenever it is consumed, and removal is planned for the
+ release after the deprecation cycle.
+
+ On MCPServer and MCPRemoteProxy this field has always been a structural
+ no-op (those CRDs do not run an embedded auth server). Setting it
+ continues to surface the AuthzPrimaryUpstreamProviderIgnored advisory
+ condition; the deprecation does not change that behaviour.
maxLength: 63
minLength: 1
pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
@@ -2426,6 +2503,15 @@ spec:
required:
- policies
type: object
+ roleClaimName:
+ description: |-
+ 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 GroupEntityType. When Type is
+ "configMap", a role_claim_name entry in the referenced ConfigMap is
+ overridden by this field if both are set.
+ maxLength: 253
+ type: string
type:
default: configMap
description: Type is the type of authorization configuration
@@ -2981,6 +3067,26 @@ spec:
Must be a valid HTTPS URL (or HTTP for localhost) without query, fragment, or trailing slash (per RFC 8414).
pattern: ^https?://[^\s?#]+[^/\s?#]$
type: string
+ primaryUpstreamProvider:
+ description: |-
+ PrimaryUpstreamProvider names the upstream IDP whose access token Cedar
+ should read claims from when authorising a request. Must match the name
+ of one of the entries in UpstreamProviders. When empty, the controller
+ auto-selects the first entry of UpstreamProviders.
+
+ Only meaningful on VirtualMCPServer, where multiple upstream providers
+ can be configured and Cedar needs to pick which token's claims to
+ evaluate. The VirtualMCPServer controller validates this field against
+ UpstreamProviders at admission and rejects unresolvable values.
+
+ On MCPServer and MCPRemoteProxy this field is structurally present (the
+ EmbeddedAuthServerConfig struct is shared) but has no runtime effect:
+ those CRDs are restricted to a single upstream so there is no choice to
+ make. Setting it on those CRDs is silently ignored.
+ maxLength: 63
+ minLength: 1
+ pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
+ type: string
signingKeySecretRefs:
description: |-
SigningKeySecretRefs references Kubernetes Secrets containing signing keys for JWT operations.
@@ -4264,6 +4370,29 @@ spec:
authz:
description: Authz contains authorization configuration (optional).
properties:
+ entitiesJson:
+ description: |-
+ 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.
+ type: string
+ groupClaimName:
+ description: |-
+ 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.
+ type: string
+ groupEntityType:
+ description: |-
+ GroupEntityType is the Cedar entity type name used for principal parent
+ UIDs synthesised from JWT group/role claims. Defaults to "THVGroup" when
+ empty. Must match the entity type used in EntitiesJSON for transitive
+ `in` checks to resolve. Namespaced names (`Foo::Bar`) are not yet supported.
+ type: string
policies:
description: Policies contains Cedar policy definitions
(when Type = "cedar").
@@ -4278,6 +4407,13 @@ spec:
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.
type: string
+ roleClaimName:
+ description: |-
+ 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.
+ type: string
type:
description: 'Type is the authz type: "cedar", "none"'
type: string
@@ -5227,6 +5363,28 @@ spec:
required:
- name
type: object
+ groupClaimName:
+ description: |-
+ 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
+ Type is "configMap", a group_claim_name entry in the referenced ConfigMap
+ is overridden by this field if both are set.
+ maxLength: 253
+ type: string
+ groupEntityType:
+ description: |-
+ GroupEntityType is the Cedar entity type name used for principal parent
+ UIDs synthesised from JWT group/role claims. Defaults to "THVGroup" when
+ empty. Must match the entity type used in the static entity store for
+ transitive `in` checks (e.g. `ClaimGroup → PlatformRole`) to resolve.
+ Namespaced names (`Foo::Bar`) are not yet supported. When Type is
+ "configMap", a group_entity_type entry in the referenced ConfigMap is
+ overridden by this field if both are set.
+ maxLength: 63
+ pattern: ^[A-Za-z_][A-Za-z0-9_]*$
+ type: string
inline:
description: |-
Inline contains direct authorization configuration
@@ -5234,8 +5392,10 @@ spec:
properties:
entitiesJson:
default: '[]'
- description: EntitiesJSON is a JSON string representing
- Cedar entities
+ description: |-
+ EntitiesJSON is a JSON string representing Cedar entities. Required when
+ transitive policies (e.g. `ClaimGroup → PlatformRole`) need a static
+ entity store; defaults to "[]".
type: string
policies:
description: Policies is a list of Cedar policy strings
@@ -5246,17 +5406,20 @@ spec:
x-kubernetes-list-type: atomic
primaryUpstreamProvider:
description: |-
- PrimaryUpstreamProvider names the upstream IDP whose access token's claims
- Cedar should evaluate. Currently honored only when the parent
- AuthzConfigRef.Type is "inline"; configMap-sourced policies will support
- this in a future release (see #5208). Only meaningful for VirtualMCPServer
- with an embedded auth server. When empty and an embedded auth server has
- upstreams configured, the controller defaults to the first upstream
- provider. The name must match one of the upstreams declared on
- spec.authServerConfig.upstreamProviders; otherwise the VirtualMCPServer is
- rejected with AuthServerConfigValidated=False. MCPServer and MCPRemoteProxy
- have no embedded auth server; setting this field on those CRs surfaces an
- AuthzPrimaryUpstreamProviderIgnored advisory condition on the resource.
+ PrimaryUpstreamProvider names the upstream IDP whose access token's
+ claims Cedar should evaluate.
+
+ Deprecated: on VirtualMCPServer this field has moved to
+ spec.authServerConfig.primaryUpstreamProvider. The old location is
+ still read for one release for backward compatibility; the
+ VirtualMCPServer controller emits an AuthzPrimaryUpstreamProviderDeprecated
+ Warning event whenever it is consumed, and removal is planned for the
+ release after the deprecation cycle.
+
+ On MCPServer and MCPRemoteProxy this field has always been a structural
+ no-op (those CRDs do not run an embedded auth server). Setting it
+ continues to surface the AuthzPrimaryUpstreamProviderIgnored advisory
+ condition; the deprecation does not change that behaviour.
maxLength: 63
minLength: 1
pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
@@ -5264,6 +5427,15 @@ spec:
required:
- policies
type: object
+ roleClaimName:
+ description: |-
+ 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 GroupEntityType. When Type is
+ "configMap", a role_claim_name entry in the referenced ConfigMap is
+ overridden by this field if both are set.
+ maxLength: 253
+ type: string
type:
default: configMap
description: Type is the type of authorization configuration
diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md
index 660141f726..cbd9c54713 100644
--- a/docs/operator/crd-api.md
+++ b/docs/operator/crd-api.md
@@ -231,7 +231,11 @@ _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: \{\}
|
| `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: \{\}
|
+| `groupEntityType` _string_ | GroupEntityType is the Cedar entity type name used for principal parent
UIDs synthesised from JWT group/role claims. Defaults to "THVGroup" when
empty. Must match the entity type used in EntitiesJSON for transitive
`in` checks to resolve. Namespaced names (`Foo::Bar`) are not yet supported. | | Optional: \{\}
|
#### vmcp.config.CircuitBreakerConfig
@@ -1069,6 +1073,9 @@ _Appears in:_
| `type` _string_ | Type is the type of authorization configuration | configMap | Enum: [configMap inline]
|
| `configMap` _[api.v1beta1.ConfigMapAuthzRef](#apiv1beta1configmapauthzref)_ | ConfigMap references a ConfigMap containing authorization configuration
Only used when Type is "configMap" | | Optional: \{\}
|
| `inline` _[api.v1beta1.InlineAuthzConfig](#apiv1beta1inlineauthzconfig)_ | Inline contains direct authorization configuration
Only used when Type is "inline" | | 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
Type is "configMap", a group_claim_name entry in the referenced ConfigMap
is overridden by this field if both are set. | | MaxLength: 253
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 GroupEntityType. When Type is
"configMap", a role_claim_name entry in the referenced ConfigMap is
overridden by this field if both are set. | | MaxLength: 253
Optional: \{\}
|
+| `groupEntityType` _string_ | GroupEntityType is the Cedar entity type name used for principal parent
UIDs synthesised from JWT group/role claims. Defaults to "THVGroup" when
empty. Must match the entity type used in the static entity store for
transitive `in` checks (e.g. `ClaimGroup → PlatformRole`) to resolve.
Namespaced names (`Foo::Bar`) are not yet supported. When Type is
"configMap", a group_entity_type entry in the referenced ConfigMap is
overridden by this field if both are set. | | MaxLength: 63
Pattern: `^[A-Za-z_][A-Za-z0-9_]*$`
Optional: \{\}
|
#### api.v1beta1.BackendAuthConfig
@@ -1198,6 +1205,7 @@ _Appears in:_
| `hmacSecretRefs` _[api.v1beta1.SecretKeyRef](#apiv1beta1secretkeyref) array_ | HMACSecretRefs references Kubernetes Secrets containing symmetric secrets for signing
authorization codes and refresh tokens (opaque tokens).
Current secret must be at least 32 bytes and cryptographically random.
Supports secret rotation via multiple entries (first is current, rest are for verification).
If not specified, an ephemeral secret will be auto-generated (development only -
auth codes and refresh tokens will be invalid after restart). | | Optional: \{\}
|
| `tokenLifespans` _[api.v1beta1.TokenLifespanConfig](#apiv1beta1tokenlifespanconfig)_ | TokenLifespans configures the duration that various tokens are valid.
If not specified, defaults are applied (access: 1h, refresh: 7d, authCode: 10m). | | Optional: \{\}
|
| `upstreamProviders` _[api.v1beta1.UpstreamProviderConfig](#apiv1beta1upstreamproviderconfig) array_ | UpstreamProviders configures connections to upstream Identity Providers.
The embedded auth server delegates authentication to these providers.
MCPServer and MCPRemoteProxy support a single upstream; VirtualMCPServer supports multiple. | | MinItems: 1
Required: \{\}
|
+| `primaryUpstreamProvider` _string_ | PrimaryUpstreamProvider names the upstream IDP whose access token Cedar
should read claims from when authorising a request. Must match the name
of one of the entries in UpstreamProviders. When empty, the controller
auto-selects the first entry of UpstreamProviders.
Only meaningful on VirtualMCPServer, where multiple upstream providers
can be configured and Cedar needs to pick which token's claims to
evaluate. The VirtualMCPServer controller validates this field against
UpstreamProviders at admission and rejects unresolvable values.
On MCPServer and MCPRemoteProxy this field is structurally present (the
EmbeddedAuthServerConfig struct is shared) but has no runtime effect:
those CRDs are restricted to a single upstream so there is no choice to
make. Setting it on those CRDs is silently ignored. | | MaxLength: 63
MinLength: 1
Pattern: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`
Optional: \{\}
|
| `storage` _[api.v1beta1.AuthServerStorageConfig](#apiv1beta1authserverstorageconfig)_ | Storage configures the storage backend for the embedded auth server.
If not specified, defaults to in-memory storage. | | Optional: \{\}
|
| `baselineClientScopes` _string array_ | BaselineClientScopes is a baseline set of OAuth 2.0 scopes guaranteed to be
included in every client registration. The embedded auth server unions these
scopes into the registered set returned by RFC 7591 Dynamic Client
Registration, so a client that narrows the `scope` field at /oauth/register
can still request the baseline scopes at /oauth/authorize. All values must
be present in the upstream-derived scopesSupported set; the auth server
fails to start if any value is missing.
Security: every client registered via /oauth/register will gain the
ability to request these scopes at /oauth/authorize, regardless of what
the client itself requested. Keep the baseline narrow (typically
"openid" and "offline_access"). Adding a privileged scope here — e.g.
"admin:read" — would grant it to every DCR-registered client, including
public clients like Claude Code, Cursor, and VS Code. | | MaxItems: 10
items:MinLength: 1
items:Pattern: `^[\x21\x23-\x5B\x5D-\x7E]+$`
Optional: \{\}
|
@@ -1536,7 +1544,11 @@ _Appears in:_
-InlineAuthzConfig contains direct authorization configuration
+InlineAuthzConfig contains direct authorization configuration.
+
+Source-agnostic Cedar JWT-claim mapping settings (GroupClaimName,
+RoleClaimName, GroupEntityType) live on the parent AuthzConfigRef so they
+work the same way for inline and configMap-sourced authz.
@@ -1546,8 +1558,8 @@ _Appears in:_
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `policies` _string array_ | Policies is a list of Cedar policy strings | | MinItems: 1
Required: \{\}
|
-| `entitiesJson` _string_ | EntitiesJSON is a JSON string representing Cedar entities | [] | Optional: \{\}
|
-| `primaryUpstreamProvider` _string_ | PrimaryUpstreamProvider names the upstream IDP whose access token's claims
Cedar should evaluate. Currently honored only when the parent
AuthzConfigRef.Type is "inline"; configMap-sourced policies will support
this in a future release (see #5208). Only meaningful for VirtualMCPServer
with an embedded auth server. When empty and an embedded auth server has
upstreams configured, the controller defaults to the first upstream
provider. The name must match one of the upstreams declared on
spec.authServerConfig.upstreamProviders; otherwise the VirtualMCPServer is
rejected with AuthServerConfigValidated=False. MCPServer and MCPRemoteProxy
have no embedded auth server; setting this field on those CRs surfaces an
AuthzPrimaryUpstreamProviderIgnored advisory condition on the resource. | | MaxLength: 63
MinLength: 1
Pattern: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`
Optional: \{\}
|
+| `entitiesJson` _string_ | EntitiesJSON is a JSON string representing Cedar entities. Required when
transitive policies (e.g. `ClaimGroup → PlatformRole`) need a static
entity store; defaults to "[]". | [] | Optional: \{\}
|
+| `primaryUpstreamProvider` _string_ | PrimaryUpstreamProvider names the upstream IDP whose access token's
claims Cedar should evaluate.
Deprecated: on VirtualMCPServer this field has moved to
spec.authServerConfig.primaryUpstreamProvider. The old location is
still read for one release for backward compatibility; the
VirtualMCPServer controller emits an AuthzPrimaryUpstreamProviderDeprecated
Warning event whenever it is consumed, and removal is planned for the
release after the deprecation cycle.
On MCPServer and MCPRemoteProxy this field has always been a structural
no-op (those CRDs do not run an embedded auth server). Setting it
continues to surface the AuthzPrimaryUpstreamProviderIgnored advisory
condition; the deprecation does not change that behaviour. | | MaxLength: 63
MinLength: 1
Pattern: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`
Optional: \{\}
|
#### api.v1beta1.InlineOIDCSharedConfig
diff --git a/docs/operator/virtualmcpserver-api.md b/docs/operator/virtualmcpserver-api.md
index 7d90254155..9e72694faa 100644
--- a/docs/operator/virtualmcpserver-api.md
+++ b/docs/operator/virtualmcpserver-api.md
@@ -86,14 +86,32 @@ Configures authentication for clients connecting to the Virtual MCP server. Reus
- `type` (string, required): `inline` or `configMap`
- `inline` (InlineAuthzConfig, required when type=inline): Inline Cedar policies
- `policies` ([]string, required): Cedar policy strings
- - `entitiesJson` (string, optional): Cedar entities (JSON)
- - `primaryUpstreamProvider` (string, optional): Names the upstream IDP whose
- access token claims Cedar should evaluate. Only meaningful when
- `spec.authServerConfig` is set with multiple upstreamProviders. When
- empty, the controller defaults to the first upstream. Must match a
- configured upstream name; the VirtualMCPServer is rejected with
- `AuthServerConfigValidated=False` otherwise.
- - `configMap` (ConfigMapAuthzRef, required when type=configMap): Reference to a ConfigMap holding policies
+ - `entitiesJson` (string, optional): Cedar entities (JSON). Required when
+ transitive policies (e.g. `ClaimGroup` → `PlatformRole`) need a static
+ entity store. Defaults to `"[]"`.
+ - `primaryUpstreamProvider` (string, optional): **Deprecated.** Use
+ `.spec.authServerConfig.primaryUpstreamProvider` instead. Setting this
+ field still resolves a primary upstream for backward compatibility and
+ emits a Warning event with reason
+ `AuthzPrimaryUpstreamProviderDeprecated`. Planned removal one release
+ after the deprecation cycle.
+ - `configMap` (ConfigMapAuthzRef, required when type=configMap): Reference to
+ a ConfigMap holding Cedar policies. The operator resolves the ConfigMap at
+ reconcile time and bakes the policies into the rendered vmcp `config.yaml`.
+ Failures surface as `AuthConfigured=False` with reason
+ `AuthzConfigMapNotFound` (missing reference) or `AuthzConfigMapInvalid`
+ (parse, validation, or non-Cedar payload).
+ - `groupClaimName` (string, optional): JWT claim key Cedar should treat as
+ the group list (overrides the well-known defaults `groups`, `roles`,
+ `cognito:groups`). When `type` is `configMap`, the spec value overrides
+ any `group_claim_name` set in the ConfigMap payload.
+ - `roleClaimName` (string, optional): JWT claim key Cedar should treat as
+ the role list. Same spec-over-ConfigMap precedence as `groupClaimName`.
+ - `groupEntityType` (string, optional): Cedar entity type used for principal
+ parent UIDs synthesised from JWT group/role claims. Defaults to
+ `THVGroup`. Must match the entity type used in the static entity store
+ for transitive `in` checks to resolve. Same spec-over-ConfigMap
+ precedence as `groupClaimName`.
**Important**: The `type` field must always be explicitly specified. When no authentication is required, use `type: anonymous`.
diff --git a/docs/operator/virtualmcpserver-kubernetes-guide.md b/docs/operator/virtualmcpserver-kubernetes-guide.md
index 11e8fbf2ae..e8943fd44f 100644
--- a/docs/operator/virtualmcpserver-kubernetes-guide.md
+++ b/docs/operator/virtualmcpserver-kubernetes-guide.md
@@ -628,17 +628,68 @@ Then gradually add restrictions. Common Cedar policy issues:
**Multiple upstream IDPs**: when `spec.authServerConfig` declares more than
one `upstreamProviders` entry, Cedar evaluates claims from the first one by
default. Pin a specific provider explicitly via
-`authzConfig.inline.primaryUpstreamProvider`:
+`spec.authServerConfig.primaryUpstreamProvider`:
```yaml
-authzConfig:
- type: inline
- inline:
+spec:
+ authServerConfig:
+ issuer: https://vmcp.example.com
primaryUpstreamProvider: okta # must match one of the configured upstreams
- policies:
- - 'permit(principal, action, resource);'
+ upstreamProviders:
+ - name: okta
+ type: oidc
+ # ...
+ - name: github
+ type: oauth2
+ # ...
+ incomingAuth:
+ authzConfig:
+ type: inline
+ inline:
+ policies:
+ - 'permit(principal, action, resource);'
```
+> **Migration: `primaryUpstreamProvider` location**
+>
+> The field used to live under
+> `spec.incomingAuth.authzConfig.inline.primaryUpstreamProvider`. It has
+> moved to `spec.authServerConfig.primaryUpstreamProvider` to sit next to
+> the `upstreamProviders` list it selects from. The old location is read
+> for one release for backward compatibility; the controller emits a
+> Warning event with reason `AuthzPrimaryUpstreamProviderDeprecated`
+> whenever it consumes the deprecated location. Move the value to the new
+> location to clear the warning. The deprecated field is planned for
+> removal one release after the deprecation cycle.
+
+**Authorization policy errors**: misconfigured authz surfaces on the
+`AuthConfigured` condition with one of:
+
+* `AuthzConfigMapNotFound`: the ConfigMap referenced by
+ `spec.incomingAuth.authzConfig.configMap` does not exist in the
+ namespace. Create it before reconciling, or fix the name.
+* `AuthzConfigMapInvalid`: the ConfigMap exists but the payload is
+ missing the configured key, empty, malformed YAML/JSON, fails Cedar
+ validation, or is a registered non-Cedar authorizer (vMCP supports
+ Cedar only). Check the payload shape (see the Cedar v1 schema in the
+ example above).
+
+**Enterprise Cedar policies that deny every request**: when a policy
+walks a transitive hierarchy like `Client → ClaimGroup → PlatformRole`,
+both Cedar JWT-claim mapping settings and the static entity store must
+agree on the entity type. The configuration fields live on
+`spec.incomingAuth.authzConfig`:
+
+* `groupClaimName` / `roleClaimName`: JWT claim keys to extract.
+* `groupEntityType`: Cedar entity type used for principal parent UIDs.
+ Must match the entity type used in `entitiesJson` (e.g. `ClaimGroup`
+ rather than the default `THVGroup`).
+
+For configMap-sourced authz, the same fields can be set in the
+ConfigMap payload (`cedar.group_claim_name`, `cedar.role_claim_name`,
+`cedar.group_entity_type`); spec-level values on `authzConfig` override
+the ConfigMap when set.
+
### Backend Discovery Issues
#### Backends Not Discovered
diff --git a/pkg/vmcp/auth/factory/incoming.go b/pkg/vmcp/auth/factory/incoming.go
index c27d842573..c88967ccc3 100644
--- a/pkg/vmcp/auth/factory/incoming.go
+++ b/pkg/vmcp/auth/factory/incoming.go
@@ -114,16 +114,29 @@ func newCedarAuthzMiddleware(
slog.Info("creating Cedar authorization middleware", "policies", len(authzCfg.Policies))
+ // Default EntitiesJSON to "[]" when the operator/CLI did not set it. Cedar
+ // requires a valid JSON array; an empty string would fail to parse.
+ entitiesJSON := authzCfg.EntitiesJSON
+ if entitiesJSON == "" {
+ entitiesJSON = "[]"
+ }
+
// 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.
cedarConfig := cedar.Config{
Version: "1.0",
Type: cedar.ConfigType,
Options: &cedar.ConfigOptions{
Policies: authzCfg.Policies,
- EntitiesJSON: "[]",
+ EntitiesJSON: entitiesJSON,
PrimaryUpstreamProvider: authzCfg.PrimaryUpstreamProvider,
+ GroupClaimName: authzCfg.GroupClaimName,
+ RoleClaimName: authzCfg.RoleClaimName,
+ GroupEntityType: authzCfg.GroupEntityType,
},
}
diff --git a/pkg/vmcp/config/config.go b/pkg/vmcp/config/config.go
index 20cd03a43c..df29ece070 100644
--- a/pkg/vmcp/config/config.go
+++ b/pkg/vmcp/config/config.go
@@ -267,6 +267,14 @@ type AuthzConfig struct {
// Policies contains Cedar policy definitions (when Type = "cedar").
Policies []string `json:"policies,omitempty" yaml:"policies,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
+ // constructed with an empty entity store and `in` checks against absent
+ // entities silently evaluate to false. Defaults to "[]" when empty.
+ // +optional
+ EntitiesJSON string `json:"entitiesJson,omitempty" yaml:"entitiesJson,omitempty"`
+
// 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.
@@ -274,6 +282,28 @@ type AuthzConfig struct {
// (e.g. "default", "github"). Only relevant when the embedded auth server is active.
// +optional
PrimaryUpstreamProvider string `json:"primaryUpstreamProvider,omitempty" yaml:"primaryUpstreamProvider,omitempty"`
+
+ // 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
+ GroupClaimName string `json:"groupClaimName,omitempty" yaml:"groupClaimName,omitempty"`
+
+ // 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
+ RoleClaimName string `json:"roleClaimName,omitempty" yaml:"roleClaimName,omitempty"`
+
+ // GroupEntityType is the Cedar entity type name used for principal parent
+ // UIDs synthesised from JWT group/role claims. Defaults to "THVGroup" when
+ // empty. Must match the entity type used in EntitiesJSON for transitive
+ // `in` checks to resolve. Namespaced names (`Foo::Bar`) are not yet supported.
+ // +optional
+ GroupEntityType string `json:"groupEntityType,omitempty" yaml:"groupEntityType,omitempty"`
}
// StaticBackendConfig defines a pre-configured backend server for static mode.