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.23% 78.27% +0.03%
===============================================================
Files 770 775 +5
Lines 75232 75592 +360
===============================================================
+ Hits 58856 59167 +311
- Misses 16371 16420 +49
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
|
Focused recheck of The current |
JAORMX
left a comment
There was a problem hiding this comment.
Re-review complete at 5eae8f281017f4fcffc47dcfd6fa17e03e76772d.
The prior reservation atomicity, create-only registration, replay-decorator forwarding, back-channel classification, and DCO blockers are addressed. No new correctness or security findings.
All CI checks relevant to this change are green. The remaining Go Vulnerability Check failure is a repository-level x/crypto advisory on the unchanged dependency, so it should be remediated separately before release.
5eae8f2 to
fbe4ff6
Compare
fbe4ff6 to
67f5da4
Compare
|
Looked into the expired-row finding. It's the same tradeoff the earlier fix deliberately made (documented in StoreDCRCredentialsIfAbsent's "Expired existing rows" section and pinned by TestRedisStorage_DCRCredentials_ExpiredVsExpiredConverges), not a new hole -- when both the existing row and the incoming registration are already expired, returning the stable existing row rather than overwriting is what prevents concurrent claimants from exhausting retries under contention. The real gap your comment points at -- nothing re-checks a resolved credential's expiry after boot -- is broader than this function and already tracked as #6496 (filed on this same PR): resolution happens once per replica at startup, and lookupCachedResolution's expiry check only fires on a subsequent ResolveCredentials call, which nothing currently makes. A patch scoped to just this branch would also be incomplete -- the identical exposure exists, unguarded, in the genuinely-absent-and-claims-immediately path too, if the fresh registration is itself already expired by the time it's claimed. Rather than a bespoke partial fix here (which risks reopening the retry-exhaustion problem this function was already changed once to avoid), I added a doc-comment note making the tradeoff and the #6496 pointer explicit, so a future reader doesn't mistake this for an oversight. CI is green. |
|
Focused recheck of
|
|
CI update: the retried |
8f31427 to
61c427c
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>
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>
67f5da4 to
b4c8fed
Compare
|
Focused recheck of
Current CI is green, including the v1.33.7 lifecycle retry, but neither issue is resolved by this head. |
JAORMX
left a comment
There was a problem hiding this comment.
Re-review of the updated head b4c8fed88dca17c60fef5d4eb3653124cdf1e6e2: approval remains valid. The current PR-only diff correctly restores CIMD write-through renewal while preserving the configured/SPIFFE client-ID collision protections. No new correctness or security findings. No local tests were run; CI is green.
JAORMX
left a comment
There was a problem hiding this comment.
Changes requested:
pkg/authserver/storage/redis.go:1977-1979: when both credentials are expired, the claim path returns the retained expired credential andregisterAndCacheinstalls it. Reject this state (or require a usable authoritative credential) rather than leaving the replica unable to complete authorization-code exchanges until restart.
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>
|
Pushed a fix scoped exactly to your objection. The Redis claim path's "return the stable expired row without error when both are expired" behavior is unchanged and correct -- that's storage-layer anti-retry-exhaustion logic, not the bug. The bug was one layer up: Given CI is green. |
JAORMX
left a comment
There was a problem hiding this comment.
Re-review complete at d20d2f7eef8b96c3c8aaff4a3e44f747f75af60c.
The expired-authoritative-DCR-credential blocker is resolved: registerAndCache rejects a non-zero past client_secret_expires_at instead of returning an unusable credential (pkg/auth/dcr/resolver.go:499), with coverage for the concurrent-winner path (pkg/auth/dcr/resolver_test.go:1878). No new material OAuth or security findings in this delta.
All current CI checks are green; no local tests were run.
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).