diff --git a/cmd/thv-operator/api/v1beta1/mcpserver_types.go b/cmd/thv-operator/api/v1beta1/mcpserver_types.go
index 200874bc95..d86a20751c 100644
--- a/cmd/thv-operator/api/v1beta1/mcpserver_types.go
+++ b/cmd/thv-operator/api/v1beta1/mcpserver_types.go
@@ -89,6 +89,12 @@ const (
// ConditionTypeWebhookConfigValidated indicates whether the WebhookConfig is valid
ConditionTypeWebhookConfigValidated = "WebhookConfigValidated"
+
+ // ConditionTypeAuthzPrimaryUpstreamProviderIgnored is an advisory condition set
+ // 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.
+ ConditionTypeAuthzPrimaryUpstreamProviderIgnored = "AuthzPrimaryUpstreamProviderIgnored"
)
const (
@@ -98,6 +104,11 @@ const (
// ConditionReasonWebhookConfigInvalid indicates the referenced webhook config is invalid or missing
ConditionReasonWebhookConfigInvalid = "WebhookConfigInvalid"
+
+ // ConditionReasonAuthzPrimaryUpstreamProviderIgnored indicates that
+ // primaryUpstreamProvider is set on a CR type without an embedded auth server,
+ // where the field has no runtime effect.
+ ConditionReasonAuthzPrimaryUpstreamProviderIgnored = "PrimaryUpstreamProviderIgnored"
)
const (
@@ -693,6 +704,20 @@ type AuthzConfigRef struct {
Inline *InlineAuthzConfig `json:"inline,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 {
+ if r == nil || r.Inline == nil {
+ return ""
+ }
+ return r.Inline.PrimaryUpstreamProvider
+}
+
// ConfigMapAuthzRef references a ConfigMap containing authorization configuration
type ConfigMapAuthzRef struct {
// Name is the name of the ConfigMap
@@ -773,6 +798,23 @@ type InlineAuthzConfig struct {
// +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.
+ // +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"`
}
// AuditConfig defines audit logging configuration for the MCP server
diff --git a/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go b/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go
index c63139b133..b69c454d64 100644
--- a/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go
+++ b/cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go
@@ -400,6 +400,20 @@ const (
// the Cedar claim source. The advisory message names the selected upstream.
ConditionReasonAuthzUpstreamAutoSelected = "AuthzUpstreamAutoSelected"
+ // ConditionReasonAuthzUpstreamUnknown indicates that
+ // spec.incomingAuth.authzConfig.inline.primaryUpstreamProvider names an upstream
+ // IDP that is not declared on spec.authServerConfig.upstreamProviders. Cedar
+ // would otherwise deny every request at runtime; reject at admission instead.
+ ConditionReasonAuthzUpstreamUnknown = "AuthzUpstreamUnknown"
+
+ // ConditionReasonAuthzPrimaryProviderRequiresAuthServer indicates that
+ // spec.incomingAuth.authzConfig.inline.primaryUpstreamProvider 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.
+ // Distinct from AuthzUpstreamUnknown so tooling (alertmanager rules,
+ // dashboards) can route the two misconfigurations separately.
+ ConditionReasonAuthzPrimaryProviderRequiresAuthServer = "AuthzPrimaryProviderRequiresAuthServer"
+
// ConditionReasonVirtualMCPServerTelemetryConfigRefValid indicates the referenced MCPTelemetryConfig is valid
ConditionReasonVirtualMCPServerTelemetryConfigRefValid = "TelemetryConfigRefValid"
diff --git a/cmd/thv-operator/controllers/mcpremoteproxy_controller.go b/cmd/thv-operator/controllers/mcpremoteproxy_controller.go
index 255a26044c..f83e7127ff 100644
--- a/cmd/thv-operator/controllers/mcpremoteproxy_controller.go
+++ b/cmd/thv-operator/controllers/mcpremoteproxy_controller.go
@@ -124,6 +124,9 @@ func (r *MCPRemoteProxyReconciler) validateAndHandleConfigs(ctx context.Context,
// Validate the GroupRef if specified
r.validateGroupRef(ctx, proxy)
+ // Surface advisory condition when primaryUpstreamProvider is set but ignored
+ r.validateAuthzPrimaryUpstreamProviderIgnored(proxy)
+
// Handle MCPToolConfig
if err := r.handleToolConfig(ctx, proxy); err != nil {
ctxLogger.Error(err, "Failed to handle MCPToolConfig")
@@ -1078,6 +1081,31 @@ func (r *MCPRemoteProxyReconciler) validateGroupRef(ctx context.Context, proxy *
}
}
+// validateAuthzPrimaryUpstreamProviderIgnored surfaces an advisory condition
+// when spec.authzConfig.inline.primaryUpstreamProvider is set on an
+// MCPRemoteProxy. The proxy has no embedded auth server, so the field has no
+// runtime effect — the condition gives operators a kubectl-visible signal
+// that a configured value is being silently ignored.
+//
+// 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()
+ conditionType := mcpv1beta1.ConditionTypeAuthzPrimaryUpstreamProviderIgnored
+ if provider == "" {
+ meta.RemoveStatusCondition(&proxy.Status.Conditions, conditionType)
+ return
+ }
+ meta.SetStatusCondition(&proxy.Status.Conditions, metav1.Condition{
+ Type: conditionType,
+ Status: metav1.ConditionTrue,
+ Reason: mcpv1beta1.ConditionReasonAuthzPrimaryUpstreamProviderIgnored,
+ Message: fmt.Sprintf("spec.authzConfig.inline.primaryUpstreamProvider=%q has no effect on MCPRemoteProxy; "+
+ "the field only takes effect on VirtualMCPServer with an embedded auth server", provider),
+ ObservedGeneration: proxy.Generation,
+ })
+}
+
// ensureRBACResources ensures that the RBAC resources are in place for the remote proxy.
// Uses the RBAC client (pkg/kubernetes/rbac) which creates or updates RBAC resources
// automatically during operator upgrades.
diff --git a/cmd/thv-operator/controllers/mcpserver_controller.go b/cmd/thv-operator/controllers/mcpserver_controller.go
index feb933343b..be38902e80 100644
--- a/cmd/thv-operator/controllers/mcpserver_controller.go
+++ b/cmd/thv-operator/controllers/mcpserver_controller.go
@@ -229,6 +229,9 @@ func (r *MCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (
// Validate CABundleRef if specified
r.validateCABundleRef(ctx, mcpServer)
+ // Surface advisory condition when primaryUpstreamProvider is set but ignored
+ r.validateAuthzPrimaryUpstreamProviderIgnored(mcpServer)
+
// Validate stdio replica cap, session storage, and rate limit config
r.validateStdioReplicaCap(ctx, mcpServer)
r.validateSessionStorageForReplicas(ctx, mcpServer)
@@ -644,6 +647,31 @@ func (r *MCPServerReconciler) updateCABundleStatus(ctx context.Context, mcpServe
}
}
+// validateAuthzPrimaryUpstreamProviderIgnored surfaces an advisory condition
+// when spec.authzConfig.inline.primaryUpstreamProvider is set on an MCPServer.
+// MCPServer has no embedded auth server, so the field has no runtime effect —
+// the condition gives operators a kubectl-visible signal that a configured
+// value is being silently ignored.
+//
+// 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()
+ conditionType := mcpv1beta1.ConditionTypeAuthzPrimaryUpstreamProviderIgnored
+ if provider == "" {
+ meta.RemoveStatusCondition(&mcpServer.Status.Conditions, conditionType)
+ return
+ }
+ meta.SetStatusCondition(&mcpServer.Status.Conditions, metav1.Condition{
+ Type: conditionType,
+ Status: metav1.ConditionTrue,
+ Reason: mcpv1beta1.ConditionReasonAuthzPrimaryUpstreamProviderIgnored,
+ Message: fmt.Sprintf("spec.authzConfig.inline.primaryUpstreamProvider=%q has no effect on MCPServer; "+
+ "the field only takes effect on VirtualMCPServer with an embedded auth server", provider),
+ ObservedGeneration: mcpServer.Generation,
+ })
+}
+
// setReadyCondition sets the top-level Ready status condition.
func setReadyCondition(mcpServer *mcpv1beta1.MCPServer, status metav1.ConditionStatus, reason, message string) {
meta.SetStatusCondition(&mcpServer.Status.Conditions, metav1.Condition{
diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller.go b/cmd/thv-operator/controllers/virtualmcpserver_controller.go
index b32bdec40d..4b8817afd2 100644
--- a/cmd/thv-operator/controllers/virtualmcpserver_controller.go
+++ b/cmd/thv-operator/controllers/virtualmcpserver_controller.go
@@ -13,6 +13,7 @@ import (
"fmt"
"maps"
"reflect"
+ "slices"
"strings"
"time"
@@ -552,6 +553,32 @@ func (*VirtualMCPServerReconciler) applyAuthServerIdentitySynthesizedCondition(
)
}
+// rejectAuthzAdmission centralizes the boilerplate shared by every
+// authz-spec rejection branch in validateAuthzUpstreamAvailable: clear any
+// stale advisory, log the rejection, set Phase=Failed plus the
+// AuthServerConfigValidated=False condition, and return a *SpecValidationError
+// the reconciler converts into a non-requeueing outcome.
+func rejectAuthzAdmission(
+ ctx context.Context,
+ vmcp *mcpv1beta1.VirtualMCPServer,
+ statusManager virtualmcpserverstatus.StatusManager,
+ logMsg, reason, userMessage, errSummary string,
+ extraLogFields ...any,
+) *SpecValidationError {
+ statusManager.RemoveConditionsWithPrefix(mcpv1beta1.ConditionTypeAuthzUpstreamSelectionWarning, []string{})
+ logFields := append([]any{
+ "name", vmcp.Name,
+ "namespace", vmcp.Namespace,
+ "reason", reason,
+ }, extraLogFields...)
+ log.FromContext(ctx).Info(logMsg, logFields...)
+ statusManager.SetPhase(mcpv1beta1.VirtualMCPServerPhaseFailed)
+ statusManager.SetMessage(userMessage)
+ statusManager.SetAuthServerConfigValidatedCondition(reason, userMessage, metav1.ConditionFalse)
+ statusManager.SetObservedGeneration(vmcp.Generation)
+ return &SpecValidationError{Message: errSummary}
+}
+
// validateAuthzUpstreamAvailable ensures that when authorization policies are
// configured via IncomingAuth.AuthzConfig AND an embedded AuthServer is in use,
// at least one upstream IDP is declared so Cedar evaluates claim references
@@ -567,6 +594,11 @@ func (*VirtualMCPServerReconciler) applyAuthServerIdentitySynthesizedCondition(
// first one is authoritative for Cedar. Surface an advisory
// AuthzUpstreamSelectionWarning condition naming the selected provider so the
// operator can reorder or prune the list if the auto-selection is wrong.
+//
+// When spec.incomingAuth.authzConfig.inline.primaryUpstreamProvider is set
+// 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(
ctx context.Context,
vmcp *mcpv1beta1.VirtualMCPServer,
@@ -583,7 +615,31 @@ func (*VirtualMCPServerReconciler) validateAuthzUpstreamAvailable(
// Direct-IdP flow: no embedded AS. Cedar evaluates against identity.Claims
// populated by incoming OIDC middleware from the IdP token. No upstream
// needed; nothing to warn about. Remove any stale condition.
+ //
+ // However, an explicit primaryUpstreamProvider is meaningless in this mode
+ // — there is no upstream-token table for Cedar to look it up in — so the
+ // converter would forward a name that cannot resolve at runtime. Reject at
+ // 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()
+ if explicitProvider != "" {
+ 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.",
+ explicitProvider,
+ )
+ return rejectAuthzAdmission(ctx, vmcp, statusManager,
+ "authz primaryUpstreamProvider set without an embedded auth server; rejecting VirtualMCPServer",
+ mcpv1beta1.ConditionReasonAuthzPrimaryProviderRequiresAuthServer,
+ message,
+ fmt.Sprintf("authz primaryUpstreamProvider %q set without an embedded auth server", explicitProvider),
+ "primaryUpstreamProvider", explicitProvider,
+ )
+ }
statusManager.RemoveConditionsWithPrefix(mcpv1beta1.ConditionTypeAuthzUpstreamSelectionWarning, []string{})
return nil
}
@@ -591,8 +647,6 @@ func (*VirtualMCPServerReconciler) validateAuthzUpstreamAvailable(
// Embedded AS configured but no upstreams: this is the misconfiguration
// that silently evaluates policies against the AS-issued token.
if len(vmcp.Spec.AuthServerConfig.UpstreamProviders) == 0 {
- statusManager.RemoveConditionsWithPrefix(mcpv1beta1.ConditionTypeAuthzUpstreamSelectionWarning, []string{})
-
// User-facing message includes full remediation guidance and ends with
// a period, matching other validator messages. The returned error uses
// a trimmed form without trailing punctuation to satisfy staticcheck.
@@ -602,29 +656,51 @@ func (*VirtualMCPServerReconciler) validateAuthzUpstreamAvailable(
"Configure spec.authServerConfig.upstreamProviders with at least one " +
"upstream IDP, or remove authServerConfig if clients will present IdP " +
"tokens directly."
-
- ctxLogger := log.FromContext(ctx)
- ctxLogger.Info("authz configured without an upstream IDP; rejecting VirtualMCPServer",
- "name", vmcp.Name,
- "namespace", vmcp.Namespace,
- "reason", mcpv1beta1.ConditionReasonAuthzRequiresUpstream,
- )
-
- statusManager.SetPhase(mcpv1beta1.VirtualMCPServerPhaseFailed)
- statusManager.SetMessage(message)
- statusManager.SetAuthServerConfigValidatedCondition(
+ return rejectAuthzAdmission(ctx, vmcp, statusManager,
+ "authz configured without an upstream IDP; rejecting VirtualMCPServer",
mcpv1beta1.ConditionReasonAuthzRequiresUpstream,
message,
- metav1.ConditionFalse,
+ "authz configured without an upstream IDP",
)
- statusManager.SetObservedGeneration(vmcp.Generation)
- return stderrors.New("authz configured without an upstream IDP")
}
- // Valid configuration. When multiple upstreams are declared, surface an
- // advisory naming the auto-selected upstream; otherwise ensure any stale
- // warning is cleared.
- if len(vmcp.Spec.AuthServerConfig.UpstreamProviders) > 1 {
+ // 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 explicitProvider != "" {
+ resolved := authserver.ResolveUpstreamName(explicitProvider)
+ matched := slices.ContainsFunc(
+ vmcp.Spec.AuthServerConfig.UpstreamProviders,
+ func(up mcpv1beta1.UpstreamProviderConfig) bool {
+ return authserver.ResolveUpstreamName(up.Name) == resolved
+ },
+ )
+ 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.",
+ explicitProvider,
+ )
+ return rejectAuthzAdmission(ctx, vmcp, statusManager,
+ "authz primaryUpstreamProvider does not match any upstream; rejecting VirtualMCPServer",
+ mcpv1beta1.ConditionReasonAuthzUpstreamUnknown,
+ message,
+ fmt.Sprintf("authz primaryUpstreamProvider %q does not match any configured upstream", explicitProvider),
+ "primaryUpstreamProvider", explicitProvider,
+ )
+ }
+ }
+
+ // Valid configuration. When multiple upstreams are declared AND the user has
+ // not pinned a choice via primaryUpstreamProvider, surface an advisory naming
+ // the auto-selected upstream so the operator can reorder or set the explicit
+ // field. Otherwise — single upstream, or an explicit choice that disambiguates
+ // the multi-upstream case — ensure any stale warning is cleared.
+ if len(vmcp.Spec.AuthServerConfig.UpstreamProviders) > 1 && explicitProvider == "" {
selected := vmcp.Spec.AuthServerConfig.UpstreamProviders[0].Name
statusManager.SetCondition(
mcpv1beta1.ConditionTypeAuthzUpstreamSelectionWarning,
@@ -632,7 +708,8 @@ func (*VirtualMCPServerReconciler) validateAuthzUpstreamAvailable(
fmt.Sprintf(
"multiple upstreamProviders configured; Cedar policies will evaluate "+
"claims from the first upstream (%q). If another upstream should be "+
- "authoritative, remove or reorder the list.",
+ "authoritative, set spec.incomingAuth.authzConfig.inline."+
+ "primaryUpstreamProvider explicitly, or remove or reorder the list.",
selected,
),
metav1.ConditionTrue,
diff --git a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go
index 3a918bcbfa..a8ba7b0bce 100644
--- a/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go
+++ b/cmd/thv-operator/controllers/virtualmcpserver_controller_test.go
@@ -24,6 +24,7 @@ import (
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
+ "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
@@ -3544,6 +3545,18 @@ 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 {
+ return &mcpv1beta1.AuthzConfigRef{
+ Type: "inline",
+ Inline: &mcpv1beta1.InlineAuthzConfig{
+ Policies: []string{`permit(principal, action, resource);`},
+ PrimaryUpstreamProvider: primary,
+ },
+ }
+ }
+
// warningExpectation captures the expected state of the advisory
// AuthzUpstreamSelectionWarning condition after validation. When
// expectPresent is false the condition must not appear in status at
@@ -3633,6 +3646,78 @@ func TestVirtualMCPServerValidateAuthzUpstreamAvailable(t *testing.T) {
messageSubstr: `"okta"`,
},
},
+ {
+ // Explicit PrimaryUpstreamProvider matching one of the upstreams is
+ // valid and emits no advisory — the user has disambiguated the choice.
+ name: "explicit primary provider matching an upstream is valid",
+ incomingAuth: &mcpv1beta1.IncomingAuthConfig{
+ Type: "oidc",
+ AuthzConfig: authzRefWithPrimary("entra"),
+ },
+ authServerConfig: &mcpv1beta1.EmbeddedAuthServerConfig{
+ Issuer: "https://authserver.example.com",
+ UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{
+ {Name: "okta", Type: mcpv1beta1.UpstreamProviderTypeOIDC},
+ {Name: "entra", Type: mcpv1beta1.UpstreamProviderTypeOIDC},
+ },
+ },
+ expectedWarning: warningExpectation{expectPresent: false},
+ },
+ {
+ // Explicit PrimaryUpstreamProvider with multiple upstreams suppresses
+ // the advisory warning — auto-selection is no longer happening.
+ name: "explicit primary provider suppresses multi-upstream advisory",
+ incomingAuth: &mcpv1beta1.IncomingAuthConfig{
+ Type: "oidc",
+ AuthzConfig: authzRefWithPrimary("okta"),
+ },
+ authServerConfig: &mcpv1beta1.EmbeddedAuthServerConfig{
+ Issuer: "https://authserver.example.com",
+ UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{
+ {Name: "okta", Type: mcpv1beta1.UpstreamProviderTypeOIDC},
+ {Name: "entra", Type: mcpv1beta1.UpstreamProviderTypeOIDC},
+ {Name: "google", Type: mcpv1beta1.UpstreamProviderTypeOIDC},
+ },
+ },
+ expectedWarning: warningExpectation{expectPresent: false},
+ },
+ {
+ // Explicit PrimaryUpstreamProvider that does not match any declared
+ // upstream is rejected at admission. Cedar would otherwise deny every
+ // request at runtime; failing loudly is the right behavior.
+ name: "explicit primary provider not matching any upstream is invalid",
+ incomingAuth: &mcpv1beta1.IncomingAuthConfig{
+ Type: "oidc",
+ AuthzConfig: authzRefWithPrimary("ping"),
+ },
+ authServerConfig: &mcpv1beta1.EmbeddedAuthServerConfig{
+ Issuer: "https://authserver.example.com",
+ UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{
+ {Name: "okta", Type: mcpv1beta1.UpstreamProviderTypeOIDC},
+ {Name: "entra", Type: mcpv1beta1.UpstreamProviderTypeOIDC},
+ },
+ },
+ expectError: true,
+ expectedReason: mcpv1beta1.ConditionReasonAuthzUpstreamUnknown,
+ expectedWarning: warningExpectation{expectPresent: false},
+ },
+ {
+ // Explicit PrimaryUpstreamProvider with no embedded auth server at
+ // all is rejected at admission. The field names an upstream IDP on
+ // 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.
+ name: "explicit primary provider without embedded auth server is invalid",
+ incomingAuth: &mcpv1beta1.IncomingAuthConfig{
+ Type: "oidc",
+ AuthzConfig: authzRefWithPrimary("okta"),
+ },
+ authServerConfig: nil,
+ expectError: true,
+ expectedReason: mcpv1beta1.ConditionReasonAuthzPrimaryProviderRequiresAuthServer,
+ expectedWarning: warningExpectation{expectPresent: false},
+ },
}
for _, tt := range tests {
@@ -3773,6 +3858,161 @@ func TestVirtualMCPServerValidateAuthzUpstreamAvailable_ClearsStaleWarning(t *te
}
}
+// TestVirtualMCPServerValidateAuthzUpstreamAvailable_ConfigMapFallThrough
+// pins the documented fall-through contract for configMap-sourced authz: the
+// new admission rejections never fire because primaryUpstreamProvider lives on
+// InlineAuthzConfig only. ConfigMap users get auto-selection of the first
+// upstream and the multi-upstream advisory, identical to inline users with no
+// explicit override. Locks the inline-only contract until the configMap
+// loader (TODO #5208) is implemented.
+func TestVirtualMCPServerValidateAuthzUpstreamAvailable_ConfigMapFallThrough(t *testing.T) {
+ t.Parallel()
+
+ configMapAuthzRef := &mcpv1beta1.AuthzConfigRef{
+ Type: "configMap",
+ ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{
+ Name: "authz-policies",
+ Key: "authz.json",
+ },
+ }
+
+ vmcp := &mcpv1beta1.VirtualMCPServer{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: testVmcpName,
+ Namespace: "default",
+ Generation: 1,
+ },
+ Spec: mcpv1beta1.VirtualMCPServerSpec{
+ GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName},
+ IncomingAuth: &mcpv1beta1.IncomingAuthConfig{
+ Type: "oidc",
+ AuthzConfig: configMapAuthzRef,
+ },
+ AuthServerConfig: &mcpv1beta1.EmbeddedAuthServerConfig{
+ Issuer: "https://authserver.example.com",
+ UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{
+ {Name: "okta", Type: mcpv1beta1.UpstreamProviderTypeOIDC},
+ {Name: "entra", Type: mcpv1beta1.UpstreamProviderTypeOIDC},
+ },
+ },
+ },
+ }
+
+ r := &VirtualMCPServerReconciler{}
+ statusManager := virtualmcpserverstatus.NewStatusManager(vmcp)
+ require.NoError(t, r.validateAuthzUpstreamAvailable(t.Context(), vmcp, statusManager))
+ statusManager.UpdateStatus(t.Context(), &vmcp.Status)
+
+ advisoryFound := false
+ for _, cond := range vmcp.Status.Conditions {
+ if cond.Type == mcpv1beta1.ConditionTypeAuthzUpstreamSelectionWarning {
+ advisoryFound = true
+ assert.Equal(t, metav1.ConditionTrue, cond.Status)
+ assert.Equal(t, mcpv1beta1.ConditionReasonAuthzUpstreamAutoSelected, cond.Reason)
+ }
+ assert.NotEqual(t, mcpv1beta1.ConditionReasonAuthzPrimaryProviderRequiresAuthServer, cond.Reason,
+ "configMap-sourced authz should not trip the no-AS rejection")
+ assert.NotEqual(t, mcpv1beta1.ConditionReasonAuthzUpstreamUnknown, cond.Reason,
+ "configMap-sourced authz should not trip the upstream-mismatch rejection")
+ }
+ assert.True(t, advisoryFound, "multi-upstream advisory should be present for configMap authz")
+}
+
+// TestVirtualMCPServerValidateAuthzUpstreamAvailable_ClearsStaleAuthzUnknown
+// verifies the recovery path for the new failure reasons: a VMCP that was
+// previously rejected with AuthServerConfigValidated=False (either reason)
+// must transition back to a passing state after the spec is corrected.
+// Without this, a fix-then-reconcile cycle would leave the VMCP stuck in
+// Failed phase forever.
+//
+// AuthServerConfigValidated is co-owned by validateAuthServerConfig (sets True
+// on success) and validateAuthzUpstreamAvailable (sets False on authz
+// rejection). The True transition comes from validateAuthServerConfig running
+// first; this test asserts validateAuthzUpstreamAvailable does NOT re-emit a
+// False rejection on a corrected spec, so the True from the prior validator
+// survives through to status.
+func TestVirtualMCPServerValidateAuthzUpstreamAvailable_ClearsStaleAuthzUnknown(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ staleReason string
+ }{
+ {
+ name: "recovers from AuthzUpstreamUnknown after fixing the explicit name",
+ staleReason: mcpv1beta1.ConditionReasonAuthzUpstreamUnknown,
+ },
+ {
+ name: "recovers from AuthzPrimaryProviderRequiresAuthServer after configuring AS",
+ staleReason: mcpv1beta1.ConditionReasonAuthzPrimaryProviderRequiresAuthServer,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ authzRef := &mcpv1beta1.AuthzConfigRef{
+ Type: "inline",
+ Inline: &mcpv1beta1.InlineAuthzConfig{
+ Policies: []string{`permit(principal, action, resource);`},
+ PrimaryUpstreamProvider: "okta",
+ },
+ }
+
+ vmcp := &mcpv1beta1.VirtualMCPServer{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: testVmcpName,
+ Namespace: "default",
+ Generation: 2,
+ },
+ Spec: mcpv1beta1.VirtualMCPServerSpec{
+ GroupRef: &mcpv1beta1.MCPGroupRef{Name: testGroupName},
+ IncomingAuth: &mcpv1beta1.IncomingAuthConfig{
+ Type: "oidc",
+ AuthzConfig: authzRef,
+ },
+ // Spec is now valid: explicit "okta" matches a declared upstream.
+ AuthServerConfig: &mcpv1beta1.EmbeddedAuthServerConfig{
+ Issuer: "https://authserver.example.com",
+ UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{
+ {Name: "okta", Type: mcpv1beta1.UpstreamProviderTypeOIDC},
+ },
+ },
+ },
+ Status: mcpv1beta1.VirtualMCPServerStatus{
+ Phase: mcpv1beta1.VirtualMCPServerPhaseFailed,
+ Conditions: []metav1.Condition{
+ {
+ Type: mcpv1beta1.ConditionTypeAuthServerConfigValidated,
+ Status: metav1.ConditionFalse,
+ Reason: tt.staleReason,
+ Message: "previous rejection from before the spec was fixed",
+ },
+ },
+ },
+ }
+
+ r := &VirtualMCPServerReconciler{}
+ statusManager := virtualmcpserverstatus.NewStatusManager(vmcp)
+
+ // Mirror the production reconcile order: AuthServerConfig validates
+ // first (sets AuthServerConfigValidated=True on success, overwriting
+ // the stale False), then the authz validator runs.
+ require.NoError(t, r.validateAuthServerConfig(vmcp, statusManager))
+ require.NoError(t, r.validateAuthzUpstreamAvailable(t.Context(), vmcp, statusManager))
+ statusManager.UpdateStatus(t.Context(), &vmcp.Status)
+
+ cond := meta.FindStatusCondition(vmcp.Status.Conditions, mcpv1beta1.ConditionTypeAuthServerConfigValidated)
+ require.NotNil(t, cond, "AuthServerConfigValidated condition should be present after recovery")
+ assert.Equal(t, metav1.ConditionTrue, cond.Status,
+ "AuthServerConfigValidated must transition back to True after the spec is corrected")
+ assert.NotEqual(t, tt.staleReason, cond.Reason,
+ "stale rejection reason must not survive the recovery cycle")
+ })
+ }
+}
+
// TestVirtualMCPServerValidateAuthServerConfig_IdentitySynthesizedCondition
// is the parity test: same condition shape as MCPExternalAuthConfig emits
// for the same upstreamProviders, on a VirtualMCPServer's inline AuthServerConfig.
diff --git a/cmd/thv-operator/pkg/vmcpconfig/converter.go b/cmd/thv-operator/pkg/vmcpconfig/converter.go
index cd31af6d69..4d309dee6f 100644
--- a/cmd/thv-operator/pkg/vmcpconfig/converter.go
+++ b/cmd/thv-operator/pkg/vmcpconfig/converter.go
@@ -189,16 +189,49 @@ func (c *Converter) convertIncomingAuth(
if vmcp.Spec.IncomingAuth.AuthzConfig.Type == authzLabelValueInline && vmcp.Spec.IncomingAuth.AuthzConfig.Inline != nil {
incoming.Authz.Policies = vmcp.Spec.IncomingAuth.AuthzConfig.Inline.Policies
}
- // TODO: Load policies from ConfigMap if Type is "configMap"
+ // 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 embedded AS or no upstreams) lets
- // Cedar fall back to claims from the ToolHive-issued token.
- if vmcp.Spec.AuthServerConfig != nil && len(vmcp.Spec.AuthServerConfig.UpstreamProviders) > 0 {
+ // 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)
+ }
+ 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,
)
diff --git a/cmd/thv-operator/pkg/vmcpconfig/converter_test.go b/cmd/thv-operator/pkg/vmcpconfig/converter_test.go
index cee72256af..c357125469 100644
--- a/cmd/thv-operator/pkg/vmcpconfig/converter_test.go
+++ b/cmd/thv-operator/pkg/vmcpconfig/converter_test.go
@@ -1898,16 +1898,22 @@ func TestConverter_TelemetryConfigRef(t *testing.T) {
// propagates the first configured upstream provider name into AuthzConfig so Cedar
// 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.
+// 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.
func TestConvertIncomingAuth_PrimaryUpstreamProvider(t *testing.T) {
t.Parallel()
- inlineAuthzRef := &mcpv1beta1.AuthzConfigRef{
- Type: "inline",
- Inline: &mcpv1beta1.InlineAuthzConfig{
- Policies: []string{`permit(principal, action, resource);`},
- },
+ authzWith := func(primary string) *mcpv1beta1.AuthzConfigRef {
+ return &mcpv1beta1.AuthzConfigRef{
+ Type: "inline",
+ Inline: &mcpv1beta1.InlineAuthzConfig{
+ Policies: []string{`permit(principal, action, resource);`},
+ PrimaryUpstreamProvider: primary,
+ },
+ }
}
+ inlineAuthzRef := authzWith("")
tests := []struct {
name string
@@ -1915,6 +1921,7 @@ func TestConvertIncomingAuth_PrimaryUpstreamProvider(t *testing.T) {
authzConfig *mcpv1beta1.AuthzConfigRef
expectAuthzNil bool
expectedProvider string
+ expectError bool
}{
{
name: "no auth server leaves provider unset",
@@ -1986,6 +1993,74 @@ func TestConvertIncomingAuth_PrimaryUpstreamProvider(t *testing.T) {
authzConfig: nil,
expectAuthzNil: true,
},
+ {
+ // Explicit primaryUpstreamProvider with a single upstream is honored
+ // (and matches it). Validates the explicit branch is taken at all.
+ name: "explicit primary provider with single upstream is honored",
+ authServerConfig: &mcpv1beta1.EmbeddedAuthServerConfig{
+ Issuer: "https://authserver.example.com",
+ UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{
+ {Name: "okta", Type: mcpv1beta1.UpstreamProviderTypeOIDC},
+ },
+ },
+ authzConfig: authzWith("okta"),
+ expectedProvider: "okta",
+ },
+ {
+ // Explicit primaryUpstreamProvider overrides the auto-selected first
+ // upstream when multiple are configured. This is the core feature.
+ name: "explicit primary provider overrides first of multiple upstreams",
+ authServerConfig: &mcpv1beta1.EmbeddedAuthServerConfig{
+ Issuer: "https://authserver.example.com",
+ UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{
+ {Name: "okta", Type: mcpv1beta1.UpstreamProviderTypeOIDC},
+ {Name: "github", Type: mcpv1beta1.UpstreamProviderTypeOAuth2},
+ {Name: "google", Type: mcpv1beta1.UpstreamProviderTypeOIDC},
+ },
+ },
+ authzConfig: authzWith("github"),
+ expectedProvider: "github",
+ },
+ {
+ // Exercises the actual normalization step inside ResolveUpstreamName:
+ // the upstream is declared with Name:"" (which resolves to "default")
+ // and the user pins primaryUpstreamProvider to "default". The explicit
+ // branch must forward "default" — exercising both the explicit path
+ // and the resolver's empty-input handling. The previous "okta -> okta"
+ // case did not exercise normalization because ResolveUpstreamName is
+ // the identity function for non-empty input.
+ name: "explicit primary provider 'default' resolves to default upstream",
+ authServerConfig: &mcpv1beta1.EmbeddedAuthServerConfig{
+ Issuer: "https://authserver.example.com",
+ UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{
+ {Name: "", Type: mcpv1beta1.UpstreamProviderTypeOIDC},
+ },
+ },
+ authzConfig: authzWith("default"),
+ expectedProvider: "default",
+ },
+ {
+ // Defense in depth: even if invoked outside the reconcile flow
+ // (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.
+ name: "explicit primary provider without auth server is rejected",
+ authServerConfig: nil,
+ authzConfig: authzWith("okta"),
+ expectError: true,
+ },
+ {
+ name: "explicit primary provider that doesn't match any upstream is rejected",
+ authServerConfig: &mcpv1beta1.EmbeddedAuthServerConfig{
+ Issuer: "https://authserver.example.com",
+ UpstreamProviders: []mcpv1beta1.UpstreamProviderConfig{
+ {Name: "okta", Type: mcpv1beta1.UpstreamProviderTypeOIDC},
+ },
+ },
+ authzConfig: authzWith("ping"),
+ expectError: true,
+ },
}
for _, tt := range tests {
@@ -2008,6 +2083,10 @@ func TestConvertIncomingAuth_PrimaryUpstreamProvider(t *testing.T) {
ctx := log.IntoContext(t.Context(), logr.Discard())
incoming, err := converter.convertIncomingAuth(ctx, vmcp)
+ if tt.expectError {
+ require.Error(t, err)
+ return
+ }
require.NoError(t, err)
require.NotNil(t, incoming)
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 915417ddd9..21e6ee87a7 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
@@ -131,6 +131,23 @@ spec:
minItems: 1
type: array
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.
+ maxLength: 63
+ minLength: 1
+ pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
+ type: string
required:
- policies
type: object
@@ -697,6 +714,23 @@ spec:
minItems: 1
type: array
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.
+ maxLength: 63
+ minLength: 1
+ pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
+ type: string
required:
- policies
type: object
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 3be75f8a63..04179042e7 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
@@ -138,6 +138,23 @@ spec:
minItems: 1
type: array
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.
+ maxLength: 63
+ minLength: 1
+ pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
+ type: string
required:
- policies
type: object
@@ -1002,6 +1019,23 @@ spec:
minItems: 1
type: array
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.
+ maxLength: 63
+ minLength: 1
+ pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
+ type: string
required:
- policies
type: object
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 a51fe4b5bd..bb5b074f48 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
@@ -2089,6 +2089,23 @@ spec:
minItems: 1
type: array
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.
+ maxLength: 63
+ minLength: 1
+ pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
+ type: string
required:
- policies
type: object
@@ -4585,6 +4602,23 @@ spec:
minItems: 1
type: array
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.
+ maxLength: 63
+ minLength: 1
+ pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
+ type: string
required:
- policies
type: object
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 79154701fb..ac0ea398db 100644
--- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml
+++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpremoteproxies.yaml
@@ -134,6 +134,23 @@ spec:
minItems: 1
type: array
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.
+ maxLength: 63
+ minLength: 1
+ pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
+ type: string
required:
- policies
type: object
@@ -700,6 +717,23 @@ spec:
minItems: 1
type: array
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.
+ maxLength: 63
+ minLength: 1
+ pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
+ type: string
required:
- policies
type: object
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 89b0a20107..5fd7dbd415 100644
--- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml
+++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml
@@ -141,6 +141,23 @@ spec:
minItems: 1
type: array
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.
+ maxLength: 63
+ minLength: 1
+ pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
+ type: string
required:
- policies
type: object
@@ -1005,6 +1022,23 @@ spec:
minItems: 1
type: array
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.
+ maxLength: 63
+ minLength: 1
+ pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
+ type: string
required:
- policies
type: object
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 6078670479..fbd8209525 100644
--- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml
+++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml
@@ -2092,6 +2092,23 @@ spec:
minItems: 1
type: array
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.
+ maxLength: 63
+ minLength: 1
+ pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
+ type: string
required:
- policies
type: object
@@ -4588,6 +4605,23 @@ spec:
minItems: 1
type: array
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.
+ maxLength: 63
+ minLength: 1
+ pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$
+ type: string
required:
- policies
type: object
diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md
index e60f05015e..d81ee93ded 100644
--- a/docs/operator/crd-api.md
+++ b/docs/operator/crd-api.md
@@ -1415,6 +1415,7 @@ _Appears in:_
| --- | --- | --- | --- |
| `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: \{\}
|
#### api.v1beta1.InlineOIDCSharedConfig
diff --git a/docs/operator/virtualmcpserver-api.md b/docs/operator/virtualmcpserver-api.md
index 2417da9754..7d90254155 100644
--- a/docs/operator/virtualmcpserver-api.md
+++ b/docs/operator/virtualmcpserver-api.md
@@ -83,6 +83,17 @@ Configures authentication for clients connecting to the Virtual MCP server. Reus
- `audience` (string, required): Must be unique per server to prevent token replay
- `scopes` ([]string, optional): Defaults to `["openid"]`
- `authzConfig` (AuthzConfigRef, optional): Authorization policy configuration
+ - `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
**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 379ca9aaa6..11e8fbf2ae 100644
--- a/docs/operator/virtualmcpserver-kubernetes-guide.md
+++ b/docs/operator/virtualmcpserver-kubernetes-guide.md
@@ -625,6 +625,20 @@ Then gradually add restrictions. Common Cedar policy issues:
- Verify attribute names match token claims
- Test policies with different user roles
+**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`:
+
+```yaml
+authzConfig:
+ type: inline
+ inline:
+ primaryUpstreamProvider: okta # must match one of the configured upstreams
+ policies:
+ - 'permit(principal, action, resource);'
+```
+
### Backend Discovery Issues
#### Backends Not Discovered