Skip to content

fix(workloadapi): keep X.509 context watch alive after rejected updates - #487

Merged
maxlambrecht merged 1 commit into
spiffe:mainfrom
NopeCharon:fix/x509-source-rejected-update
Oct 29, 2026
Merged

maxlambrecht merged 1 commit into
spiffe:mainfrom
NopeCharon:fix/x509-source-rejected-update

Conversation

@NopeCharon

Copy link
Copy Markdown
Contributor

What

DefaultX509Source's X.509 context watcher no longer throws from onError. This is the same change #482 made for DefaultJwtSource and CachedJwtSource:

  • An error before the first update is recorded, and init throws it, so newSource fails with X509SourceException (cause Error in X509Context watcher, as before).
  • After initialization, errors are logged and the current SVID and bundles are kept.

Fixes #486

Why

StreamObservers calls watcher.onError from inside StreamObserver.onNext when GrpcConversionUtils.toX509Context rejects an update. The WatcherException thrown there escaped onNext, gRPC cancelled the call with CANCELLED: Failed to read message., and since that code is non-retryable the watch ended permanently. The source kept serving its current SVID and never applied a later rotation, so TLS built on it failed once that SVID expired.

Before this change, a first update that failed asynchronously also counted down the init latch without throwing, so newSource returned a source with no SVID. Calls on that source then failed with X.509 SVID source not initialized. Now newSource throws.

How tested

  • DefaultX509SourceTest.testX509ContextWatcherError_afterInit_keepsX509ContextAndAppliesLaterUpdates: after onError, the source keeps its SVID and bundle, and the next update is applied.
  • DefaultX509SourceTest.testWatchX509Context_corruptedUpdate_laterUpdatesAreApplied: an in-process fake Workload API streams a valid response, a corrupted one, then another valid one. The source ends up with the last SVID (hint rotated). Without the fix it stays on initial.
  • Both tests fail without the change. newSource_errorFetchingX509Context still passes unchanged.
  • ./gradlew build on JDK 17.

DefaultX509Source threw a WatcherException from the X.509 context
watcher's onError. StreamObservers calls onError from inside
StreamObserver.onNext when GrpcConversionUtils.toX509Context rejects an
update (for example a corrupted certificate or key, or a SPIFFE ID
mismatch), so the exception escaped onNext and gRPC cancelled the call.
CANCELLED is non-retryable, so the watch ended permanently: the source
kept serving its current SVID, never applied a later rotation, and TLS
built on it started failing once that SVID expired.

Apply the same handling the JWT sources got: only errors before the
first update are initialization failures. The watcher records the error
and init throws it, so newSource also fails when the first update is
rejected asynchronously instead of returning an uninitialized source.
After initialization, errors are logged and the current SVID and
bundles are kept, so the stream stays open and the next valid update
replaces them.

Fixes spiffe#486

Signed-off-by: NopeCharon <115794254+NopeCharon@users.noreply.github.com>

@maxlambrecht maxlambrecht left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM!

@maxlambrecht
maxlambrecht merged commit ae84260 into spiffe:main Oct 29, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DefaultX509Source stops receiving SVID rotations after a rejected X.509 context update

2 participants