Expose canonical inbound grants in CRDs - #6499
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## spiffe-integration-split3-3 #6499 +/- ##
===============================================================
- Coverage 78.38% 78.36% -0.03%
===============================================================
Files 776 776
Lines 76094 76175 +81
===============================================================
+ Hits 59647 59693 +46
- Misses 16442 16477 +35
Partials 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
JAORMX
left a comment
There was a problem hiding this comment.
The canonical model is wired through the CRDs, conversion, and generated artifacts, but two correctness gaps block approval:
-
VirtualMCPServerinline auth validation still recognizes only legacydelegateClients/trustedIssuers[*].jwtBearerGrantas satisfying token-only operation (cmd/thv-operator/controllers/virtualmcpserver_controller.go:670-685). A valid inline configuration using onlyspec.authServerConfig.inboundGrantsis admitted but markedAuthServerConfigValidated=Falseand never deploys. Please make this path recognize configured canonical grant families, consistent with the external-auth path. -
MCPExternalAuthConfigvalidation's confidential-client transport check only considers legacyDelegateClients(cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go:961-967, called at2129-2133). A canonical delegate client for an invalid non-loopback HTTP issuer can therefore reportValid=True, while the normalized runtime configuration later rejects it. Please apply the same normalized/canonical-aware transport validation at the owning configuration resource and add coverage for the canonical path.
The remaining red Go Vulnerability Check is also present on the stacked bases and appears unrelated to this diff.
fbe4ff6 to
67f5da4
Compare
5834d9c to
6dbb23e
Compare
67f5da4 to
b4c8fed
Compare
6dbb23e to
e038ceb
Compare
JAORMX
left a comment
There was a problem hiding this comment.
Re-review at e038ceb5d38e0961cb3b332c58359863bbf392f6: the two prior blockers are still unresolved.
VirtualMCPServeraccepts canonical grants in CRD admission but its inline runtime guard still ignorescfg.InboundGrants, rejecting valid token-only configurations (cmd/thv-operator/controllers/virtualmcpserver_controller.go:670-685).ValidateConfidentialClientTransportstill ignores canonicalinboundGrants.tokenExchange.delegateClients, allowing cleartext non-loopback HTTP issuers with a confidential client (cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go:961-967).
Please address both with regression tests. CI is green; no local tests were run.
JAORMX
left a comment
There was a problem hiding this comment.
Changes requested:
cmd/thv-operator/controllers/virtualmcpserver_controller.go:670: inline validation ignores canonicalInboundGrants, so valid token-only canonical configurations are rejected and never reconcile. Include canonical grant families.cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go:961: confidential-client transport validation considers only legacy delegate clients; include canonical token-exchange delegate clients.cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go:2151: canonical issuer policies bypass equivalent trusted-issuer validation and can be publishedValid=Truedespite invalid policy values. Validate normalized canonical policies at this boundary.
JAORMX
left a comment
There was a problem hiding this comment.
One precision addendum to the latest review: canonical inboundGrants also skips MCPExternalAuthConfig's early trusted-issuer validation. At cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go:2151-2155, tokenexchange.ValidateTrustedIssuers(...) only runs when cfg.InboundGrants == nil. Consequently an invalid issuer policy (for example, self-issuer collision or invalid issuer/JWKS URL) can leave the owning config reported Valid=True.
This is not a complete runtime bypass: BuildAuthServerRunConfig later calls RunConfig.Validate() and catches the invalid configuration before a consuming workload runs (cmd/thv-operator/pkg/controllerutil/authserver.go:952-954,1034-1052). Please apply the equivalent normalized trusted-issuer validation on the owning configuration path and test the Valid=False result for a canonical invalid issuer policy.
e038ceb to
ae26bc4
Compare
JAORMX
left a comment
There was a problem hiding this comment.
Changes requested:
cmd/thv-operator/controllers/virtualmcpserver_controller.go:670-685: inline validation still ignores canonicalInboundGrants, so valid token-only canonical configurations are rejected and never deployed.cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go:961-967: confidential-client transport validation examines only legacy delegate clients. Apply it to normalized canonical token-exchange delegate clients too.cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go:2151-2155: canonical issuer policies bypass the owningMCPExternalAuthConfigtrusted-issuer validation and can be reportedValid=Truedespite invalid issuer/JWKS URLs or self-issuer collisions. Normalize and validate them at this boundary.
All current CI checks are green; no local tests were run.
ae26bc4 to
ce2447f
Compare
Restart reconstruction must not overwrite dynamic registrations or weaken the existing DCR replacement contract. Separate configured-client insertion from replacement and enforce duplicate behavior consistently in memory and Redis storage. Refs #6200 Signed-off-by: Jakub Hrozek <jakub@stacklok.com>
Static workload clients have no interactive redirect flow, and exposing them through authorization lookup would permit enumeration and accidental browser use. Filter configured back-channel clients from authorization requests while leaving their token-endpoint registration available. Refs #6200 Signed-off-by: Jakub Hrozek <jakub@stacklok.com>
A reviewer (JAORMX) found four remaining issues in how this branch registers and reserves static SPIFFE clients: RegisterClient let any caller overwrite any existing client just by omitting a marker, the SPIFFE overlay never durably reserved its client IDs so a rolling deployment could let an old replica hand the same ID to a DCR registration, JWT-bearer replay protection bypassed every storage decorator by unwrapping straight to the base backend, and the /authorize back-channel guard inferred client class from metadata shape instead of an explicit marker. This commit closes all four, designed with an oauth-expert/go-architect review pair per finding and implemented and adversarially re-reviewed in two rounds before landing. Registration is now uniformly create-only. `RegisterClient` on both storage backends no longer branches on whether the incoming client carries the DCR-issued marker — it always fails if a client with that ID exists, full stop, so no caller (present or future) can silently overwrite an existing registration by simply forgetting to mark it. The only path that can ever replace a client is the new `ClientRegistry.ReconcileConfiguredClient`, which creates on first use and otherwise requires the existing record to be non-DCR-issued and have a matching fingerprint (scopes, audience, grant/response types, public flag — never the secret, so delegate-client secret rotation still reconciles) before replacing it. Delegate-client startup registration now goes through this method instead of `RegisterClient`. Static SPIFFE client IDs are now durably reserved, not just preflight-checked. The overlay previously only read durable storage to detect a collision before serving clients in-process; nothing was ever written, so an older replica mid-rollout could still win a race and DCR-register the same ID with a different client shape. Construction now calls `ReconcileConfiguredClient` against the underlying backend with an inert placeholder for each configured ID — never the real `*SPIFFEClient` object, since persisting that directly into Redis would have degraded on read-back into a usable, unauthenticated confidential client (Redis flattens a client's fields to JSON, and `fosite.DefaultClient` substitutes real grant/response types when the stored field reads back empty). The placeholder is instead marked with a `storedClient.Reserved` bit that `clientFromStored` checks before trusting anything else in the row, so it reconstructs as genuinely unusable — no grant type, no response type, no secret — independent of backend. It keeps the real association's scopes/audience so the fingerprint check can tell "same config restarting" (idempotent) from "a different, colliding association" (a loud startup failure instead of silent divergence). The live overlay is unchanged: it still serves the real client in-process, exactly as before. The reconcile call against Redis uses a bounded WATCH/MULTI retry loop, since go-redis does not itself retry a concurrent write. JWT-bearer replay protection no longer bypasses the storage decorator chain. It used to call `storage.Unwrap`, peeling every decorator down to the base backend before checking for replay-consumption support — so a decorator sitting in between could never intercept or audit that call, and a future one could silently lose the capability by omitting an undocumented `Unwrap` method. `SPIFFEStorageDecorator` now forwards `ConsumeAssertionJWT` one level down, the same way `CIMDStorageDecorator` already did, and the lookup asserts the capability directly on the outermost storage instead of unwrapping past the chain. The /authorize back-channel guard is now marker-driven for the client types this stack introduces. `isBackChannelOnlyClient` inferred "no interactive flow" from metadata shape alone (empty response types, or an exact token-exchange grant) — a future client class sharing that shape by coincidence would be silently and incorrectly hidden. `registration.SPIFFEClient` and the durable placeholder now carry an explicit `BackChannelOnly` marker (mirroring the existing `DCRIssued` marker pattern) that the guard checks first; the metadata-shape inference remains as a fallback for delegate clients and any other existing client type, unchanged. Refs #6200 Signed-off-by: Jakub Hrozek <jakub@stacklok.com>
A reviewer found that two replicas racing on the same outbound DCR (RFC 7591) cache-miss could each independently register a different OAuth client with the upstream IdP — dynamic registration always mints a fresh client_id/secret — then whichever replica's write landed last in the shared Redis cache silently won. The losing replica keeps the client it registered baked into its own config for the rest of its process lifetime (DCR resolution runs once per upstream at startup, never re-resolved), so it no longer agrees with the durable cache about which client it holds credentials for. dcrFlight (a singleflight.Group) only coalesces concurrent callers within one process; it has no cross-replica reach. Change the cache-population contract from upsert to create-if-absent, returning the authoritative durable value either way: the caller's own resolution on a successful claim, or the concurrent winner's otherwise. CredentialStore.Put becomes PutIfAbsent, and DCRCredentialStore.StoreDCRCredentials becomes StoreDCRCredentialsIfAbsent; registerAndCache now returns whichever resolution the store says is authoritative instead of trusting its own local registration, and logs (at Debug, without ever including a secret) when this replica lost the race. Callers MUST use the returned value — RFC 7591 guarantees nothing about the two registrations converging. Redis claims the key with SET...NX (the same reservation-lock shape already used twice in this file for ClientAssertionJWTValid and ConsumeAssertionJWT), not WATCH/MULTI: unlike ReconcileConfiguredClient, this write has no read-then-decide step to protect, so a plain atomic NX claim is sufficient. On a lost claim it reads back the winner through the existing GetDCRCredentials path rather than a second, hand-rolled unmarshal, and retries the whole claim-or-read cycle (bounded) if the winner's row evicts between the failed NX and the read — its TTL can be as short as one second when the caller's ClientSecretExpiresAt was already in the past, so this is a real, reachable window, not a hypothetical one, and the alternative (a hard error) would turn a retryable race into a permanent startup failure. MemoryStorage's implementation treats an existing entry as absent only when its ClientSecretExpiresAt is non-zero and already past — otherwise it returns the existing entry unchanged rather than overwriting it. A single process's dcrFlight already prevents a live race there; this is contract symmetry with Redis, plus the correctness case Redis gets from TTL eviction: without the expiry check, a never-expiring entry can never be reclaimed, but a naive "any existing entry blocks re-registration" check would also permanently pin an already-expired one that should be re-registered. Refs #6200 Signed-off-by: Jakub Hrozek <jakub@stacklok.com>
Rebasing this stack onto main picked up CIMD's write-through client persistence (main), which relies on RegisterClient acting as an upsert to renew a resolved client's row on every document re-fetch. This stack's own registration hardening made RegisterClient create-only for DCR-issued clients, so every renewal after the first fetch for a given CIMD client_id would silently fail, leaving stale client data (and, absent the token-exchange-triggered RenewClientTTL path, a stale TTL) in storage. Add UpsertDCRIssuedClient, a narrow fourth ClientRegistry operation distinct from both RegisterClient (unauthenticated DCR, stays create-only) and ReconcileConfiguredClient (fingerprint-locked, the wrong shape since a CIMD document can legitimately change between fetches). It creates the row if absent, replaces and renews it only when the existing row is itself DCR-issued, and refuses with ErrAlreadyExists otherwise -- protecting a configured or SPIFFE client from being clobbered. Wire CIMDStorageDecorator.fetch to call it instead of RegisterClient, and give SPIFFEStorageDecorator the same reserved-ID guard its other overrides already enforce. Signed-off-by: Jakub Hrozek <jakub@stacklok.com>
StoreDCRCredentialsIfAbsent deliberately returns a stable-but-expired existing row without error when both it and a fresh registration attempt are already expired, to avoid every concurrent claimant re-entering the write path and exhausting retries. That's the right call for the storage layer, but registerAndCache was treating whatever it got back as a successful resolution regardless -- handing callers a client_secret the upstream has already invalidated. Reject an already-expired authoritative credential in registerAndCache instead, where "expired means unusable" is actually DCR policy, not storage policy. This also covers a replica's own fresh registration turning out already-expired (upstream clock skew, or an upstream that issues a past client_secret_expires_at) -- the same guard applies either way, since a fresh-but-dead secret is exactly as unusable as a stale winner's. Signed-off-by: Jakub Hrozek <jakub@stacklok.com>
95b1339 to
319de92
Compare
1d2f7a3 to
5314ea2
Compare
JAORMX
left a comment
There was a problem hiding this comment.
Deep re-review at 5314ea2a12a770f149714740a8099f3ca586de97 confirms the canonical trusted-issuer validity gap and finds two further condition defects:
- High: canonical issuer policies remain admitted and published
Valid=Truealthough runtime-invalid: legacy-equivalent CEL constraints are absent atcmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go:615-648, and trusted-issuer validation is skipped wheninboundGrantsis present at:2153-2157. Normalize/validate before reportingValid=True, while retaining consumer-specific checks at consumers. - Medium:
applyDeprecatedInboundGrantConditionaddsDeprecatedInboundGrantConfiguration=Falseeven to non-embedded auth modes, atcmd/thv-operator/controllers/mcpexternalauthconfig_controller.go:247-258,718-721. Remove this condition unless the resource has embedded auth configuration. - Medium: the new vMCP advisory condition writes the whole co-owned
status.conditionsarray from a stale object atcmd/thv-operator/controllers/virtualmcpserver_controller.go:249-251,403-408. The helper documents that an RFC 7396 merge patch replaces arrays (cmd/thv-operator/pkg/controllerutil/status.go:33-44), so concurrent runtime conditions can be erased. Establish a single conditions owner and test an injected concurrent runtime update.
Prior inline canonical-grant and canonical confidential-transport validation blockers also remain.
RFC 8707 resources and RFC 8693 audiences are independent request dimensions for SPIFFE-authenticated token exchange, but the resource allowlist was validated at config time and then silently discarded when building the runtime client: staticClients() only passed scopes and audiences to NewSPIFFEClient, and grantResourceAudience checked every "resource" request parameter against GetAudience() regardless. A client configured with disjoint audiences and resources could get a token for a resource that was never in its resources allowlist (as long as it happened to match an audience), while a legitimately configured resource was wrongly denied. Give SPIFFEClient its own Resources() accessor alongside Audiences(), thread policy.Resources() through the association registry, and have grantResourceAudience check a resourceScopedClient's Resources() instead of GetAudience() when the client implements it. Other client types (DelegateClient, DCR clients) are unaffected since they don't implement the new interface and keep using GetAudience() exactly as before. An earlier iteration of this client (before the current three-arg constructor) modeled resources correctly and had a test asserting GetAudience() stayed empty absent an explicit audience -- fail closed. That assertion was removed rather than adapted when the field was dropped, and its replacement asserted the new fail-open behavior as intended. Restore disjoint-resources/audiences coverage at the client, registry, and handler layers so the same regression can't hide again. Signed-off-by: Jakub Hrozek <jakub@stacklok.com>
5314ea2 to
291104f
Compare
memory.go and redis.go each hand-rolled their own fingerprint comparison to decide whether a reconciling client is the same logical client or a colliding one, kept in sync only by a doc comment. Replace both with one clientFingerprint value type and two adapters so the comparison exists once and cannot drift between backends.
staticClientPlaceholder durably fingerprinted a SPIFFE association on scopes and audiences only, so two associations at the same client ID differing solely in their RFC 8707 resource allowlist would reconcile as the same client instead of failing loudly. Thread resources through inertPlaceholderClient, clientFingerprint, and storedClient so the durable identity matches what the client actually authorizes.
291104f to
40a2370
Compare
JAORMX
left a comment
There was a problem hiding this comment.
Re-review at 40a2370b56b845d163469856f1b3b0ebec973350: the head changed because the stack was rebuilt on the updated #6473 base; this CRD layer still has the same unresolved validation/status issues.
Resolved since the earlier reviews: inline vMCP validation now recognizes canonical InboundGrants, and the Go confidential-transport check includes canonical delegate clients.
Still blocking:
- High: canonical issuer policies skip the owning
MCPExternalAuthConfigtrusted-issuer validation wheneverInboundGrantsis non-nil (cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go:2148-2157), so runtime-invalid issuer/JWKS/self-issuer configurations can be publishedValid=Trueuntil a consumer builds RunConfig. - Canonical token-exchange policies still lack legacy-equivalent admission constraints for reserved actor claims, wildcard exclusivity,
allowMayActcombinations, and item lengths (mcpexternalauthconfig_types.go:615-648). The confidential-transport CEL rule also remains legacy-only at line 682 even though the Go mirror handles canonical delegates. applyDeprecatedInboundGrantConditionstill adds a false deprecation condition to non-embedded auth modes (cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go:247-258).- The vMCP status write still replaces the co-owned conditions array from a separately fetched object (
cmd/thv-operator/controllers/virtualmcpserver_controller.go:391-408); the merge-patch helper documents that arrays require a single owner. A concurrent runtime condition can be erased.
All checks are green.
Signed-off-by: Jakub Hrozek <jakub@stacklok.com>
The SPIFFE client-auth epic needs a place to configure SPIFFE association policy without inventing a parallel trust/grant path next to the existing delegate-client and trusted-issuer configuration. As more inbound grant families (RFC 8693 token exchange, RFC 7523 JWT-bearer, SPIFFE) accumulate, they need one canonical surface to configure and reason about instead of three independent ones, without breaking deployments that already rely on the legacy fields. Add pkg/authserver/inbound_grants.go with NormalizeInboundGrants, which reconciles a new canonical RunConfig.InboundGrants surface (per-family token_exchange/jwt_bearer sub-configs whose issuer_policies reference a trusted_issuers entry by name) against the legacy top-level delegate_clients and the RFC 8693/7523 fields embedded directly on trusted_issuers. Legacy and canonical configuration for the same grant family are mutually exclusive and rejected at validation time; the two families are otherwise independent, and omitting inbound_grants entirely preserves released behavior. Thread the normalized result through RunConfig.Validate, the embedded-auth-server runner, and buildProvider/discovery, adding a DisableTokenExchange capability so RFC 8693 registration and discovery advertisement can be turned off together and can't drift out of sync. Add TrustedIssuer.Name so canonical issuer_policies can reference an issuer without duplicating its fields. SPIFFE client authentication (InboundGrants.SPIFFEClientAuth, defined in the previous commit) is deliberately kept a sibling of TokenExchange and JWTBearer here, not nested under either: SPIFFE authenticates a client, it does not by itself grant it anything, so making it subordinate to RFC 8693 enablement would mean disabling token exchange silently drops every SPIFFE association, and every SPIFFE-authenticated client would be implicitly token-exchange-capable. It is validated and wired directly from RunConfig.InboundGrants in RunConfig.Validate/embeddedauthserver.go, independent of this file's legacy/canonical projection, so authentication method and grant-family enablement stay separately configurable. Update docs/arch/17-token-exchange-delegation.md for the new inbound_grants shape and the now-conditional token-exchange discovery advertisement, and add a runner-level test proving the canonical delegate-client, SPIFFE-client, and jwt_bearer paths reach a running server (the existing tests only covered normalization in isolation). SPIFFE client-auth associations always require the token-exchange grant (the only grant type they may declare), independent of the legacy/canonical token-exchange projection above: NormalizeInboundGrants now sets Capabilities.TokenExchange true whenever InboundGrants.SPIFFEClientAuth is non-empty, so a SPIFFE-only configuration cannot leave it false and silently disable the RFC 8693 grant handler server-wide -- which would reject every SPIFFE client's own token requests before authentication is even checked. Guarded by a regression test in this package (not just the runner-level test above) since the equivalent fix was previously lost during a rebase when its only coverage lived one package away. DCR (RFC 7591 /oauth/register) now rejects a registration whose effective grant types include token-exchange when it is disabled server-wide, instead of accepting the client and only failing later, confusingly, at /oauth/token. The check runs on the post-defaulting grant types validateGrantTypes already computes (a private_key_jwt client with an empty grant_types is implicitly token-exchange-only), so it catches both the explicit and implicit cases the same way scope validation already gates DCR on ScopesSupported. Corrected two stale doc references caught in review: the SPIFFE client-policy field path (inbound_grants.spiffe_client_auth, not nested under token_exchange) and the JWT-bearer legacy/canonical conflict wording (family-wide across all issuers, not per-issuer). Refs #6200 Signed-off-by: Jakub Hrozek <jakub@stacklok.com>
The operator needs the shared inbound-grant model before it can add SPIFFE associations without inventing a separate Kubernetes API. Add v1beta1 grant-family types, CEL constraints, runtime conversion, generated schemas, and compatibility coverage for existing grant fields. Refs #6200
Operators need a visible migration signal before legacy grant fields can be removed safely. Record deprecated field paths during normalization and surface them as status conditions without changing the effective authorization policy. Refs #6200
40a2370 to
d634568
Compare
JAORMX
left a comment
There was a problem hiding this comment.
Re-review at d634568 (reviewed only d2d311d..d634568):
Resolved: inline vMCP validation recognizes canonical grants, and the Go confidential-transport check recognizes canonical delegate clients. Remaining blockers:
- cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go:615-647,682,2153-2157: canonical issuer policies still bypass authoritative owning-resource validation and lack legacy-equivalent CEL constraints; canonical delegates also bypass the confidential-transport CEL rule. Invalid configs can be admitted/reported Valid=True until consumed. Normalize/validate at MCPExternalAuthConfig and establish CEL parity with regression cases.
- cmd/thv-operator/controllers/mcpexternalauthconfig_controller.go:247-258: emit no DeprecatedInboundGrantConfiguration condition for a non-embedded auth mode.
- cmd/thv-operator/controllers/virtualmcpserver_controller.go:391-408: an unlocked merge patch replaces the co-owned conditions array and can erase a concurrent runtime condition. Establish one owner or otherwise serialize/write a non-co-owned field; add a concurrent-update regression test.
Exact-head CI is green (43/43 checks). task operator-test and task operator-test-integration also passed.
Summary
The operator's CRDs (
VirtualMCPServer,MCPExternalAuthConfig) only expose the legacy, per-field inbound-grant configuration (delegateClients,trustedIssuers[*]policy fields). The canonicalinboundGrantsgrant-family model already exists at the runtimeRunConfiglayer (this stack's earlier PRs), but the operator has no way to declare it — a prerequisite for adding SPIFFE associations without inventing a second, parallel Kubernetes API surface for the same underlying grants.inboundGrantsCRD types (TokenExchange/JWTBearergrant families, delegate clients, issuer policies) to the sharedEmbeddedAuthServerConfig, with CEL admission validation for legacy/canonical mutual exclusion.authserver.RunConfigvia the same converter pattern the legacy fields already use.ConditionTypeVirtualMCPServerDeprecatedInboundGrantConfiguration) during normalization, without changing the effective authorization policy — this gives operators a visible migration signal before legacy fields are removed.Refs #6200
Type of change
Test plan
task test)task lint-fix)New table-driven unit tests for the converter and CEL admission rules (
authserver_inbound_grants_test.go,inbound_grants_cel_test.go), plus a reconcile-level test asserting the deprecation condition transitions correctly and emits a one-shot warning event.API Compatibility
v1beta1API —inboundGrantsis a new optional field; existing legacy fields are unchanged and continue to work standalone.Changes
Large diff (~3,470 lines), dominated by generated CRD YAML (
deploy/charts/operator-crds/**, regenerated viatask operator-manifests/task operator-generate) anddocs/operator/crd-api.md(regenerated viatask crdref-gen) — roughly 2,600 of the changed lines are generated, not hand-written. Hand-written surface:mcpexternalauthconfig_types.go(new CRD types + CEL),controllerutil/authserver.go(converter wiring),virtualmcpserverstatus/collector.go(deprecation condition), and their tests.Does this introduce a user-facing change?
Yes — operators can now declare
inboundGrantsonVirtualMCPServer/MCPExternalAuthConfigdirectly, and get a status condition warning if they're still using the legacy per-field configuration it's meant to replace.Special notes for reviewers
This PR is a cherry-picked/rebuilt version of work originally done on an earlier, abandoned branch before this stack's review cycle reworked the underlying runtime model — content and tests were re-verified against the current
RunConfig/InboundGrantsRunConfigshape rather than merged as-is. Stacked on #6474.