You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Add the Kubernetes CRD type definitions and converter implementation needed to configure the upstream_inject outgoing auth strategy from a MCPExternalAuthConfig resource. This phase extends the operator API with a new ExternalAuthTypeUpstreamInject constant and UpstreamInjectSpec struct, wires admission-time and reconciliation-time validation, and introduces UpstreamInjectConverter to translate CRD config into a BackendAuthStrategy. It also back-fills the SubjectProviderName field on both the CRD's TokenExchangeConfig struct and the TokenExchangeConverter so the RFC 8693 subject-token enhancement introduced in Phase 1 (#4144) is fully usable from Kubernetes.
Context
This is Phase 4 of the RFC-0054 epic (#3925), which implements the upstream_inject outgoing auth strategy for vMCP. Phase 1 (#4144) established the shared Go types (StrategyTypeUpstreamInject, UpstreamInjectConfig, ErrUpstreamTokenNotFound, SubjectProviderName) that all subsequent phases depend on. Phase 4 extends those types into the Kubernetes operator layer — defining the CRD field contract for upstreamInject and implementing the converter that bridges the Kubernetes API object to the vMCP runtime configuration.
Phase 4 can be developed in parallel with Phases 2 and 3 immediately after Phase 1 lands because it only depends on the types package; it does not require identity.UpstreamTokens (RFC-0052) or the validateAuthServerIntegration scaffold (RFC-0053 Phase 3) to compile or unit-test.
ExternalAuthTypeUpstreamInject ExternalAuthType = "upstreamInject" constant is present in cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go alongside the other ExternalAuthType* constants
UpstreamInjectSpec struct is present with a single ProviderName string field tagged json:"providerName" and a // +kubebuilder:validation:MinLength=1 marker
MCPExternalAuthConfigSpec has a new field UpstreamInject *UpstreamInjectSpec \json:"upstreamInject,omitempty"`annotated// +optional`
upstreamInject is added to the +kubebuilder:validation:Enum marker on the Type field so the admission webhook rejects unknown values
A CEL admission rule is added to MCPExternalAuthConfigSpec: self.type == 'upstreamInject' ? has(self.upstreamInject) : !has(self.upstreamInject) (with message "upstreamInject configuration must be set if and only if type is 'upstreamInject'")
validateTypeConfigConsistency() gains a nil-equality check: if (r.Spec.UpstreamInject == nil) == (r.Spec.Type == ExternalAuthTypeUpstreamInject) returning an appropriate error message
The Validate() switch statement handles ExternalAuthTypeUpstreamInject (verifying ProviderName is non-empty) so an object that bypasses CEL still receives a meaningful error from the controller
SubjectProviderName string \json:"subjectProviderName,omitempty"`is added to the CRD'sTokenExchangeConfigstruct (distinct frompkg/vmcp/auth/types/TokenExchangeConfig`)
pkg/vmcp/auth/converters/upstream_inject.go is created implementing UpstreamInjectConverter (stateless struct, StrategyType() returns authtypes.StrategyTypeUpstreamInject, ConvertToStrategy() returns a correctly populated BackendAuthStrategy, ResolveSecrets() is a pass-through)
TokenExchangeConverter.ConvertToStrategy() in pkg/vmcp/auth/converters/token_exchange.go populates SubjectProviderName from the CRD's TokenExchangeConfig.SubjectProviderName
NewRegistry() in pkg/vmcp/auth/converters/interface.go registers UpstreamInjectConverter: r.Register(mcpv1alpha1.ExternalAuthTypeUpstreamInject, &UpstreamInjectConverter{})
CRD codegen is re-run after type changes: task operator-generate, task operator-manifests, and task crdref-gen (from cmd/thv-operator/) all succeed without error
Unit tests pass for UpstreamInjectConverter (3 table-driven cases: ConvertToStrategy valid, ConvertToStrategy nil spec, ResolveSecrets pass-through)
Regression test entries are added to TestTokenExchangeConverter_ConvertToStrategy verifying that SubjectProviderName is populated when set and absent when unset
All existing tests continue to pass (task test)
SPDX license headers are present on all new and modified Go files (task license-check)
Technical Approach
Recommended Implementation
All changes fall into two areas: the operator CRD types file and the converters package.
CRD type changes (cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go):
Append ExternalAuthTypeUpstreamInject ExternalAuthType = "upstreamInject" to the existing const block.
Add UpstreamInjectSpec struct after AWSStsConfig with a single ProviderName field. No codegen markers are needed on this struct because the operator API types use controller-gen object generation at the resource root, not per-struct.
Add upstreamInject to the +kubebuilder:validation:Enum annotation on the Type field.
Add a CEL rule to the struct-level kubebuilder markers (following the existing tokenExchange, headerInjection, bearerToken, embeddedAuthServer, and awsSts rules).
In validateTypeConfigConsistency(), add the nil-equality check for UpstreamInject mirroring all existing checks.
In the Validate() switch, add a case ExternalAuthTypeUpstreamInject: that validates ProviderName is non-empty (similar to how validateAWSSts checks required fields).
Add SubjectProviderName string \json:"subjectProviderName,omitempty"`to the existingTokenExchangeConfig` struct.
Converter changes (pkg/vmcp/auth/converters/):
Create upstream_inject.go with UpstreamInjectConverter{}. ConvertToStrategy extracts UpstreamInject.ProviderName and returns a BackendAuthStrategy{Type: authtypes.StrategyTypeUpstreamInject, UpstreamInject: &authtypes.UpstreamInjectConfig{ProviderName: ...}}. ResolveSecrets returns the strategy unchanged with no error (no static secrets to resolve).
Update token_exchange.go's ConvertToStrategy to populate SubjectProviderName: tokenExchange.SubjectProviderName in the tokenExchangeConfig construction.
Register UpstreamInjectConverter in NewRegistry() in interface.go.
After making CRD changes, run the operator codegen commands listed in the acceptance criteria.
Patterns & Frameworks
Follow the discriminated union pattern used by all other ExternalAuthType types: one constant, one spec struct, one CEL rule, one nil-equality check, one Validate() case, one converter
UpstreamInjectConverter should mirror UnauthenticatedConverter in structure (stateless struct, minimal ConvertToStrategy, pass-through ResolveSecrets) but produce a non-empty config struct rather than a bare strategy
CEL rule format follows the existing pattern precisely: self.type == 'X' ? has(self.x) : !has(self.x); add the //nolint:lll comment is already present on the type
Use go.uber.org/mock (gomock) for any mock dependencies if needed, but the converter itself is fully unit-testable without mocks since it has no external dependencies
All new Go files require SPDX headers at top: // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. / // SPDX-License-Identifier: Apache-2.0
Code Pointers
cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go — the primary CRD type file; all CRD changes land here; read the existing CEL rules (lines 44-49), validateTypeConfigConsistency() (lines 740-770), and the Validate() switch (lines 714-736) to understand the exact patterns to follow
pkg/vmcp/auth/converters/interface.go — NewRegistry() (lines 67-78) is where UpstreamInjectConverter must be registered; also contains the StrategyConverter interface definition (lines 19-43) that UpstreamInjectConverter must satisfy
pkg/vmcp/auth/converters/unauthenticated.go — structural model for a stateless converter with a pass-through ResolveSecrets; UpstreamInjectConverter follows the same skeleton but constructs a typed config rather than a bare strategy
pkg/vmcp/auth/converters/token_exchange.go — ConvertToStrategy() (lines 29-67) needs one additional field populated; the tokenExchangeConfig struct literal (lines 50-56) is where SubjectProviderName: tokenExchange.SubjectProviderName is added
pkg/vmcp/auth/converters/token_exchange_test.go — the TestTokenExchangeConverter_ConvertToStrategy table (lines 29-255) is where new regression entries for SubjectProviderName are added
UpstreamInjectConverter must satisfy the StrategyConverter interface defined in pkg/vmcp/auth/converters/interface.go:
// UpstreamInjectConverter converts MCPExternalAuthConfig UpstreamInject to vMCP upstream_inject strategy.typeUpstreamInjectConverterstruct{}
// StrategyType returns the vMCP strategy type for upstream inject.func (*UpstreamInjectConverter) StrategyType() string {
returnauthtypes.StrategyTypeUpstreamInject
}
// ConvertToStrategy converts UpstreamInjectSpec to a BackendAuthStrategy with typed fields.func (*UpstreamInjectConverter) ConvertToStrategy(
externalAuth*mcpv1alpha1.MCPExternalAuthConfig,
) (*authtypes.BackendAuthStrategy, error) {
upstreamInject:=externalAuth.Spec.UpstreamInjectifupstreamInject==nil {
returnnil, fmt.Errorf("upstream inject config is nil")
}
return&authtypes.BackendAuthStrategy{
Type: authtypes.StrategyTypeUpstreamInject,
UpstreamInject: &authtypes.UpstreamInjectConfig{
ProviderName: upstreamInject.ProviderName,
},
}, nil
}
// ResolveSecrets is a no-op pass-through for upstream_inject; there are no static secrets to resolve.func (*UpstreamInjectConverter) ResolveSecrets(
_ context.Context,
_*mcpv1alpha1.MCPExternalAuthConfig,
_ client.Client,
_string,
strategy*authtypes.BackendAuthStrategy,
) (*authtypes.BackendAuthStrategy, error) {
returnstrategy, nil
}
CRD type additions that downstream phases depend on (all in MCPExternalAuthConfigSpec / mcpexternalauthconfig_types.go):
// New constantExternalAuthTypeUpstreamInjectExternalAuthType="upstreamInject"// New spec structtypeUpstreamInjectSpecstruct {
// ProviderName is the name of the upstream IDP provider whose access token// should be injected as the Authorization: Bearer header.// +kubebuilder:validation:MinLength=1ProviderNamestring`json:"providerName"`
}
// New field on MCPExternalAuthConfigSpec// UpstreamInject configures upstream token injection for backend requests.// Only used when Type is "upstreamInject".// +optionalUpstreamInject*UpstreamInjectSpec`json:"upstreamInject,omitempty"`
TokenExchangeConfig CRD addition (the CRD struct in mcpexternalauthconfig_types.go, not the types package):
// SubjectProviderName is the name of the upstream provider whose token is used as the// RFC 8693 subject token instead of identity.Token when performing token exchange.// +optionalSubjectProviderNamestring`json:"subjectProviderName,omitempty"`
Testing Strategy
Unit Tests — pkg/vmcp/auth/converters/upstream_inject_test.go (new file, 3 table-driven cases in a single TestUpstreamInjectConverter_ConvertToStrategy function):
ConvertToStrategy valid config: input MCPExternalAuthConfig with Type: ExternalAuthTypeUpstreamInject and UpstreamInject: &UpstreamInjectSpec{ProviderName: "github"} → expect BackendAuthStrategy{Type: "upstream_inject", UpstreamInject: &UpstreamInjectConfig{ProviderName: "github"}}, no error
ConvertToStrategy nil spec: input with UpstreamInject: nil → expect error containing "upstream inject config is nil"
ResolveSecrets pass-through: input strategy is returned unchanged; wantStrategy equals inputStrategy; no error
Unit Tests — pkg/vmcp/auth/converters/token_exchange_test.go (additions to existing TestTokenExchangeConverter_ConvertToStrategy table):
SubjectProviderName absent (regression): input TokenExchangeConfig{TokenURL: "..."} with no SubjectProviderName → expect authtypes.TokenExchangeConfig{...} with SubjectProviderName: "", no error; ensures no regression on the zero-value case
Codegen Verification:
task operator-generate succeeds with no unexpected changes beyond the new type
task operator-manifests succeeds and the updated CRD YAML includes the upstreamInject field and enum value
task crdref-gen (run from inside cmd/thv-operator/) succeeds
License Check:
task license-check passes (SPDX headers on all modified and new files)
Out of Scope
Implementation of UpstreamInjectStrategy runtime behavior (Phase 2)
Startup validation rules V-01, V-02, V-06 in pkg/vmcp/config/validator.go (Phase 3)
Description
Add the Kubernetes CRD type definitions and converter implementation needed to configure the
upstream_injectoutgoing auth strategy from aMCPExternalAuthConfigresource. This phase extends the operator API with a newExternalAuthTypeUpstreamInjectconstant andUpstreamInjectSpecstruct, wires admission-time and reconciliation-time validation, and introducesUpstreamInjectConverterto translate CRD config into aBackendAuthStrategy. It also back-fills theSubjectProviderNamefield on both the CRD'sTokenExchangeConfigstruct and theTokenExchangeConverterso the RFC 8693 subject-token enhancement introduced in Phase 1 (#4144) is fully usable from Kubernetes.Context
This is Phase 4 of the RFC-0054 epic (#3925), which implements the
upstream_injectoutgoing auth strategy for vMCP. Phase 1 (#4144) established the shared Go types (StrategyTypeUpstreamInject,UpstreamInjectConfig,ErrUpstreamTokenNotFound,SubjectProviderName) that all subsequent phases depend on. Phase 4 extends those types into the Kubernetes operator layer — defining the CRD field contract forupstreamInjectand implementing the converter that bridges the Kubernetes API object to the vMCP runtime configuration.Phase 4 can be developed in parallel with Phases 2 and 3 immediately after Phase 1 lands because it only depends on the types package; it does not require
identity.UpstreamTokens(RFC-0052) or thevalidateAuthServerIntegrationscaffold (RFC-0053 Phase 3) to compile or unit-test.Dependencies: #4144 (Phase 1: core types and sentinel)
Blocks: none (leaf phase)
Acceptance Criteria
ExternalAuthTypeUpstreamInject ExternalAuthType = "upstreamInject"constant is present incmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.goalongside the otherExternalAuthType*constantsUpstreamInjectSpecstruct is present with a singleProviderName stringfield taggedjson:"providerName"and a// +kubebuilder:validation:MinLength=1markerMCPExternalAuthConfigSpechas a new fieldUpstreamInject *UpstreamInjectSpec \json:"upstreamInject,omitempty"`annotated// +optional`upstreamInjectis added to the+kubebuilder:validation:Enummarker on theTypefield so the admission webhook rejects unknown valuesMCPExternalAuthConfigSpec:self.type == 'upstreamInject' ? has(self.upstreamInject) : !has(self.upstreamInject)(with message"upstreamInject configuration must be set if and only if type is 'upstreamInject'")validateTypeConfigConsistency()gains a nil-equality check:if (r.Spec.UpstreamInject == nil) == (r.Spec.Type == ExternalAuthTypeUpstreamInject)returning an appropriate error messageValidate()switch statement handlesExternalAuthTypeUpstreamInject(verifyingProviderNameis non-empty) so an object that bypasses CEL still receives a meaningful error from the controllerSubjectProviderName string \json:"subjectProviderName,omitempty"`is added to the CRD'sTokenExchangeConfigstruct (distinct frompkg/vmcp/auth/types/TokenExchangeConfig`)pkg/vmcp/auth/converters/upstream_inject.gois created implementingUpstreamInjectConverter(stateless struct,StrategyType()returnsauthtypes.StrategyTypeUpstreamInject,ConvertToStrategy()returns a correctly populatedBackendAuthStrategy,ResolveSecrets()is a pass-through)TokenExchangeConverter.ConvertToStrategy()inpkg/vmcp/auth/converters/token_exchange.gopopulatesSubjectProviderNamefrom the CRD'sTokenExchangeConfig.SubjectProviderNameNewRegistry()inpkg/vmcp/auth/converters/interface.goregistersUpstreamInjectConverter:r.Register(mcpv1alpha1.ExternalAuthTypeUpstreamInject, &UpstreamInjectConverter{})task operator-generate,task operator-manifests, andtask crdref-gen(fromcmd/thv-operator/) all succeed without errorUpstreamInjectConverter(3 table-driven cases:ConvertToStrategyvalid,ConvertToStrategynil spec,ResolveSecretspass-through)TestTokenExchangeConverter_ConvertToStrategyverifying thatSubjectProviderNameis populated when set and absent when unsettask test)task license-check)Technical Approach
Recommended Implementation
All changes fall into two areas: the operator CRD types file and the converters package.
CRD type changes (
cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go):ExternalAuthTypeUpstreamInject ExternalAuthType = "upstreamInject"to the existingconstblock.UpstreamInjectSpecstruct afterAWSStsConfigwith a singleProviderNamefield. No codegen markers are needed on this struct because the operator API types use controller-gen object generation at the resource root, not per-struct.UpstreamInject *UpstreamInjectSpec \json:"upstreamInject,omitempty"`field toMCPExternalAuthConfigSpec`.upstreamInjectto the+kubebuilder:validation:Enumannotation on theTypefield.tokenExchange,headerInjection,bearerToken,embeddedAuthServer, andawsStsrules).validateTypeConfigConsistency(), add the nil-equality check forUpstreamInjectmirroring all existing checks.Validate()switch, add acase ExternalAuthTypeUpstreamInject:that validatesProviderNameis non-empty (similar to howvalidateAWSStschecks required fields).SubjectProviderName string \json:"subjectProviderName,omitempty"`to the existingTokenExchangeConfig` struct.Converter changes (
pkg/vmcp/auth/converters/):upstream_inject.gowithUpstreamInjectConverter{}.ConvertToStrategyextractsUpstreamInject.ProviderNameand returns aBackendAuthStrategy{Type: authtypes.StrategyTypeUpstreamInject, UpstreamInject: &authtypes.UpstreamInjectConfig{ProviderName: ...}}.ResolveSecretsreturns the strategy unchanged with no error (no static secrets to resolve).token_exchange.go'sConvertToStrategyto populateSubjectProviderName: tokenExchange.SubjectProviderNamein thetokenExchangeConfigconstruction.UpstreamInjectConverterinNewRegistry()ininterface.go.After making CRD changes, run the operator codegen commands listed in the acceptance criteria.
Patterns & Frameworks
ExternalAuthTypetypes: one constant, one spec struct, one CEL rule, one nil-equality check, oneValidate()case, one converterUpstreamInjectConvertershould mirrorUnauthenticatedConverterin structure (stateless struct, minimalConvertToStrategy, pass-throughResolveSecrets) but produce a non-empty config struct rather than a bare strategyself.type == 'X' ? has(self.x) : !has(self.x); add the//nolint:lllcomment is already present on the typego.uber.org/mock(gomock) for any mock dependencies if needed, but the converter itself is fully unit-testable without mocks since it has no external dependencies// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc./// SPDX-License-Identifier: Apache-2.0Code Pointers
cmd/thv-operator/api/v1alpha1/mcpexternalauthconfig_types.go— the primary CRD type file; all CRD changes land here; read the existing CEL rules (lines 44-49),validateTypeConfigConsistency()(lines 740-770), and theValidate()switch (lines 714-736) to understand the exact patterns to followpkg/vmcp/auth/converters/interface.go—NewRegistry()(lines 67-78) is whereUpstreamInjectConvertermust be registered; also contains theStrategyConverterinterface definition (lines 19-43) thatUpstreamInjectConvertermust satisfypkg/vmcp/auth/converters/unauthenticated.go— structural model for a stateless converter with a pass-throughResolveSecrets;UpstreamInjectConverterfollows the same skeleton but constructs a typed config rather than a bare strategypkg/vmcp/auth/converters/token_exchange.go—ConvertToStrategy()(lines 29-67) needs one additional field populated; thetokenExchangeConfigstruct literal (lines 50-56) is whereSubjectProviderName: tokenExchange.SubjectProviderNameis addedpkg/vmcp/auth/converters/token_exchange_test.go— theTestTokenExchangeConverter_ConvertToStrategytable (lines 29-255) is where new regression entries forSubjectProviderNameare addedpkg/vmcp/auth/types/types.go— definesUpstreamInjectConfigandStrategyTypeUpstreamInjectthat the converter imports (added by Phase 1, Phase 1: Add core types and sentinel for upstream_inject strategy (RFC-0054) #4144)Component Interfaces
UpstreamInjectConvertermust satisfy theStrategyConverterinterface defined inpkg/vmcp/auth/converters/interface.go:CRD type additions that downstream phases depend on (all in
MCPExternalAuthConfigSpec/mcpexternalauthconfig_types.go):TokenExchangeConfig CRD addition (the CRD struct in
mcpexternalauthconfig_types.go, not the types package):Testing Strategy
Unit Tests —
pkg/vmcp/auth/converters/upstream_inject_test.go(new file, 3 table-driven cases in a singleTestUpstreamInjectConverter_ConvertToStrategyfunction):ConvertToStrategy valid config: inputMCPExternalAuthConfigwithType: ExternalAuthTypeUpstreamInjectandUpstreamInject: &UpstreamInjectSpec{ProviderName: "github"}→ expectBackendAuthStrategy{Type: "upstream_inject", UpstreamInject: &UpstreamInjectConfig{ProviderName: "github"}}, no errorConvertToStrategy nil spec: input withUpstreamInject: nil→ expect error containing"upstream inject config is nil"ResolveSecrets pass-through: input strategy is returned unchanged;wantStrategyequalsinputStrategy; no errorUnit Tests —
pkg/vmcp/auth/converters/token_exchange_test.go(additions to existingTestTokenExchangeConverter_ConvertToStrategytable):SubjectProviderName populated: inputTokenExchangeConfig{TokenURL: "...", SubjectProviderName: "github"}→ expectauthtypes.TokenExchangeConfig{..., SubjectProviderName: "github"}SubjectProviderName absent (regression): inputTokenExchangeConfig{TokenURL: "..."}with noSubjectProviderName→ expectauthtypes.TokenExchangeConfig{...}withSubjectProviderName: "", no error; ensures no regression on the zero-value caseCodegen Verification:
task operator-generatesucceeds with no unexpected changes beyond the new typetask operator-manifestssucceeds and the updated CRD YAML includes theupstreamInjectfield and enum valuetask crdref-gen(run from insidecmd/thv-operator/) succeedsLicense Check:
task license-checkpasses (SPDX headers on all modified and new files)Out of Scope
UpstreamInjectStrategyruntime behavior (Phase 2)pkg/vmcp/config/validator.go(Phase 3)docs/arch/02-core-concepts.md,docs/vmcp-auth.md)ErrUpstreamTokenNotFoundis defined in Phase 1 but the intercept/redirect flow is a separate RFCActorProviderName) or any additionalTokenExchangeConfigfield beyondSubjectProviderNameReferences
docs/proposals/THV-0054-vmcp-upstream-inject-strategy.mdidentity.UpstreamTokens): Auth Server: multi-upstream provider support #3924