Skip to content

Normalize canonical inbound grants - #6473

Merged
jhrozek merged 14 commits into
mainfrom
spiffe-integration-split3-3
Sep 5, 2026
Merged

Normalize canonical inbound grants#6473
jhrozek merged 14 commits into
mainfrom
spiffe-integration-split3-3

Conversation

@jhrozek

@jhrozek jhrozek commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Summary

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.

Stacked on #6467.

  • Adds 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.
  • Threads the normalized result through RunConfig.Validate, the embedded-auth-server runner, and buildProvider/discovery — this is also where spiffe_trust_domains (added in Define SPIFFE trust configuration #6467) finally gets validated and wired into Config.SPIFFETrust, closing the gap flagged in that PR's review.
  • Adds a DisableTokenExchange capability so RFC 8693 registration and discovery advertisement turn off together and can't drift out of sync.
  • Adds TrustedIssuer.Name so canonical issuer_policies can reference an issuer without duplicating its fields.
  • Updates docs/arch/17-token-exchange-delegation.md for the new inbound_grants shape and the now-conditional token-exchange discovery advertisement.
  • Adds 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).

Fixes #

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

  • Unit tests (task test)
  • E2E tests (task test-e2e)
  • Linting (task lint-fix)
  • Manual testing (describe below)

pkg/authserver/inbound_grants_test.go covers normalization: legacy/canonical mutual exclusion per grant family, issuer-policy resolution by name (unknown/duplicate refs), and capability derivation. pkg/authserver/runner/embeddedauthserver_test.go adds coverage proving the canonical delegate-client, SPIFFE-client, and jwt_bearer paths actually reach a running embedded auth server, not just the normalization step in isolation.

API Compatibility

  • This PR does not break the v1beta1 API, OR the api-break-allowed label is applied and the migration guidance is described above.

Special notes for reviewers

This closes the "spiffe_trust_domains is a silent no-op" gap flagged in review on #6467RunConfig.Validate() now calls ValidateSPIFFETrust via NormalizeInboundGrants, and Config.SPIFFETrust is built and threaded into the embedded auth server in embeddedauthserver.go. Live SVID/bundle verification is still out of scope here; this is config normalization and wiring only.

@github-actions github-actions Bot added the size/XL Extra large PR: 1000+ lines changed label Aug 31, 2026
@codecov

codecov Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.93994% with 67 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.38%. Comparing base (7f0d08f) to head (d2d311d).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
pkg/authserver/storage/redis.go 84.96% 23 Missing ⚠️
pkg/authserver/runner/embeddedauthserver.go 75.00% 10 Missing ⚠️
pkg/authserver/server_impl.go 78.94% 8 Missing ⚠️
pkg/auth/dcr/resolver.go 56.25% 7 Missing ⚠️
pkg/authserver/storage/spiffe_decorator.go 89.39% 7 Missing ⚠️
pkg/authserver/spiffe_association_registry.go 92.50% 3 Missing ⚠️
pkg/auth/dcr/store.go 80.00% 2 Missing ⚠️
pkg/authserver/spiffe_preflight.go 71.42% 2 Missing ⚠️
pkg/authserver/storage/memory.go 95.65% 2 Missing ⚠️
pkg/authserver/server/handlers/authorize.go 95.45% 1 Missing ⚠️
... and 2 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6473      +/-   ##
==========================================
+ Coverage   78.25%   78.38%   +0.12%     
==========================================
  Files         770      776       +6     
  Lines       75436    76094     +658     
==========================================
+ Hits        59035    59647     +612     
- Misses      16396    16442      +46     
  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.

@jhrozek
jhrozek force-pushed the spiffe-integration-split3-3 branch from a93bd9b to 047b301 Compare August 31, 2026 11: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 Aug 31, 2026
@JAORMX JAORMX mentioned this pull request Aug 31, 2026
11 tasks

@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.

I reviewed this against #6467 and #6200. CI is green, but I found two blocking model/correctness issues:

  1. SPIFFEClients is nested under inbound_grants.token_exchange (pkg/authserver/inbound_grants.go:19-24). That makes SPIFFE client authentication subordinate to RFC 8693: disabling or omitting token exchange removes the association, and every SPIFFE-authenticated client is necessarily token-exchange-enabled. #6200 calls for spiffeClientAuth under inboundGrants with grants and token-exchange permission configured per association. Authentication method and grant authorization need to remain independent in the canonical schema.

  2. SPIFFE validation reports paths under inbound_grants.spiffe_client_auth[...], but this PR's actual serialized path is inbound_grants.token_exchange.spiffe_clients[...] (pkg/authserver/spiffe_trust.go, reached from RunConfig.Validate). Operators therefore get errors naming a field that does not exist. Please make the validation paths match the final schema.

The normalization direction is useful, but it currently projects the canonical wire types back into the legacy combined TrustedIssuer runtime representation. That leaves parallel policy shapes and several field-by-field copy/conflict-detection sites that must evolve in lockstep. I would prefer one grant-oriented normalized runtime model consumed by validation/provider construction, with legacy and canonical wire inputs translated into it once.

Standards notes: the commit is missing the required Signed-off-by trailer (CONTRIBUTING.md:91), and the PR is well beyond the repository's 400-line guideline. The signoff must be fixed; please also consider whether the compatibility normalization can be split from the SPIFFE wiring to reduce review risk.

@jhrozek
jhrozek force-pushed the spiffe-integration-split3-2 branch 2 times, most recently from 8abeeaa to 108bc9c Compare August 31, 2026 14:19
@jhrozek
jhrozek force-pushed the spiffe-integration-split3-3 branch from 047b301 to 7c5e172 Compare August 31, 2026 14:26
@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 Aug 31, 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.

I re-reviewed the rebased head (7c5e172). The rebase fixes the two model issues from my previous review: spiffe_client_auth remains a sibling of the grant families, validation paths now match the schema, and the commit has its DCO trailer.

One new enablement inconsistency blocks approval:

  • Canonical inbound_grants can disable token exchange, and this PR correctly removes the Fosite handler and discovery advertisement via TokenExchangeEnabled. However, /oauth/register still calls ValidateDCRRequest without that capability (pkg/authserver/server/handlers/dcr.go:243-257). It can therefore return 201 Created for a token-exchange-only client even though the server has no token-exchange handler. This is especially direct for private_key_jwt, whose token-only path explicitly accepts a token-exchange registration. Please thread TokenExchangeEnabled into DCR validation (or reject allow_private_key_jwt_registration when canonical configuration disables token exchange) and add a regression test proving a disabled grant cannot be registered.

The architecture doc also still places SPIFFE clients at inbound_grants.token_exchange.spiffe_clients; the implemented path is the sibling inbound_grants.spiffe_client_auth. Please update those references and make the documented legacy/canonical exclusion scope match the family-wide behavior in NormalizeInboundGrants.

CI currently has two failures. Both look unrelated to this authserver diff: one VirtualMCP Redis/session E2E timeout interrupted the remaining specs, and one MCPOIDCConfig integration test timed out amid resource-version conflicts and a missing test field index. They still need a clean rerun before approval.

@jhrozek

jhrozek commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Pushed a commit that resolves both blocking issues — thanks for catching these.

  1. SPIFFE subordinate to token exchange: SPIFFEClientAuth is no longer nested under inbound_grants.token_exchange. Following Define SPIFFE trust configuration #6467's schema fix, InboundGrantsRunConfig now has SPIFFEClientAuth as a sibling of TokenExchange/JWTBearer, and NormalizeInboundGrants no longer touches SPIFFE at all — ValidateSPIFFETrust/NewSPIFFETrustConfig read cfg.InboundGrants directly in RunConfig.Validate()/embeddedauthserver.go, independent of the legacy/canonical projection this file does. Disabling or omitting token_exchange no longer drops SPIFFE associations.

    This surfaced a real bug while fixing it: since SPIFFE client auth is now independent of NormalizedInboundGrants.Capabilities.TokenExchange, a config with only SPIFFE associations (no inbound_grants.token_exchange) was computing DisableTokenExchange: true and silently disabling the token-exchange grant server-wide — even though SPIFFE clients exclusively use that grant. Fixed by having prepareInboundGrantConfiguration OR in whether any SPIFFE client-auth association is configured. Caught by re-running the existing TestEmbeddedAuthServer_SPIFFEAssociationDoesNotAuthenticateClient test, which started failing with invalid_request instead of the expected invalid_client once the schema was decoupled.

  2. Validation paths didn't match the schema: fixed as part of Define SPIFFE trust configuration #6467 — error paths already say inbound_grants.spiffe_client_auth[...], matching the real field now that it's flat.

On the normalization-direction note (projecting canonical types back into the legacy TrustedIssuer representation): left as-is for now — agreed it's worth revisiting, but a larger refactor of the runtime model felt like more risk than this PR should carry given the SPIFFE-specific fixes above. Happy to open a follow-up if you'd like it tracked separately.

Signed-off-by trailer fixed. CI is green on the amended commit.

@jhrozek
jhrozek force-pushed the spiffe-integration-split3-2 branch from 108bc9c to e497cae Compare August 31, 2026 15:44
@jhrozek
jhrozek force-pushed the spiffe-integration-split3-3 branch from 7c5e172 to a1a21ea Compare August 31, 2026 15:59
@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 Aug 31, 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.

I re-reviewed the current rebased head (a1a21ea). The earlier SPIFFE placement and validation-path fixes remain good, but the latest blocker is still present: validateDCRRequest does not receive TokenExchangeEnabled, so /oauth/register can create a token-exchange-only client after canonical configuration has removed the token-exchange handler and discovery advertisement (pkg/authserver/server/handlers/dcr.go:243-257). Please gate DCR validation on the effective capability and add the disabled-grant regression test.

The architecture documentation still names inbound_grants.token_exchange.spiffe_clients at docs/arch/17-token-exchange-delegation.md:127, while the implemented path is inbound_grants.spiffe_client_auth. Its “mutually exclusive per issuer” wording around line 703 also does not match the family-wide conflict implemented by NormalizeInboundGrants; please align one side.

The amended commit has its DCO trailer. CI is still running on this head.

@jhrozek
jhrozek force-pushed the spiffe-integration-split3-2 branch from e497cae to 3413cc2 Compare August 31, 2026 19:48
@jhrozek
jhrozek force-pushed the spiffe-integration-split3-3 branch from a1a21ea to f1fc2d8 Compare August 31, 2026 20:12
@github-actions github-actions Bot removed the size/XL Extra large PR: 1000+ lines changed label Aug 31, 2026
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
@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 319de92a14c711a5c2f6209a23409662c905272d found two additional implementation defects:

  • P1 — SPIFFE resources are discarded. pkg/authserver/spiffe_association_registry.go:55-58 and pkg/authserver/server/registration/spiffe_client.go:31-44 retain scopes/audiences only, while pkg/authserver/spiffe_trust.go:138-150 independently validates Resources. The token-exchange handler checks RFC 8707 resource through client.GetAudience() (pkg/authserver/server/tokenexchange/handler.go:828-851), so resources: [R] cannot authorize resource=R, while resource=A is incorrectly allowed when only audiences: [A] is configured. Preserve resource authorization separately and test disjoint audience/resource permissions.
  • docs/arch/17-token-exchange-delegation.md:643-645 says an external issuer is not retained across delegated-token re-exchange, but pkg/authserver/server/tokenexchange/handler.go:575-579 copies it. Align the documentation or behavior and test the intended provenance contract.

The existing canonical-omission documentation blocker remains as previously reviewed.

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>
@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 7ff760b2c9c831c5172ae1779c58f6fde3911ac2: the new resource-vs-audience authorization behavior is correct and well covered, but its durable configuration fingerprint still omits the new resource policy.

  • High: pkg/authserver/storage/spiffe_decorator.go:143-149 constructs the durable static-client placeholder with scopes and RFC 8693 audiences only. Two replicas using the same SPIFFE client ID/scopes/audiences but different RFC 8707 resources therefore reconcile as identical during a rolling policy change, while their in-process clients authorize different resource sets. Include the resource allowlist in the placeholder fingerprint/reconciliation contract for memory and Redis, and add a conflicting-resources regression test.

The resource authorization implementation itself now correctly keeps Resources() separate from GetAudience() (pkg/authserver/server/tokenexchange/handler.go:810-865). Local task test did not complete before the command deadline; CI should provide the authoritative full-suite result.

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.
@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 bcb58e0ba9a304412b4a80c79b1b3c1599d00aba: the RFC 8707 resource allowlist is now included in both memory and Redis placeholder fingerprints, with conflict coverage. That resolves the last-head blocker.

The deeper pass found remaining issues:

  • High: the durable placeholder still fingerprints only OAuth policy (pkg/authserver/storage/spiffe_decorator.go:143-176; pkg/authserver/storage/types.go:613-652), not the SPIFFE association identity: trust domain, principal pattern, or enabled authentication methods. During a rolling change, two replicas can therefore accept different SPIFFE principals for the same client ID while durable reconciliation considers them identical whenever scopes/audiences/resources match. Include association identity in the durable ownership contract and cover a same-policy/different-principal conflict.
  • Medium: preflightDurableCollisions only reconciles desired IDs (spiffe_decorator.go:76-84) and has no removal path. Removing or renaming an association leaves its durable placeholder reserved indefinitely, so that client ID cannot later be registered. Reconcile stale placeholders owned by this auth-server configuration without deleting unrelated configured/DCR clients.
  • The prior documentation blockers remain: canonical JWT-bearer configuration does not disable legacy RFC 8693 when token_exchange is omitted (docs/arch/17-token-exchange-delegation.md:269-275); external_issuer is carried across re-exchange despite lines 643-645 saying it is single-hop; and lines 259-265 say static configuration replaces a DCR collision although ReconcileConfiguredClient rejects it.

All checks are green.

Signed-off-by: Jakub Hrozek <jakub@stacklok.com>
@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
@jhrozek

jhrozek commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

Pushed a fix for the durable placeholder fingerprint gap — it now includes the SPIFFE association identity (trust domain, principal pattern, authentication methods), not just OAuth policy, so two replicas can no longer durably reconcile associations that differ only in which principal is allowed to authenticate as a given client ID. Went through two rounds of adversarial review before landing: the first pass caught a real regression in an early version of the fix (an interface-embedding mistake that would have silently dropped the RFC 8707 resources enforcement we'd just added), which got fixed and independently re-verified.

On the placeholder-removal finding (stale reservations never getting cleaned up when an association disappears or is renamed): that's the same gap tracked in #6477, which is a separate, more involved piece of work (lease-based reconciliation across replicas, not a small fix). I'd like to keep it out of this PR and track it there rather than block on it here — let me know if you'd rather see it addressed inline first.

@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 d2d311d:

Resolved: durable SPIFFE fingerprints now include association identity (trust-domain reference, normalized principal, methods) as well as OAuth policy. External-issuer propagation is implemented and tested. The remaining blockers are:

  • pkg/authserver/storage/spiffe_decorator.go:29-48,61-84: SPIFFE placeholders have no stale-association reconciliation/removal path. Removing or renaming an association leaves its durable client ID reserved indefinitely. Reconcile stale placeholders owned by this configuration without deleting configured/DCR clients.
  • docs/arch/17-token-exchange-delegation.md:260-275,638-645: documentation still says canonical omission disables RFC 8693, that configured clients replace DCR clients, and that external_issuer is single-hop. Those claims disagree with NormalizeInboundGrants, storage collision behavior, and re-exchange propagation. Align the document and add/retain behavior coverage.

Exact-head CI is green.

@jhrozek
jhrozek merged commit 6b40bf3 into main Sep 5, 2026
49 checks passed
@jhrozek
jhrozek deleted the spiffe-integration-split3-3 branch 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