-
Notifications
You must be signed in to change notification settings - Fork 192
Description
Description
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.
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*constants -
UpstreamInjectSpecstruct is present with a singleProviderName stringfield taggedjson:"providerName"and a// +kubebuilder:validation:MinLength=1marker -
MCPExternalAuthConfigSpechas a new fieldUpstreamInject *UpstreamInjectSpec \json:"upstreamInject,omitempty"`annotated// +optional` -
upstreamInjectis added to the+kubebuilder:validation:Enummarker on theTypefield 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 handlesExternalAuthTypeUpstreamInject(verifyingProviderNameis 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.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.SubjectProviderName -
NewRegistry()inpkg/vmcp/auth/converters/interface.goregistersUpstreamInjectConverter:r.Register(mcpv1alpha1.ExternalAuthTypeUpstreamInject, &UpstreamInjectConverter{}) - CRD codegen is re-run after type changes:
task operator-generate,task operator-manifests, andtask crdref-gen(fromcmd/thv-operator/) all succeed without error - Unit tests pass for
UpstreamInjectConverter(3 table-driven cases:ConvertToStrategyvalid,ConvertToStrategynil spec,ResolveSecretspass-through) - Regression test entries are added to
TestTokenExchangeConverter_ConvertToStrategyverifying thatSubjectProviderNameis 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 existingconstblock. - Add
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. - Add
UpstreamInject *UpstreamInjectSpec \json:"upstreamInject,omitempty"`field toMCPExternalAuthConfigSpec`. - Add
upstreamInjectto the+kubebuilder:validation:Enumannotation on theTypefield. - Add a CEL rule to the struct-level kubebuilder markers (following the existing
tokenExchange,headerInjection,bearerToken,embeddedAuthServer, andawsStsrules). - In
validateTypeConfigConsistency(), add the nil-equality check forUpstreamInjectmirroring all existing checks. - In the
Validate()switch, add acase ExternalAuthTypeUpstreamInject:that validatesProviderNameis non-empty (similar to howvalidateAWSStschecks required fields). - Add
SubjectProviderName string \json:"subjectProviderName,omitempty"`to the existingTokenExchangeConfig` struct.
Converter changes (pkg/vmcp/auth/converters/):
- Create
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). - Update
token_exchange.go'sConvertToStrategyto populateSubjectProviderName: tokenExchange.SubjectProviderNamein thetokenExchangeConfigconstruction. - Register
UpstreamInjectConverterinNewRegistry()ininterface.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
ExternalAuthTypetypes: one constant, one spec struct, one CEL rule, one nil-equality check, oneValidate()case, one converter UpstreamInjectConvertershould mirrorUnauthenticatedConverterin structure (stateless struct, minimalConvertToStrategy, pass-throughResolveSecrets) 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:lllcomment 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 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
UpstreamInjectConverter must satisfy the StrategyConverter interface defined in pkg/vmcp/auth/converters/interface.go:
// UpstreamInjectConverter converts MCPExternalAuthConfig UpstreamInject to vMCP upstream_inject strategy.
type UpstreamInjectConverter struct{}
// StrategyType returns the vMCP strategy type for upstream inject.
func (*UpstreamInjectConverter) StrategyType() string {
return authtypes.StrategyTypeUpstreamInject
}
// ConvertToStrategy converts UpstreamInjectSpec to a BackendAuthStrategy with typed fields.
func (*UpstreamInjectConverter) ConvertToStrategy(
externalAuth *mcpv1alpha1.MCPExternalAuthConfig,
) (*authtypes.BackendAuthStrategy, error) {
upstreamInject := externalAuth.Spec.UpstreamInject
if upstreamInject == nil {
return nil, 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) {
return strategy, nil
}CRD type additions that downstream phases depend on (all in MCPExternalAuthConfigSpec / mcpexternalauthconfig_types.go):
// New constant
ExternalAuthTypeUpstreamInject ExternalAuthType = "upstreamInject"
// New spec struct
type UpstreamInjectSpec struct {
// ProviderName is the name of the upstream IDP provider whose access token
// should be injected as the Authorization: Bearer header.
// +kubebuilder:validation:MinLength=1
ProviderName string `json:"providerName"`
}
// New field on MCPExternalAuthConfigSpec
// UpstreamInject configures upstream token injection for backend requests.
// Only used when Type is "upstreamInject".
// +optional
UpstreamInject *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.
// +optional
SubjectProviderName string `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: inputMCPExternalAuthConfigwithType: ExternalAuthTypeUpstreamInjectandUpstreamInject: &UpstreamInjectSpec{ProviderName: "github"}→ expectBackendAuthStrategy{Type: "upstream_inject", UpstreamInject: &UpstreamInjectConfig{ProviderName: "github"}}, no error -
ConvertToStrategy nil spec: input withUpstreamInject: nil→ expect error containing"upstream inject config is nil" -
ResolveSecrets pass-through: input strategy is returned unchanged;wantStrategyequalsinputStrategy; no error
Unit Tests — pkg/vmcp/auth/converters/token_exchange_test.go (additions to existing TestTokenExchangeConverter_ConvertToStrategy table):
-
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 case
Codegen Verification:
-
task operator-generatesucceeds with no unexpected changes beyond the new type -
task operator-manifestssucceeds and the updated CRD YAML includes theupstreamInjectfield and enum value -
task crdref-gen(run from insidecmd/thv-operator/) succeeds
License Check:
-
task license-checkpasses (SPDX headers on all modified and new files)
Out of Scope
- Implementation of
UpstreamInjectStrategyruntime behavior (Phase 2) - Startup validation rules V-01, V-02, V-06 in
pkg/vmcp/config/validator.go(Phase 3) - Architecture documentation updates (
docs/arch/02-core-concepts.md,docs/vmcp-auth.md) - Step-up auth signaling (UC-06) —
ErrUpstreamTokenNotFoundis defined in Phase 1 but the intercept/redirect flow is a separate RFC - Actor token (
ActorProviderName) or any additionalTokenExchangeConfigfield beyondSubjectProviderName - New integration or E2E tests — full flow E2E coverage is provided by the RFC-0053 test specification
References
- RFC-0054 (primary):
docs/proposals/THV-0054-vmcp-upstream-inject-strategy.md - Parent epic: vMCP: implement upstream_inject outgoing auth strategy #3925
- Phase 1 (upstream dependency): Phase 1: Add core types and sentinel for upstream_inject strategy (RFC-0054) #4144
- RFC-0052 (multi-upstream IDP, defines
identity.UpstreamTokens): Auth Server: multi-upstream provider support #3924 - RFC-0053 (embedded AS in vMCP, prerequisite for Phase 3): vMCP: add embedded authorization server #4120