Skip to content

Phase 4: Add CRD type and converter for upstream_inject strategy (RFC-0054) #4146

@tgrunnagle

Description

@tgrunnagle

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 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):

  1. Append ExternalAuthTypeUpstreamInject ExternalAuthType = "upstreamInject" to the existing const block.
  2. 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.
  3. Add UpstreamInject *UpstreamInjectSpec \json:"upstreamInject,omitempty"`field toMCPExternalAuthConfigSpec`.
  4. Add upstreamInject to the +kubebuilder:validation:Enum annotation on the Type field.
  5. Add a CEL rule to the struct-level kubebuilder markers (following the existing tokenExchange, headerInjection, bearerToken, embeddedAuthServer, and awsSts rules).
  6. In validateTypeConfigConsistency(), add the nil-equality check for UpstreamInject mirroring all existing checks.
  7. In the Validate() switch, add a case ExternalAuthTypeUpstreamInject: that validates ProviderName is non-empty (similar to how validateAWSSts checks required fields).
  8. Add SubjectProviderName string \json:"subjectProviderName,omitempty"`to the existingTokenExchangeConfig` struct.

Converter changes (pkg/vmcp/auth/converters/):

  1. 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).
  2. Update token_exchange.go's ConvertToStrategy to populate SubjectProviderName: tokenExchange.SubjectProviderName in the tokenExchangeConfig construction.
  3. 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.goNewRegistry() (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.goConvertToStrategy() (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
  • pkg/vmcp/auth/types/types.go — defines UpstreamInjectConfig and StrategyTypeUpstreamInject that 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: 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 populated: input TokenExchangeConfig{TokenURL: "...", SubjectProviderName: "github"} → expect authtypes.TokenExchangeConfig{..., SubjectProviderName: "github"}
  • 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)
  • Architecture documentation updates (docs/arch/02-core-concepts.md, docs/vmcp-auth.md)
  • Step-up auth signaling (UC-06) — ErrUpstreamTokenNotFound is defined in Phase 1 but the intercept/redirect flow is a separate RFC
  • Actor token (ActorProviderName) or any additional TokenExchangeConfig field beyond SubjectProviderName
  • New integration or E2E tests — full flow E2E coverage is provided by the RFC-0053 test specification

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    authenticationauthorizationenhancementNew feature or requestgoPull requests that update go codevmcpVirtual MCP Server related issues

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions