Skip to content

Add loginPolicy to upstream provider CRD config - #5767

Closed
onepabz wants to merge 2 commits into
stacklok:mainfrom
onepabz:feat/vmcp-declarative-upstream-filter
Closed

Add loginPolicy to upstream provider CRD config#5767
onepabz wants to merge 2 commits into
stacklok:mainfrom
onepabz:feat/vmcp-declarative-upstream-filter

Conversation

@onepabz

@onepabz onepabz commented Jul 9, 2026

Copy link
Copy Markdown

What

Since #5725/#5733 the embedded auth server honors a non-nil Config.UpstreamFilter — but nothing feeds it: pkg/authserver/runner/embeddedauthserver.go builds resolvedCfg without ever setting it, and no CRD/Helm/env surface reaches it. So a declarative operator deployment still chains every upstreamProviders entry at every login — adding GitHub next to the identity IdP forces GitHub consent on every user at every login, even for users who never call a GitHub-backed tool.

This is a minimal strawman toward #5383: it exposes the already-merged chain narrowing through the CRD.

  • UpstreamProviderConfig gains an optional loginPolicy enum (required | onDemand; empty = required = today's behavior).
  • The operator copies it into authserver.UpstreamRunConfig in the shared BuildAuthServerRunConfig (so MCPServer / MCPRemoteProxy / VirtualMCPServer all inherit it).
  • The runner turns onDemand entries into a StaticUpstreamFilter and sets Config.UpstreamFilter, activating the dormant wiring.
  • onDemand on the first upstream is rejected at three layers (CRD validation, vMCP reconcile, RunConfig.Validate) — the first upstream anchors identity and the filter contract never removes it.

Additive and opt-in — filter nil / field empty is byte-identical to today; the CRD schema-compat check passes against the v0.34.0 baseline.

Scope (step 1 of 2): onDemand only skips login-time acquisition; already-stored tokens still inject/refresh at request time. There's no in-band flow yet for a user to grant an onDemand provider after login — SingleLeg exists in storage/callback but nothing originates it. So this removes the forced-consent blocker to aggregating backends under one endpoint; the on-demand connect flow (a browser-facing surface that originates SingleLeg — the linked-account half of #5383) is the natural follow-up and deserves maintainer direction first. Happy to build it.

Testing

go build ./..., gofmt, and golangci-lint run clean on touched packages; pkg/authserver/..., pkg/vmcp/config, and cmd/thv-operator/... unit suites pass (new tests at each layer). Generated artifacts via task operator-generate / operator-manifests / crdref-gen.

Notes

Marked draft — a strawman to help unstick #5383; happy to rename the field/values or reshape per maintainer direction. Context: we run ToolHive in production on Kubernetes (operator + vMCP + MCPRemoteProxy, an OIDC IdP as primary upstream, per-user SaaS tokens via upstream_inject), today working around the forced chain with one single-backend vMCP per SaaS provider.

Refs #5383

The embedded auth server walks every configured upstream provider at
every login: the first authorize leg always targets upstreams[0] and
callback.go's continueChainOrComplete then walks all remaining
upstreams. Since stacklok#5725/stacklok#5733 the server honors Config.UpstreamFilter to
narrow that chain, but nothing feeds it: the runner never sets the
field and no CRD, Helm, or file surface reaches it. A declarative
operator deployment therefore cannot stop a secondary SaaS provider
(e.g. GitHub next to the identity IdP) from forcing its consent screen
on every user at every login.

Expose the existing narrowing through the CRD surface:

- UpstreamProviderConfig gains an optional loginPolicy enum field
  (required|onDemand; empty means required, today's behavior).
- The operator copies it into authserver.UpstreamRunConfig via the
  shared BuildAuthServerRunConfig, covering MCPServer, MCPRemoteProxy,
  and VirtualMCPServer alike.
- The runner translates onDemand entries into a StaticUpstreamFilter
  (new, implements handlers.UpstreamFilter) and sets
  Config.UpstreamFilter, activating the previously dormant wiring.
- Validation rejects onDemand on the first upstream (it anchors the
  chain and resolves the user's identity) at CRD validation time, at
  VirtualMCPServer reconcile time, and at RunConfig load.

Tokens previously stored for an onDemand provider are still injected
and refreshed at MCP-request time; only login-time acquisition is
skipped. There is intentionally no in-band flow yet for a user to
authorize an onDemand provider after login - that is the linked-account
half of stacklok#5383 and is left for maintainer direction.

Toward stacklok#5383.

Signed-off-by: Pablo Sanchez <494757+onepabz@users.noreply.github.com>
@onepabz
onepabz marked this pull request as ready for review July 9, 2026 06:13
@github-actions github-actions Bot added the size/L Large PR: 600-999 lines changed label Jul 14, 2026
@codecov

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.55556% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.80%. Comparing base (709c918) to head (f391f8d).
⚠️ Report is 7 commits behind head on main.

Files with missing lines Patch % Lines
pkg/authserver/runner/embeddedauthserver.go 87.50% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5767      +/-   ##
==========================================
+ Coverage   70.78%   70.80%   +0.01%     
==========================================
  Files         683      686       +3     
  Lines       69236    69479     +243     
==========================================
+ Hits        49012    49192     +180     
- Misses      16624    16686      +62     
- Partials     3600     3601       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@onepabz
onepabz requested a review from aponcedeleonch as a code owner July 14, 2026 09:09
@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/L Large PR: 600-999 lines changed labels Jul 14, 2026
@jerm-dro

jerm-dro commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Thanks for digging into this, @onepabz, and sorry for the slow reply — this sat far longer than it should have. Your diagnosis was accurate: the UpstreamFilter mechanism and authserver.New landed in v0.34.0, the operator's embedded auth server never sets Config.UpstreamFilter, and nothing originates a SingleLeg authorization.

Closing this. It's now behind main, but the substantive reason is that the sequencing needs to change before a CRD shape is worth settling. Three things, the last of which is where I'd like to land.

The connect flow is the load-bearing half. You scoped it as step 2, but narrowing the login chain is only safe once a user can grant a provider afterward. Without it, a provider marked onDemand never has a token for that user at all.

The resulting failure is silent, and it hits discovery rather than invocation. vMCP queries each backend for its capabilities per-user, per-request using that user's own credentials — there's no shared service identity for discovery. A backend whose upstream token was never acquired fails that query in upstream_inject before any request reaches it, and the aggregator logs the failure and continues. The backend's tools are then absent from tools/list entirely: no error the user can act on, and the backend still reports healthy. The field doc says a request to such a backend "fails until such a flow exists" — in practice the user never gets that far, because the tool was never advertised.

The connect flow splits by protocol revision, which isn't visible from the auth server packages. On Legacy (2025-11-25) there's a client session and a server-initiated request channel, so a mid-session prompt is buildable. On stateless Modern (2026-07-28) there's no such channel by design, and because authentication is triggered by the MCP request itself, there's no out-of-band surface to send a user to either. So there's currently no mechanism to ask a Modern client's user for anything mid-session, which means any design here would be Legacy-only today.

On #5383 — I think your ask is a different one, and that's why it's been hard to get traction. That issue was filed against two gaps: no per-backend incoming auth, and upstream providers behaving as alternates rather than a chain. The second isn't a gap — sequential chaining works, and you're running it.

Your ask sits on the other side of that. Chaining works for you; the problem is that it's eager — every user is walked through every upstream's consent screen at every login, including users who never touch that backend. That's acquisition timing, not composition, and it only becomes statable because chaining already works. It's a reasonable thing to want, and it's getting lost appended to a thread about a different problem.

Would you open a separate issue for it? Most useful would be your topology, what your users hit at login today, and what you'd want them to hit instead — described as behavior rather than as a config surface. Easier to discuss the shape of a solution once the problem is scoped on its own.

@jerm-dro jerm-dro closed this Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/L Large PR: 600-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants