Skip to content

Expose canonical inbound grants in CRDs - #6499

Open
jhrozek wants to merge 17 commits into
mainfrom
spiffe-integration-split3-5
Open

Expose canonical inbound grants in CRDs#6499
jhrozek wants to merge 17 commits into
mainfrom
spiffe-integration-split3-5

Conversation

@jhrozek

@jhrozek jhrozek commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

The operator's CRDs (VirtualMCPServer, MCPExternalAuthConfig) only expose the legacy, per-field inbound-grant configuration (delegateClients, trustedIssuers[*] policy fields). The canonical inboundGrants grant-family model already exists at the runtime RunConfig layer (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.

  • Add inboundGrants CRD types (TokenExchange/JWTBearer grant families, delegate clients, issuer policies) to the shared EmbeddedAuthServerConfig, with CEL admission validation for legacy/canonical mutual exclusion.
  • Wire the new fields through to authserver.RunConfig via the same converter pattern the legacy fields already use.
  • Report deprecated legacy field usage as a status condition (ConditionTypeVirtualMCPServerDeprecatedInboundGrantConfiguration) during normalization, without changing the effective authorization policy — this gives operators a visible migration signal before legacy fields are removed.
  • Regenerate CRD YAML/deepcopy/docs.

Refs #6200

Type of change

  • New feature

Test plan

  • Unit tests (task test)
  • Linting (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

  • This PR does not break the v1beta1 API — inboundGrants is 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 via task operator-manifests/task operator-generate) and docs/operator/crd-api.md (regenerated via task 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 inboundGrants on VirtualMCPServer/MCPExternalAuthConfig directly, 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/InboundGrantsRunConfig shape rather than merged as-is. Stacked on #6474.

@codecov

codecov Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.37864% with 26 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.36%. Comparing base (d2d311d) to head (d634568).

Files with missing lines Patch % Lines
...or/controllers/mcpexternalauthconfig_controller.go 73.80% 22 Missing ⚠️
...perator/controllers/virtualmcpserver_controller.go 94.59% 2 Missing ⚠️
cmd/thv-operator/pkg/controllerutil/authserver.go 96.87% 2 Missing ⚠️
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.
📢 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.

@JAORMX JAORMX left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The canonical model is wired through the CRDs, conversion, and generated artifacts, but two correctness gaps block approval:

  1. VirtualMCPServer inline auth validation still recognizes only legacy delegateClients / trustedIssuers[*].jwtBearerGrant as satisfying token-only operation (cmd/thv-operator/controllers/virtualmcpserver_controller.go:670-685). A valid inline configuration using only spec.authServerConfig.inboundGrants is admitted but marked AuthServerConfigValidated=False and never deploys. Please make this path recognize configured canonical grant families, consistent with the external-auth path.

  2. MCPExternalAuthConfig validation's confidential-client transport check only considers legacy DelegateClients (cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go:961-967, called at 2129-2133). A canonical delegate client for an invalid non-loopback HTTP issuer can therefore report Valid=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.

@jhrozek
jhrozek force-pushed the spiffe-integration-split3-4 branch 2 times, most recently from fbe4ff6 to 67f5da4 Compare September 3, 2026 10:02
@jhrozek
jhrozek force-pushed the spiffe-integration-split3-5 branch from 5834d9c to 6dbb23e Compare September 3, 2026 10:04
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Sep 3, 2026
@jhrozek
jhrozek force-pushed the spiffe-integration-split3-4 branch from 67f5da4 to b4c8fed Compare September 3, 2026 13:00
@jhrozek
jhrozek force-pushed the spiffe-integration-split3-5 branch from 6dbb23e to e038ceb Compare September 3, 2026 13:03
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Sep 3, 2026

@JAORMX JAORMX left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-review at e038ceb5d38e0961cb3b332c58359863bbf392f6: the two prior blockers are still unresolved.

  • VirtualMCPServer accepts canonical grants in CRD admission but its inline runtime guard still ignores cfg.InboundGrants, rejecting valid token-only configurations (cmd/thv-operator/controllers/virtualmcpserver_controller.go:670-685).
  • ValidateConfidentialClientTransport still ignores canonical inboundGrants.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 JAORMX left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Changes requested:

  • cmd/thv-operator/controllers/virtualmcpserver_controller.go:670: inline validation ignores canonical InboundGrants, 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 published Valid=True despite invalid policy values. Validate normalized canonical policies at this boundary.

@JAORMX JAORMX left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

@jhrozek
jhrozek force-pushed the spiffe-integration-split3-5 branch from e038ceb to ae26bc4 Compare September 3, 2026 14:14
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Sep 3, 2026

@JAORMX JAORMX left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Changes requested:

  • cmd/thv-operator/controllers/virtualmcpserver_controller.go:670-685: inline validation still ignores canonical InboundGrants, 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 owning MCPExternalAuthConfig trusted-issuer validation and can be reported Valid=True despite 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.

@jhrozek
jhrozek force-pushed the spiffe-integration-split3-5 branch from ae26bc4 to ce2447f Compare September 3, 2026 16:16
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>
@jhrozek
jhrozek force-pushed the spiffe-integration-split3-3 branch from 95b1339 to 319de92 Compare September 4, 2026 05:26
@jhrozek
jhrozek force-pushed the spiffe-integration-split3-5 branch from 1d2f7a3 to 5314ea2 Compare September 4, 2026 05:44
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Sep 4, 2026

@JAORMX JAORMX left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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=True although runtime-invalid: legacy-equivalent CEL constraints are absent at cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go:615-648, and trusted-issuer validation is skipped when inboundGrants is present at :2153-2157. Normalize/validate before reporting Valid=True, while retaining consumer-specific checks at consumers.
  • Medium: applyDeprecatedInboundGrantCondition adds DeprecatedInboundGrantConfiguration=False even to non-embedded auth modes, at cmd/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.conditions array from a stale object at cmd/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>
@jhrozek
jhrozek force-pushed the spiffe-integration-split3-5 branch from 5314ea2 to 291104f Compare September 4, 2026 08:07
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Sep 4, 2026
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.
@jhrozek
jhrozek force-pushed the spiffe-integration-split3-5 branch from 291104f to 40a2370 Compare September 4, 2026 12:31
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Sep 4, 2026

@JAORMX JAORMX left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 MCPExternalAuthConfig trusted-issuer validation whenever InboundGrants is non-nil (cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go:2148-2157), so runtime-invalid issuer/JWKS/self-issuer configurations can be published Valid=True until a consumer builds RunConfig.
  • Canonical token-exchange policies still lack legacy-equivalent admission constraints for reserved actor claims, wildcard exclusivity, allowMayAct combinations, 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.
  • applyDeprecatedInboundGrantCondition still 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
@jhrozek
jhrozek force-pushed the spiffe-integration-split3-5 branch from 40a2370 to d634568 Compare September 5, 2026 13:45
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Sep 5, 2026

@JAORMX JAORMX left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Base automatically changed from spiffe-integration-split3-3 to main September 5, 2026 14:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Extra large PR: 1000+ lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants