-
Notifications
You must be signed in to change notification settings - Fork 191
Description
Description
Move the multi-upstream restriction from the auth server library layer to the consumer layers: lift the len > 1 guard from pkg/authserver/config.go, add explicit-name and uniqueness requirements for multi-upstream configs, add a len > 1 guard to the proxy runner, remove the guard from MCPExternalAuthConfig.validateEmbeddedAuthServer(), and add multi-upstream rejection to the MCPServer and MCPRemoteProxy controller reconcile loops (while leaving VirtualMCPServer intentionally unrestricted). This is the config and operator plumbing that lets VirtualMCPServer reference multi-upstream auth configs once Phase 2 (handler layer) lands.
Context
RFC-0052 introduces a sequential multi-upstream authorization chain in the embedded auth server. Phase 1 (#4136) restructured the storage layer so multiple providers' tokens can coexist per session. Phase 3 updates the validation boundaries: the library (pkg/authserver/) should no longer enforce single-upstream — that check belongs at the consumer layer, where the proxy runner and the K8s workload types MCPServer/MCPRemoteProxy remain single-upstream, while VirtualMCPServer is the intended multi-upstream consumer. Phase 3 can be developed in parallel with Phase 2 after Phase 1 merges.
Dependencies: #4136 (Phase 1 — Storage Layer; the new UpstreamTokenStorage signatures must exist before this phase is merged so that the library layer can accept multi-upstream configs end-to-end)
Blocks: None (TASK-004 depends on TASK-001 only)
Acceptance Criteria
-
pkg/authserver/config.govalidateUpstreams()no longer contains theif len(c.Upstreams) > 1rejection block -
validateUpstreams()adds a new explicit-name requirement: whenlen(c.Upstreams) > 1, each upstream'sNamefield must be explicitly set (non-empty and not"default") before defaulting; a missing or"default"name in multi-upstream context returns a clear error -
validateUpstreams()name-uniqueness check is retained (already present; verify it runs even after removing thelen > 1early return) -
Config.GetUpstream()method is removed frompkg/authserver/config.go; the sole call sitepkg/authserver/server_impl.gois updated to accesscfg.Upstreams[0](or equivalent) directly instead -
TestConfigGetUpstreamtest function is removed frompkg/authserver/config_test.go; the test case"multiple upstreams"inTestConfigValidateis updated to expect success (no error) instead of the old "multiple upstreams not yet supported" error; the"duplicate upstream names"test case is updated to verify the correct new error message - A new test case is added in
TestConfigValidatefor multi-upstream with explicit names (valid) and multi-upstream where one upstream has an empty or"default"name (invalid) -
pkg/runner/has a new validation path that rejectsEmbeddedAuthServerConfigwithlen(Upstreams) > 1; this validation is invoked before the runner starts the embedded auth server - New tests in
pkg/runner/(orpkg/runner/config_test.go) cover: embedded auth server config with 1 upstream passes; embedded auth server config with 2 upstreams fails with a clear error -
MCPExternalAuthConfig.validateEmbeddedAuthServer()incmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.gono longer contains theif len(cfg.UpstreamProviders) > 1rejection block and its comment -
MCPExternalAuthConfig.Validate()incmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types_test.gono longer has a test case that expectsvalidateEmbeddedAuthServerto rejectlen > 1upstreams; a new test case verifies that a multi-upstreamMCPExternalAuthConfigpassesValidate()successfully -
MCPServercontroller reconcile logic incmd/thv-operator/controllers/mcpserver_controller.gorejects anMCPExternalAuthConfigreference that hasembeddedAuthServertype with more than one upstream provider, using theConditionTypeExternalAuthConfigValidatedstatus condition pattern already present inhandleExternalAuthConfig; the condition message clearly identifies the cause -
MCPRemoteProxycontroller reconcile logic incmd/thv-operator/controllers/mcpremoteproxy_controller.goapplies the same multi-upstream rejection for itsExternalAuthConfigRefusing theConditionTypeMCPRemoteProxyExternalAuthConfigValidatedstatus condition pattern -
VirtualMCPServercontroller reconcile logic does NOT enforce a single-upstream restriction on itsExternalAuthConfigRef(the resource is intentionally exempt) - Tests are added or updated in
cmd/thv-operator/controllers/mcpserver_controller_test.goto cover:MCPServerreferencing a single-upstream config passes;MCPServerreferencing a multi-upstream config fails withConditionTypeExternalAuthConfigValidated = False - Tests are added or updated in
cmd/thv-operator/controllers/mcpremoteproxy_controller_test.gofor the equivalentMCPRemoteProxycases -
task license-checkpasses (all new/modified.gofiles have SPDX headers) -
task lint-fixpasses with no remaining lint errors -
task testpasses (unit tests)
Technical Approach
Recommended Implementation
Start with pkg/authserver/config.go since it is the most self-contained change: remove the two-line len > 1 guard, then add the multi-upstream explicit-name rule (when len > 1, an empty name or the literal "default" must be rejected with a descriptive error). Remove GetUpstream() from config.go and update the single call site in server_impl.go to use cfg.Upstreams[0] directly. Update config_test.go to match.
Next, add the proxy runner validation. The RunConfig struct in pkg/runner/config.go already embeds *authserver.RunConfig as EmbeddedAuthServerConfig. The natural place for the guard is either a new Validate() method on RunConfig or inline in the runner startup path in pkg/runner/runner.go where the embedded auth server is initialized. Check where EmbeddedAuthServerConfig is first used and add the guard there; a standalone validation function is preferred so it can be independently unit-tested.
Then update the operator. In cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go, delete lines 786-789 (the len > 1 guard and its comment). In the MCPServer and MCPRemoteProxy controllers, extend the existing handleExternalAuthConfig functions: after fetching the referenced MCPExternalAuthConfig, inspect the UpstreamProviders slice length. If len > 1 and the auth type is embeddedAuthServer, set the ExternalAuthConfigValidated condition to False with a clear reason and message, and return an error to prevent reconciliation from proceeding.
Patterns and Frameworks
MCPExternalAuthConfig.validateEmbeddedAuthServer()pattern: The existing validation helper incmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go(lines 772–798) is the reference for how CRD-level GoValidate()methods are structured. After Phase 3, thelen > 1block (lines 786–789) is removed; the pattern forlen == 0check and per-provider loop is retained unchanged.handleExternalAuthConfigcontroller pattern:MCPServerReconciler.handleExternalAuthConfig(lines 1805–1849 inmcpserver_controller.go) andMCPRemoteProxyReconciler.handleExternalAuthConfig(lines 477–522 inmcpremoteproxy_controller.go) already fetch the referenced config, set conditions, and return errors. The multi-upstream check should be inserted after the fetch succeeds, before the hash check, following the samemeta.SetStatusCondition+return errorpattern.ConditionTypeExternalAuthConfigValidated/ConditionTypeMCPRemoteProxyExternalAuthConfigValidated: Use the existing constants and reason constants (e.g.,ConditionReasonMCPRemoteProxyExternalAuthConfigValid) frommcpserver_types.goandmcpremoteproxy_types.go. Add new reason constants for the multi-upstream rejection case following the naming pattern in those files.- Table-driven tests with
require.NoError: Follow the pattern inpkg/authserver/config_test.goandcmd/thv-operator/controllers/mcpremoteproxy_controller_test.gofor table-driven test organization. fmt.Errorfwith%w: Use for all error wrapping;errors.Is()for inspection.- SPDX license headers: All new or modified
.gofiles must have the standard header. Runtask license-fixto add them automatically.
Code Pointers
pkg/authserver/config.go— Primary file: remove lines 392–394 (if len(c.Upstreams) > 1block) fromvalidateUpstreams(); removeGetUpstream()method (lines 342–350); add explicit-name validation for multi-upstream in the existingfor i := range c.Upstreamsloop (the upstream name defaulting to"default"when empty must only apply to the single-upstream case)pkg/authserver/server_impl.goline 128 — Updatecfg.GetUpstream()call to&cfg.Upstreams[0]; confirm no other files callGetUpstream()by searching the repositorypkg/authserver/config_test.golines 55–109 —TestConfigValidate: update the"multiple upstreams"case (line 84) fromwantErr: truetowantErr: false; update the"duplicate upstream names"case (line 85) to expect the new error message from the updated uniqueness check; add new test cases for multi-upstream with valid explicit names and multi-upstream with a"default"name; lines 111–149TestConfigGetUpstream: remove the entire test functionpkg/runner/config.go— Add avalidateEmbeddedAuthServerConfighelper function (or add to an existing validate path); rejectEmbeddedAuthServerConfigwhenlen(Upstreams) > 1with an error like"proxy runner does not support multiple upstream providers (found %d); use VirtualMCPServer for multi-upstream deployments"pkg/runner/config_test.go— Add table-driven test cases for the new proxy runner embedded auth server validationcmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.golines 786–789 — Remove thelen > 1block; update the comment on line 786 to remove the "max items = 1" note; updatemcpexternalauthconfig_types_test.goto add a test case for multi-upstreamMCPExternalAuthConfigthat passes validationcmd/thv-operator/controllers/mcpserver_controller.golines 1820–1848 — InhandleExternalAuthConfig, after line 1828 (nil check), add alen(externalAuthConfig.Spec.EmbeddedAuthServer.UpstreamProviders) > 1check; setConditionTypeExternalAuthConfigValidatedcondition toFalsewith a new reason constant; return an errorcmd/thv-operator/controllers/mcpremoteproxy_controller.golines 491–522 — Apply the same multi-upstream check inhandleExternalAuthConfigafter the config is fetchedcmd/thv-operator/api/v1alpha1/mcpserver_types.go— Add a new condition reason constant for the multi-upstream rejection (following theConditionReasonExternalAuthConfig*naming pattern)cmd/thv-operator/api/v1alpha1/mcpremoteproxy_types.go— Add a new condition reason constant forMCPRemoteProxymulti-upstream rejectioncmd/thv-operator/controllers/mcpserver_controller_test.go— Add test cases forhandleExternalAuthConfigwith multi-upstream embedded auth server configcmd/thv-operator/controllers/mcpremoteproxy_controller_test.go— Add equivalent test cases
Component Interfaces
// pkg/authserver/config.go — updated validateUpstreams() signature (unchanged)
// Remove: the len > 1 early-return block
// Add: explicit-name enforcement for multi-upstream
func (c *Config) validateUpstreams() error {
if len(c.Upstreams) == 0 {
return fmt.Errorf("at least one upstream is required")
}
// len > 1 guard removed here
seenNames := make(map[string]bool)
for i := range c.Upstreams {
up := &c.Upstreams[i]
// For single-upstream, allow empty name to default to "default".
// For multi-upstream, each upstream must have an explicit, non-"default" name.
if len(c.Upstreams) == 1 {
if up.Name == "" {
up.Name = "default"
}
} else {
// Multi-upstream: explicit names required
if up.Name == "" || up.Name == "default" {
return fmt.Errorf(
"upstream[%d]: name must be explicitly set and must not be %q in multi-upstream configs",
i, "default",
)
}
}
if seenNames[up.Name] {
return fmt.Errorf("duplicate upstream name: %q", up.Name)
}
seenNames[up.Name] = true
// ... existing per-upstream type validation unchanged ...
}
return nil
}
// GetUpstream() is removed entirely — no replacement in this file.
// Call sites use cfg.Upstreams[0] directly.// pkg/runner/ — new validation for proxy runner
// Location: validate function or runner startup, called before embedded auth server start.
// validateEmbeddedAuthServerConfig enforces proxy runner constraints on the embedded auth
// server configuration. The proxy runner supports exactly one upstream provider.
// VirtualMCPServer deployments must be used for multi-upstream configurations.
func validateEmbeddedAuthServerConfig(cfg *authserver.RunConfig) error {
if cfg == nil {
return nil
}
if len(cfg.Upstreams) > 1 {
return fmt.Errorf(
"proxy runner does not support multiple upstream providers (found %d); "+
"use VirtualMCPServer for multi-upstream deployments",
len(cfg.Upstreams),
)
}
return nil
}// cmd/thv-operator/api/v1alpha1/mcpserver_types.go — new condition reason constant
// Add alongside existing ConditionReasonExternalAuthConfig* constants:
// ConditionReasonExternalAuthConfigMultiUpstreamNotSupported indicates that the
// referenced MCPExternalAuthConfig has multiple upstream providers, which MCPServer
// does not support. Use VirtualMCPServer for multi-upstream configurations.
ConditionReasonExternalAuthConfigMultiUpstreamNotSupported = "MultiUpstreamNotSupported"// cmd/thv-operator/api/v1alpha1/mcpremoteproxy_types.go — new condition reason constant
// ConditionReasonMCPRemoteProxyExternalAuthConfigMultiUpstreamNotSupported indicates
// that the referenced MCPExternalAuthConfig has multiple upstream providers, which
// MCPRemoteProxy does not support. Use VirtualMCPServer for multi-upstream configurations.
ConditionReasonMCPRemoteProxyExternalAuthConfigMultiUpstreamNotSupported = "MultiUpstreamNotSupported"// cmd/thv-operator/controllers/mcpserver_controller.go — handleExternalAuthConfig
// Insert after fetching the config and the nil check, before the hash check:
embeddedCfg := externalAuthConfig.Spec.EmbeddedAuthServer
if embeddedCfg != nil && len(embeddedCfg.UpstreamProviders) > 1 {
meta.SetStatusCondition(&m.Status.Conditions, metav1.Condition{
Type: mcpv1alpha1.ConditionTypeExternalAuthConfigValidated,
Status: metav1.ConditionFalse,
Reason: mcpv1alpha1.ConditionReasonExternalAuthConfigMultiUpstreamNotSupported,
Message: fmt.Sprintf(
"MCPExternalAuthConfig %q has %d upstream providers; MCPServer supports only one. "+
"Use VirtualMCPServer for multi-upstream deployments.",
externalAuthConfig.Name, len(embeddedCfg.UpstreamProviders),
),
ObservedGeneration: m.Generation,
})
return fmt.Errorf(
"MCPExternalAuthConfig %q has %d upstream providers; MCPServer supports only one",
externalAuthConfig.Name, len(embeddedCfg.UpstreamProviders),
)
}Testing Strategy
Unit Tests — pkg/authserver/config_test.go
- Update
"multiple upstreams"test case inTestConfigValidatetowantErr: false(two upstreams with explicit non-"default"names should now pass) - Update
"duplicate upstream names"test case to use a two-upstream config with non-"default"names that collide; verify the duplicate-name error message is correct - Add
"multi-upstream with empty name"test case: two upstreams where oneNameis empty; expect error containing "must be explicitly set" - Add
"multi-upstream with default name"test case: two upstreams where oneNameis"default"; expect error containing "must not be" - Add
"multi-upstream valid explicit names"test case: two upstreams with distinct non-"default"names; expect no error - Remove
TestConfigGetUpstreamtest function entirely
Unit Tests — pkg/runner/config_test.go
-
"embedded auth server config nil": nilEmbeddedAuthServerConfigreturns no error -
"embedded auth server config single upstream": config with 1 upstream returns no error -
"embedded auth server config two upstreams": config with 2 upstreams returns error containing "proxy runner does not support multiple upstream providers"
Unit Tests — cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types_test.go
- Update or remove the existing test case that expects
validateEmbeddedAuthServerto rejectlen > 1upstreams; replace with a test case that expects multi-upstreamMCPExternalAuthConfigto passValidate()successfully (two providers with valid configs)
Unit Tests — cmd/thv-operator/controllers/mcpserver_controller_test.go
-
"handleExternalAuthConfig single upstream passes":MCPExternalAuthConfigwith 1 upstream provider; expect no error, condition set toTrue -
"handleExternalAuthConfig multi upstream rejected":MCPExternalAuthConfigwith 2 upstream providers; expect error; condition set toFalsewith reasonMultiUpstreamNotSupported
Unit Tests — cmd/thv-operator/controllers/mcpremoteproxy_controller_test.go
- Same two test cases as above for
MCPRemoteProxy.handleExternalAuthConfig
Edge Cases
-
MCPExternalAuthConfigwithembeddedAuthServer: nil(different auth type) must not be rejected whenMCPServerorMCPRemoteProxyreferences it — the multi-upstream guard only applies whenembeddedAuthServeris non-nil - Single-upstream config with empty
Namefield continues to default to"default"(backward compatibility for existing single-upstream deployments) -
validateUpstreams()called on a config with exactly 2 upstreams: both with distinct non-"default"names passes; one with"default"name fails
Out of Scope
- Handler layer changes for multi-upstream chain logic (sequential authorize/callback,
nextMissingUpstream): covered in TASK-002 Identity.UpstreamTokensenrichment andupstreamswapmiddleware refactor: covered in TASK-004- Multi-upstream support in the CLI proxy runner or
MCPServer/MCPRemoteProxyK8s workload types — these workload types intentionally remain single-upstream VirtualMCPServervalidation changes — it is explicitly exempt from the single-upstream restrictioncmd/thv-operator/pkg/controllerutil/authserver.gomulti-upstream support (line 435 currently takes onlyUpstreamProviders[0]): this is a consumer-layer wiring concern that may be needed for Phase 2 or a follow-on task, but is not required to complete the Phase 3 validation boundary changespkg/authserver/server_impl.gomulti-upstream handler wiring (building theupstreams mapandupstreamOrderslice): covered in TASK-002- CRD kubebuilder marker changes (
+kubebuilder:validation:MaxItems=1) onMCPExternalAuthConfig.EmbeddedAuthServerConfig.upstreamProviders— assess whether the admission webhook marker also needs updating; if it blocks multi-upstream at API admission time, it must be relaxed; coordinate with the operator CRD regeneration (task operator-generate,task operator-manifests,task crdref-gen)
References
- RFC:
docs/proposals/THV-0052-multi-upstream-idp-authserver.md - Architecture doc:
docs/arch/11-auth-server-storage.md - Parent epic: Auth Server: multi-upstream provider support #3924
- Phase 1 (dependency): Implement multi-provider upstream token storage layer (RFC-0052 Phase 1) #4136
- Related Stacklok epic: https://github.com/stacklok/stacklok-epics/issues/251