Skip to content

Report unsupported external auth types - #6242

Open
3nesdeniz wants to merge 1 commit into
stacklok:mainfrom
3nesdeniz:agent/report-unsupported-external-auth
Open

Report unsupported external auth types#6242
3nesdeniz wants to merge 1 commit into
stacklok:mainfrom
3nesdeniz:agent/report-unsupported-external-auth

Conversation

@3nesdeniz

@3nesdeniz 3nesdeniz commented Aug 8, 2026

Copy link
Copy Markdown

Summary

MCPExternalAuthConfig has nine spec.type values, but each consumer kind implements only a subset, and the unsupported combinations fail silently: the resource reads as valid, the workload runs, and the credential is never injected. This PR delivers the two things #5930 asks for:

  • Reconcile-time validation surfaced as status conditions. An unsupported consumer/type combination sets ExternalAuthConfigValidated=False with reason UnsupportedAuthType on MCPServer, MCPRemoteProxy, and MCPServerEntry, and a per-backend condition on VirtualMCPServer (including the discovered-mode case where a bearerToken-backed MCPRemoteProxy used to vanish from aggregation with only a log line). Terminal configuration errors return without requeueing; the existing watches trigger reconciliation when the spec or the referenced config changes.
  • Supported consumer kinds documented in the CRD. Field godoc on MCPExternalAuthConfigSpec.Type and on every consumer's externalAuthConfigRef, flowing into the generated CRD schemas and reference docs.

The support matrix lives in cmd/thv-operator/pkg/externalauthsupport and cannot drift from the code: the VirtualMCPServer/MCPServerEntry rows derive from the converter registry (converters.DefaultRegistry().RegisteredTypes()), and the MCPServer/MCPRemoteProxy rows are enforced at the run-config dispatch — AddExternalAuthConfigOptions now takes the consumer, validates against the matrix, and the silent no-op arms for headerInjection/upstreamInject/xaa are gone. TestAddExternalAuthConfigOptions_EnforcesSupportMatrix drives the real dispatch with all nine types for both consumers and requires agreement with the matrix in both directions.

Two consolidations that fell out of review:

  • The generic vMCP converter no longer converts outgoing auth. The reconciler's status-aware path (processOutgoingAuth) was already the authority and is now the only implementation; the converter's copy (convertOutgoingAuthWithDefaults/convertOutgoingAuth/convertBackendAuthConfig) is deleted rather than bypassed with a modified input. Discovered mode persists exactly what the spec declares (default + inline entries), as before. The one behaviour change is intentional and in scope: a single broken backend in inline mode now degrades to a per-backend condition instead of failing the whole conversion.
  • handleExternalAuthConfig on MCPServer sets the condition back to True once a previously rejected config is fixed (it previously had no success setter, so the condition stuck at False forever), and MCPRemoteProxy reports Ready=False with reason AuthInvalid instead of DeploymentNotReady for an auth configuration failure.

Out of scope, split out per review

Validation

  • go build ./... and golangci-lint run on the touched trees: clean
  • go test ./cmd/thv-operator/... ./pkg/vmcp/...: all unit suites pass
  • full test-integration suite under envtest (all 11 packages, including mcp-remote-proxy and virtualmcp): pass
  • rebased on current main

@3nesdeniz

Copy link
Copy Markdown
Author

Hi @jhrozek — no rush at all, just flagging that this is ready for review whenever you have time. Happy to adjust anything about the approach.

@jhrozek jhrozek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for digging into #5930 — the compatibility matrix itself is right. I checked every cell against the code rather than against the docs: the VirtualMCPServer and MCPServerEntry rows match the converter registry in pkg/vmcp/auth/converters/interface.go exactly, and the MCPServer/MCPRemoteProxy rows line up with the dispatcher switch in controllerutil/tokenexchange.go, including the non-obvious awsSts-on-MCPServer case, which fails on the empty-RemoteURL check in pkg/runner/middleware.go rather than silently no-opping. I also traced the reconcile flow to confirm it returns before the Deployment block, so this does close the unauthenticated-deploy footgun rather than just annotating it.

My main concern is scope. The issue asked for two things: reconcile-time validation surfaced as status conditions, and the supported consumer kinds documented in the field godoc. Both are here. But a lot came along with them, and some of it changes behaviour nobody asked to change.

The one I'd push back on hardest is the outgoing-auth precedence change in pkg/vmcp/aggregator/discoverer.go. In discovered mode an explicit per-backend override now wins over auth discovered from the backend resource, where previously discovered auth won. That decides which credential a live backend authenticates with. It may well be the right call, but it needs its own issue and its own discussion — it shouldn't be settled as a side effect of a PR about reporting unsupported types. Same goes for ExplicitBackends and its nil-versus-empty compatibility semantics, which exist only to support that change.

ExcludedBackends and DefaultAuthFailed are closer to the mark but still past the ask. The issue's vMCP line is "surface the skipped backend in VirtualMCPServer status rather than log-and-drop", which is a status condition. What's here is a fail-closed routing rework threaded through the discoverer, the watcher and the static path. I'd keep the condition and leave routing semantics for a separate change.

The examples fix (snake_case to camelCase under examples/operator/external-auth/) is a real bug and a good catch, but it's unrelated — worth its own PR so it can land quickly on its own merits.

Two things I'd treat as blocking regardless of what happens to the scope:

ensureVmcpConfigConfigMap now deep-copies the VirtualMCPServer, sets Spec.OutgoingAuth to nil, runs converter.Convert against that, then patches Source back on afterwards. That line is the only caller of Convert in the tree, so Converter.convertOutgoingAuth and convertBackendAuthConfig are now unreachable in production — they survive only in their own unit tests, which will keep passing while covering code that no longer runs. The controller already had a near-duplicate in convertBackendAuthConfigToVMCP, and this PR updates that copy to match the converter's behaviour. Better to pick one and delete the other than to disable the original by feeding it a modified input.

ExternalAuthConfigValidated on MCPServer has no path back to True. Both False branches are there (the new UnsupportedAuthType one and the pre-existing MultiUpstream one), plus RemoveStatusCondition when the ref goes away, but there's no success setter — MCPRemoteProxy has the matching block at the end of its handleExternalAuthConfig and MCPServer doesn't. Point a server at a two-upstream config, then edit that config down to one, and the condition stays False permanently. The MultiUpstream half predates this PR, but the new branch reproduces it in a function you're already editing.

Smaller things, several of which follow conventions already present in the files being changed:

  • excludedBackends and explicitBackends need +listType=atomic. That's what's failing the CRD Schema Compatibility check, and there's an existing +listType=atomic about 200 lines up in pkg/vmcp/config/config.go. The other red checks aren't yours: the E2E lifecycle jobs all died on a Docker Hub 502 pulling python:3.9-slim, and the unit-test failure is a flaky transparent-proxy test unrelated to this change.
  • pkg/vmcp/backendregistry/registry.go:130 still calls NewBackendWatcher without the auth config, and the comment directly above it asks that it stay in sync with cli/serve.go. It's only reachable from internal/exampleembedder today, but NewKubernetesBackendRegistry is the exported constructor for embedders and now has no way to pass an OutgoingAuthConfig at all.
  • The "failed default means deny" rule is implemented three times: ResolveForBackend, dependsOnFailedDefault in the discoverer, and again inline in backend_reconciler.go. They agree today, nothing keeps them agreeing, and drift reopens the hole this is meant to close. Same for outgoingAuthExclusionSet, copy-pasted verbatim into two packages — it'd be better as a method on OutgoingAuthConfig.
  • support_test.go re-declares supportMatrix as a literal and asserts Supports() matches it, so it can only fail on a typo made differently twice. The test that would earn its place asserts the vMCP rows equal the key set of converters.DefaultRegistry() and the MCPServer/MCPRemoteProxy rows equal the non-no-op arms of the dispatcher switch. That one breaks when someone adds a converter and forgets the table, which is the failure this package exists to prevent.
  • Three new ...WithAuthConfig constructors for two production callers of the old ones. Changing the signatures is smaller.
  • MCPRemoteProxy sets Ready=False with Reason: DeploymentNotReady for an external-auth configuration error.

