Skip to content
42 changes: 42 additions & 0 deletions cmd/thv-operator/api/v1beta1/mcpserver_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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 (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Comment thread
tgrunnagle marked this conversation as resolved.
// +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"`
Comment thread
tgrunnagle marked this conversation as resolved.
}

// AuditConfig defines audit logging configuration for the MCP server
Expand Down
14 changes: 14 additions & 0 deletions cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
28 changes: 28 additions & 0 deletions cmd/thv-operator/controllers/mcpremoteproxy_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions cmd/thv-operator/controllers/mcpserver_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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{
Expand Down
119 changes: 98 additions & 21 deletions cmd/thv-operator/controllers/virtualmcpserver_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"fmt"
"maps"
"reflect"
"slices"
"strings"
"time"

Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -583,16 +615,38 @@ 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
}

// 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.
Expand All @@ -602,37 +656,60 @@ 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,
mcpv1beta1.ConditionReasonAuthzUpstreamAutoSelected,
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,
Expand Down
Loading
Loading