Register and harden static SPIFFE clients - #6474
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## spiffe-integration-split3-3 #6474 +/- ##
===============================================================
+ Coverage 78.21% 78.24% +0.03%
===============================================================
Files 770 775 +5
Lines 75127 75400 +273
===============================================================
+ Hits 58758 58995 +237
- Misses 16364 16400 +36
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.
I reviewed this against #6473 and #6200. CI is green, but the registration/storage semantics are not safe enough yet:
-
Static SPIFFE IDs are reserved only in each process-local decorator (
pkg/authserver/storage/spiffe_decorator.go:17-50); the preflight explicitly creates no durable reservation. With Redis and multiple replicas, an older/concurrent replica that does not yet have the overlay can DCR-register the same ID after another replica's preflight. New replicas then resolve the configured SPIFFE client while the old replica resolves the durable DCR client, with different authentication and authorization policy. Startup order becomes security-relevant during rolling deployment. Reserve configured client-ID ownership atomically in shared storage (idempotent for the same configured owner/fingerprint, rejecting DCR or another association); a local overlay can remain a cache, but not the authority. -
ClientRegistry.RegisterClientstill has two opposing operations hidden behind the concrete client's DCR marker (pkg/authserver/storage/types.go:604): DCR clients are create-only, while any unmarked caller may overwrite any existing client. Memory and Redis therefore still allow configured registration to replace an unrelated DCR/static client's secret, grants, scopes, audience, and public/confidential classification. This does not actually provide the create-vs-reconcile separation described by the PR. Make ordinary registration uniformly create-only and expose a narrowly scoped configured-client reconciliation operation that verifies ownership/class before replacement. -
storage.Unwrapdiscovers an anonymous capability recursively andassertionJWTConsumerpeels all decorators before checking replay storage. That bypasses a decorator that intentionally implementsAssertionJWTConsumerand lets a future decorator silently remove security capabilities by omitting an undocumentedUnwrap. Prefer explicit capability handles from the composition root, or at minimum forwardConsumeAssertionJWTthrough the SPIFFE decorator and assert on the supplied storage rather than bypassing the chain.
The /authorize protection is directionally good, but isBackChannelOnlyClient infers class from empty response types or a token-exchange-only grant. A dedicated configured-back-channel marker would avoid accidentally hiding future client classes that happen to share those metadata values.
All three commits are missing the required Signed-off-by trailer (CONTRIBUTING.md:91). The PR also exceeds the 400-line guideline substantially. No local tests were run per request; the full CI suite is green.
047b301 to
7c5e172
Compare
b36f0f7 to
67ac01b
Compare
67ac01b to
603cd2c
Compare
7c5e172 to
a1a21ea
Compare
JAORMX
left a comment
There was a problem hiding this comment.
I re-reviewed the rebased head (603cd2c). The rebase adapts the SPIFFE model changes, but none of the blockers from my prior review changed:
- Static SPIFFE IDs remain process-local reservations with no atomic durable ownership record, leaving Redis-backed rolling deployments vulnerable to split client identity across old/new replicas (
pkg/authserver/storage/spiffe_decorator.go:17-50). ClientRegistry.RegisterClientstill hides create versus privileged replacement behind the incoming client's DCR marker; any unmarked caller can overwrite an unrelated client (pkg/authserver/storage/types.go:604, memory/Redis implementations).- Recursive
storage.Unwrapstill bypasses decorator capabilities instead of preserving explicit capability handles or forwarding replay consumption. /authorizestill infers back-channel class from response/grant metadata rather than a dedicated marker.
All three rebased commits still lack the required Signed-off-by trailer. CI has not reported checks for the new head yet.
|
Review status update: please disregard my DCO-trailer note; it is not part of the change request. The substantive blockers that remain are:
The change request remains in place for those issues only. |
603cd2c to
dc9922b
Compare
|
Pushed a commit addressing all four remaining findings. Summary per finding: 1. Durable client-ID reservation. 2. Create-only registration. 3. 4. Explicit back-channel marker. Process note, since it's relevant to trusting this: each fix was scoped with an oauth-expert/go-architect design-review pair before implementation (to avoid over-building — e.g. finding 1 could have become a full distributed-lock/fencing-token subsystem; it didn't need to be), then implemented and adversarially re-reviewed twice. The second review round caught that my first attempt at the "inert placeholder" wasn't actually inert on the Redis backend (fosite's own defaulting defeated it), which is what led to the Known, deferred limitation: a stale configured-client record (delegate or SPIFFE) is never removed from durable storage when dropped from config — this is pre-existing behavior for delegate clients, inherited rather than introduced by the SPIFFE work. Filed as #6477 rather than folded into this PR, since building real removal needs a persisted ownership marker plus a DCO trailers added to all four commits. CI is green. |
a1a21ea to
f1fc2d8
Compare
dc9922b to
4d29529
Compare
|
Re-review complete. All four prior blockers are addressed: static SPIFFE IDs now have durable Redis-safe claims; normal registration is create-only with fingerprint-gated configured reconciliation; assertion-JWT replay consumption is forwarded through the decorator chain; and back-channel status uses an explicit marker. Current CI is green. One new medium-severity multi-replica correctness gap remains in DCR cache fill. |
f1fc2d8 to
db12a8a
Compare
4d29529 to
0fa16aa
Compare
|
Rechecked at Current unit/lint/docs/security checks are green; the E2E and Operator portions of the latest CI run are still in progress. |
db12a8a to
ad97de4
Compare
0fa16aa to
9038ac2
Compare
|
Pushed a fix for the DCR cache-fill race, as a new commit. Scoped it with an oauth-expert/go-architect/redis-valkey-advisor design pass first (per your note about not requiring a newer Redis than we already support), then implemented and adversarially re-reviewed.
The re-review caught one real gap in the first pass: on a lost claim, the follow-up read could hit a genuinely-evicted key (the winner's TTL can be as short as 1 second when its CI is green. |
|
Rechecked the new create-if-absent implementation. It fixes the original concurrent-startup cache-fill race: a Redis NX claim yields one authoritative registration and losers use the durable winner. One medium lifecycle gap remains for finite client-secret expiry. A still-running replica retains its resolved upstream credentials after the Redis entry expires; a later-starting replica then sees a miss, registers a new client, and becomes authoritative. The old replica can still initiate authorization with the prior client while another replica handles the callback with the new credentials, producing replica-dependent code-exchange failure (pkg/authserver/storage/redis.go:1765-1794; pkg/authserver/runner/embeddedauthserver.go:593-618). Please coordinate rotation/retirement across live replicas, or keep the initiating credential generation available until all in-flight authorization flows complete. A two-replica expiration/restart regression test would cover this. |
dda6838 to
33aaeb6
Compare
Configured workload associations need restart-safe OAuth client records before either SVID authentication method can use them. Build immutable SPIFFE associations, materialize narrowed static clients at startup, and reject collisions instead of silently replacing existing clients. Refs #6200 Signed-off-by: Jakub Hrozek <jakub@stacklok.com>
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>
33aaeb6 to
718c4e9
Compare
9382f6e to
64484b9
Compare
|
CI update: |
|
Focused recheck of
The initial concurrent-fill race is fixed, but these expiry paths remain blockers. CI also has the existing failed v1.33.7 lifecycle job. |
|
Traced the High finding (live-replica expiry split) back through git history — it's a pre-existing architectural gap, not something this PR introduces or worsens. The single-shot-resolve-at-startup architecture (no refresh path at all) goes back to #3540/#5044, and the TTL/expiry-tied Redis caching goes back to #5195 — both well before this PR touched anything. This PR's only change in this area ( Filed #6496 to track it separately, since a real fix needs new infrastructure (scheduled refresh or push invalidation) rather than a bounded change to the code this PR touches. Still working the Medium finding (stale Redis row winning an expired-cache readback) — that one is in scope here and I'll push a fix shortly. |
Summary
Configured SPIFFE workload associations need restart-safe OAuth client records before either SVID authentication method (X.509 or JWT) can use them — without this, a restart would lose the mapping from a SPIFFE principal to its OAuth client identity and permissions.
Stacked on #6473. Four commits:
SPIFFEAssociationRegistry) and materializes narrowed static OAuth clients at startup, rejecting collisions instead of silently replacing existing clients.Fixes #
Type of change
Test plan
task test)task test-e2e)task lint-fix)Covers: association-registry construction (duplicate pattern/client-ID rejection, audience isolation between clients), static-client materialization and restart reconstruction (including Redis-backed tests that dynamic registrations survive a restart while static authority is rebuilt exclusively from the current config), that static SPIFFE clients are rejected at the authorization endpoint while still resolving at the token endpoint, uniform create-only registration on both storage backends, durable cross-replica client-ID reservation (Redis-backed), and JWT-bearer replay-protection forwarding through the full storage decorator chain.
API Compatibility
v1beta1API, OR theapi-break-allowedlabel is applied and the migration guidance is described above.Special notes for reviewers
Rebased cleanly onto the fixed
#6467/#6473base, with one fixup:spiffe_association_registry.goreferenced aSPIFFETrustConfig.validatedfield that was removed in #6467's review pass (the zero value is now documented as intentionally valid instead), and a few tests assertednilfor an empty trust config/registry where the corrected constructor now returns a valid-but-empty value. Folded those fixes into the first commit of this branch so the stack stays bisectable.The fourth commit responds to all four remaining review findings (durable client-ID reservation, create-only registration, JWT-bearer replay-check forwarding, explicit back-channel marker). Each fix was designed with an oauth-expert/go-architect review pair, implemented, and adversarially re-reviewed in two independent rounds before landing — see the reply comment for a finding-by-finding breakdown, including one known, intentionally-deferred limitation (filed as #6477: stale configured-client records — for both delegate and SPIFFE clients — are never removed when dropped from config; pre-existing behavior for delegate clients, inherited rather than introduced here).