One structural thought worth weighing before the next revision. The silent no-ops in AddExternalAuthConfigOptions are the actual subject of #5930 and they're still there — headerInjection, upstreamInject and xaa all still return nil. The support matrix is a second, hand-maintained source of truth sitting beside them, with nothing enforcing agreement. Making those arms return the UnsupportedTypeError directly would put the guard at the choke point every consumer already routes through, and the MCPServer/MCPRemoteProxy rows could then derive from the switch instead of restating it.

MCPExternalAuthConfig has nine types, but each consumer kind implements
only a subset, and the unsupported combinations failed silently: the
resource read as valid, the workload ran, and the credential was never
injected (stacklok#5930).

Validate the consumer/type combination at reconcile time and surface
failures as ExternalAuthConfigValidated=False with reason
UnsupportedAuthType on MCPServer, MCPRemoteProxy, and MCPServerEntry,
and as per-backend conditions on VirtualMCPServer. Terminal
configuration errors return without requeueing; the watches on the spec
and the referenced config trigger reconciliation when either changes.
Document each consumer's supported types in the field godoc so the CRD
schemas and reference docs carry them.

The support matrix lives in one place and cannot drift from the code:
the vMCP-backed rows derive from the converter registry, and the
MCPServer/MCPRemoteProxy rows are enforced at the run-config dispatch in
AddExternalAuthConfigOptions, which now takes the consumer and returns a
typed UnsupportedTypeError instead of keeping silent no-op arms for
headerInjection, upstreamInject, and xaa. A test drives that dispatch
with every type for both consumers and requires agreement with the
matrix in both directions.

The generic vMCP converter no longer converts outgoing auth. The
reconciler's status-aware path (processOutgoingAuth) was already the
authority on it and is now the only implementation; the converter's
unreachable copy is deleted rather than bypassed. This also turns the
inline-mode hard error for a single broken backend into a per-backend
condition with the remaining backends still served. handleExternalAuthConfig
on MCPServer now sets the condition back to True once a previously
rejected configuration is fixed, matching MCPRemoteProxy, and MCPRemoteProxy
reports Ready=False with reason AuthInvalid rather than DeploymentNotReady
for an auth configuration failure.

Closes stacklok#5930.

Signed-off-by: Enes Deniz <142517728+3nesdeniz@users.noreply.github.com>
@3nesdeniz

3nesdeniz commented Aug 14, 2026

Copy link
Copy Markdown
Author

Thanks for the thorough review, @jhrozek — checking the matrix cell-by-cell against the code is exactly what this needed, and you were right about the scope. Reworked accordingly; the diff against main went from ~+3,300 to +1,620/−380, and every runtime behaviour change is gone.

Scope

The two blockers

  • Convert no longer gets a modified copy: the converter's outgoing-auth path (convertOutgoingAuthWithDefaults/convertOutgoingAuth/convertBackendAuthConfig) is deleted, and processOutgoingAuth — already the status-aware authority — is the only implementation left. Discovered mode persists exactly what the spec declares, as before. The one deliberate behaviour change is in scope: a single broken backend in inline mode now degrades to a per-backend condition instead of failing the whole conversion.
  • MCPServer's handleExternalAuthConfig got the missing success setter, mirroring MCPRemoteProxy — your two-upstreams-then-one scenario now flips the condition back to True, with a regression test.

Structural suggestion — taken. AddExternalAuthConfigOptions takes the consumer and enforces the matrix at the top; the silent no-op arms are gone (they remain as explicit unreachable cases for switch exhaustiveness, failing loudly if the matrix ever admits one without a real arm). The vMCP/MCPServerEntry rows derive from converters.DefaultRegistry(), so registering a converter extends them automatically. The tautological test is replaced by TestAddExternalAuthConfigOptions_EnforcesSupportMatrix, which drives the real dispatch with all nine types for both consumers and requires agreement with the matrix in both directions.

Smaller items. +listType=atomic is moot — the fields no longer exist and the regenerated CRDs dropped them, which should clear the schema-compatibility check. The NewBackendWatcher gap, the triplicated failed-default rule, the copy-pasted exclusion set, and the three ...WithAuthConfig constructors all went away with the revert. MCPRemoteProxy now reports Ready=False with AuthInvalid instead of DeploymentNotReady.

Validation: build and golangci-lint clean, unit suites green, and the full test-integration suite green under envtest (including mcp-remote-proxy and virtualmcp). Rebased on current main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants