[improve][client] PIP-478: Athenz and SASL v5 authentication plugins - #26319
[improve][client] PIP-478: Athenz and SASL v5 authentication plugins#26319lhotari wants to merge 3 commits into
Conversation
6ef9083 to
5a6e8a8
Compare
5a6e8a8 to
1311981
Compare
The entries of a GitHub stack keep their position when a pull request of the stack is merged: after apache#26317 was merged, apache#26319 still reports position 2 of stack #26321. Requiring position 1 therefore kept the CI blocked for a pull request which had already become the bottom one. Resolve the bottom of the stack as the lowest entry which is still open, and accept a pull request which targets the trunk branch of the stack as well, since GitHub retargets a pull request when the one below it is merged. Either condition is enough because the retargeting and the stack entries aren't necessarily updated at the same time. Assisted-by: Claude Code (Opus 5)
Migrate the two remaining built-in authentication plugins onto the v5 SPI, so every built-in now hands the client a v5-native body rather than being bridged. SASL is the interesting one: it is multi-round on both transports, and SaslAuthenticationV5 is the first production implementor of the framework HTTP authentication driver the core migration added — the piece that makes HttpAuthenticationDriver / AsyncHttpAuthenticationProvider live rather than an unused extension point. - AthenzAuthenticationV5: single-pass role token over both transports. The ZTS exchange and its cache stay on the v4 shim, which owns the Athenz SDK's transport; the body reads the current role token through a provider. - SaslAuthenticationV5: BinaryAuthDataProvider + BinaryAuthChallengeHandler for the binary protocol, HttpAuthChallengeHandler + HttpAuthHeadersProvider for SASL over HTTP. The per-broker PulsarSaslClient lives in the exchange's call-context state slot, so one body serves the whole client while each connection keeps its own handshake state. - Both shims expose their body through V5AuthenticationProvider, matching token, basic and OAuth2. The seam's javadoc no longer has to except them. Two defects found reviewing the original version of this change are folded in: - JaxRsChallengeTransport leaked every successfully-completed JAX-RS Response. InvocationCallback hands the caller an unclosed response, and reading only its headers neither consumes the entity nor releases the connection, so the success branch leaked one pooled connection per authentication round while only the timed-out branch closed. The driver runs at least one round on every admin request, so this was per request, not per client. Completion and close now both go through completeAndClose(). - AuthenticationSasl.client and saslRoleToken were plain fields, written by start()/close() on the application thread and read from the challenge driver's Jersey continuation threads with no happens-before edge. Both are volatile, matching the neighbouring fields. A third fix from that review is deliberately dropped: it made PulsarClientBuilderV5 drive a plugin implementing AsyncAuthenticationDriver raw rather than wrapping it in V5ToV4AuthenticationAdapter, to stop wrapping from hiding the plugin's HTTP capabilities. The v5-native inversion removed both the wrapper and the decision — the builder now always hands the raw plugin to the v4 slot and the client derives the body — so the branch and its tests no longer describe anything the code does. Assisted-by: Claude Code (Opus 5)
…I work SaslAuthenticationV5's per-exchange SASL provider creation and evaluateChallenge are the blocking part of a Kerberos handshake, and they must run on the client's bounded blocking executor rather than inline on the caller thread — which in production is a Netty event loop. Nothing pinned that: the suite for this body was HTTP-only. Drive it through the real V5BinaryAuthenticationDriver with a deliberately-blocking fake SASL provider, and assert the future is not already complete on the caller thread and that the work landed on the executor's thread. The test was written for the later PIP-337 removal; it belongs with the migration it describes. Assisted-by: Claude Code (Opus 5)
Three findings from reviewing this change, one of which was fixed in the base commit because it was not specific to these plugins. The SASL shim cached its HTTP authentication driver with whatever framework services were bound at first use, and kept it until close. One plugin instance is routinely shared between a PulsarClient and a PulsarAdmin, and both bind services — so whichever bound first won, and the other transport ran with services meant for its neighbour. Rebuild the driver when the binding changes; it holds no cross-request state, so replacing it is safe. The rebase left two javadoc blocks stacked before toHeaders(), so the transport class and completeAndClose() were both undocumented while toHeaders carried a doc describing neither (including a @PARAM for an argument it does not take). Each is back on the member it describes. The Athenz shim similarly kept a comment about framework services it no longer holds, and an editing artifact in its class javadoc. completeAndClose's javadoc claimed it was package-private so the close contract could be asserted on both branches — and nothing asserted it, so the response-leak fix this change carries could have regressed silently. JaxRsResponseCloseTest now pins both branches: completing from a response closes it, and a response arriving after the future already settled is closed too. Mutation-verified — restoring the original close-only-on-the-late- branch behaviour fails the first case. Assisted-by: Claude Code (Opus 5)
1311981 to
226eb43
Compare
There was a problem hiding this comment.
Pull request overview
Migrates Athenz and SASL authentication to the asynchronous v5 SPI while preserving v4 compatibility.
Changes:
- Adds v5-native Athenz and multi-round SASL implementations.
- Offloads blocking credential work and adds HTTP challenge handling.
- Adds coverage for HTTP exchanges, executor offloading, exception preservation, and response cleanup.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
pulsar-client-auth-sasl/.../SaslAuthenticationV5HttpTest.java |
Tests SASL-over-HTTP exchanges and token caching. |
pulsar-client-auth-sasl/.../SaslAuthenticationV5BinaryOffloadTest.java |
Tests binary SASL executor offloading. |
pulsar-client-auth-sasl/.../JaxRsResponseCloseTest.java |
Tests JAX-RS response cleanup. |
pulsar-client-auth-sasl/.../SaslAuthenticationV5.java |
Implements v5 SASL authentication. |
pulsar-client-auth-sasl/.../v5/package-info.java |
Documents the SASL v5 package. |
pulsar-client-auth-sasl/.../AuthenticationSasl.java |
Bridges v4 SASL to v5 drivers. |
pulsar-client-auth-sasl/build.gradle.kts |
Adds framework test dependency. |
pulsar-client-auth-athenz/.../AuthenticationAthenzTest.java |
Tests asynchronous exception preservation. |
pulsar-client-auth-athenz/.../v5/package-info.java |
Documents the Athenz v5 package. |
pulsar-client-auth-athenz/.../AthenzAuthenticationV5.java |
Implements v5 Athenz authentication. |
pulsar-client-auth-athenz/.../AuthenticationAthenz.java |
Exposes the Athenz v5 body. |
pulsar-client-api-v5/.../V5AuthenticationProvider.java |
Updates provider documentation. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| ClientAuthenticationServices services = this.authServices; | ||
| HttpAuthenticationDriver driver = httpAuthenticationDriver; | ||
| if (driver != null && httpAuthenticationDriverServices == services) { |
| * v5-native binary-protocol implementation of the built-in SASL authentication plugin (PIP-478). The v4 | ||
| * {@code AuthenticationSasl} class in the parent package is a shim that keeps its verbatim synchronous | ||
| * surface (including the SASL-over-HTTP loop) and drives | ||
| * {@link org.apache.pulsar.client.impl.auth.v5.SaslAuthenticationV5} on the async binary path via the | ||
| * shared {@code V5BinaryAuthenticationDriver}. |
| // Late-bound at initializeAsync(...): the client's bounded blocking executor, onto which the | ||
| // (ZTS-blocking) role-token fetch is off-loaded so it never runs on the Netty event loop | ||
| // (PIP-478). Null when used outside a client, in which case the fetch runs inline. |
| // PIP-478 FIX D: the client's bounded blocking executor, late-bound at initializeAsync(...). The SASL | ||
| // provider creation and evaluateChallenge/authenticate (GSSAPI/Kerberos) work is off-loaded onto it so | ||
| // it never runs on the Netty event loop. Null when used outside a client -> degraded inline computation. |
david-streamlio
left a comment
There was a problem hiding this comment.
Reviewed against master (this retargeted cleanly once #26317 merged, so it's a self-contained 12-file diff). The Athenz layering is clean, and the SASL body is a careful port. Three things below, two of which are about the two new fixes interacting.
A few things I checked and can confirm rather than just take on trust:
- The
CompletionExceptionreasoning inAuthenticationAthenz.currentRoleToken()is exactly right.CompletableFuture'sAsyncSupplycallsencodeThrowable, which re-uses an already-CompletionExceptionthrowable rather than wrapping it again — so exactly one layer reachesBinaryAuthenticationExchange.unwrap, andGettingAuthenticationDataExceptionsurvives totoV4Exception. A bareRuntimeExceptionreally would have flattened it. The comment explaining this is accurate, which is worth saying because it is the kind of claim that is usually slightly wrong. - The JAX-RS leak fix is sound.
orTimeoutmutates and returnsthis, so discarding the return value still arms the timeout on the returned future;completeAndClose'sfinallycovers the late-arrival branch thatcomplete()no-ops. Good catch on the original — one pooled connection per admin request is a real leak. - The HTTP port quietly fixes a latent v4 NPE. v4 does
previousRespHeaders.get(SASL_HEADER_STATE).equalsIgnoreCase(SASL_STATE_COMPLETE), which NPEs when the server omits that header; the port writes it constant-first. Unclaimed in the description, worth keeping.
1. httpAuthenticationDriver()'s fast path can still hand back a driver built with the other binding's services.
The fast path reads two volatiles independently:
ClientAuthenticationServices services = this.authServices;
HttpAuthenticationDriver driver = httpAuthenticationDriver; // read A
if (driver != null && httpAuthenticationDriverServices == services) { // read Band the writer publishes them in sequence under the lock:
httpAuthenticationDriver = driver; // write 1
httpAuthenticationDriverServices = services; // write 2A reader that performs read A before write 1 and read B after write 2 sees the old driver paired with the new services, passes the guard, and returns the driver built with the previous binding — precisely the defect this fix exists to remove. It self-heals on the next call, so the blast radius is one request. But since both call sites (BaseResource:131, HttpClient:358) invoke this per request, "two threads at once" is the normal case for a shared client+admin.
The interleaving:
| # | reader thread | rebuilding thread |
|---|---|---|
| 1 | services = authServices → S2 |
|
| 2 | driver → D1 (read A) |
|
| 3 | httpAuthenticationDriver = D2 (write 1) |
|
| 4 | httpAuthenticationDriverServices = S2 (write 2) |
|
| 5 | driverServices == services → S2 == S2 ✓ (read B) |
|
| 6 | returns D1, built with S1 |
Note this is not a memory-model subtlety — volatile accesses are totally ordered, so no reordering is involved. It is a plain temporal window: read A simply happens before write 1, and read B after write 2.
Reproduction. The window is sub-microsecond, so I built a harness that mirrors the exact field-access shape and runs it two ways — pinned (deterministic) and unassisted. Saved as a single file, runs with java BindingRace.java, no dependencies:
BindingRace.java
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicLong;
/** Repro for the httpAuthenticationDriver() binding race. Run: java BindingRace.java */
public class BindingRace {
record Services(String name) { public String toString() { return name; } }
record Driver(Services builtWith) { }
record Result(Driver driver, Services comparedAgainst) { }
static volatile boolean stop;
/** The shape as written in AuthenticationSasl: two independently-read volatiles. */
static class Racy {
volatile Services authServices; volatile Driver driver; volatile Services driverServices;
volatile Thread pin; volatile Runnable betweenReads;
Result get() {
Services services = authServices;
Driver d = driver; // read A
if (Thread.currentThread() == pin && betweenReads != null) betweenReads.run();
if (d != null && driverServices == services) return new Result(d, services); // read B
synchronized (this) {
d = driver;
if (d == null || driverServices != services) {
Driver nd = new Driver(services);
driver = nd; // write 1
driverServices = services; // write 2
d = nd;
}
return new Result(d, services);
}
}
}
/** The fix: one immutable pair behind one volatile, so the fast path is a single read. */
static class Fixed {
record Binding(Driver driver, Services services) { }
volatile Services authServices; volatile Binding binding;
volatile Thread pin; volatile Runnable betweenReads;
Result get() {
Services services = authServices;
Binding b = binding; // single read
if (Thread.currentThread() == pin && betweenReads != null) betweenReads.run();
if (b != null && b.services() == services) return new Result(b.driver(), services);
synchronized (this) {
b = binding;
if (b == null || b.services() != services) binding = b = new Binding(new Driver(services), services);
return new Result(b.driver(), services);
}
}
}
/** Pin a reader between its two reads while another thread rebuilds. Deterministic. */
static Result pinned(Object holder) throws Exception {
Services s1 = new Services("S1-client"), s2 = new Services("S2-admin");
Racy r = holder instanceof Racy x ? x : null; Fixed f = holder instanceof Fixed x ? x : null;
if (r != null) { r.authServices = s1; r.get(); r.authServices = s2; }
else { f.authServices = s1; f.get(); f.authServices = s2; }
CountDownLatch didReadA = new CountDownLatch(1), writerDone = new CountDownLatch(1);
Result[] out = new Result[1];
Thread reader = new Thread(() -> out[0] = r != null ? r.get() : f.get());
Runnable hook = () -> { didReadA.countDown(); try { writerDone.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } };
if (r != null) { r.pin = reader; r.betweenReads = hook; } else { f.pin = reader; f.betweenReads = hook; }
reader.start();
didReadA.await(); // reader has done read A
new Thread(() -> { if (r != null) r.get(); else f.get(); writerDone.countDown(); }).start();
reader.join();
return out[0];
}
/** No hooks at all: concurrent readers plus a thread that rebinds. */
static long stress(Object holder, int threads, long ms) throws Exception {
Services s1 = new Services("S1-client"), s2 = new Services("S2-admin");
Racy r = holder instanceof Racy x ? x : null; Fixed f = holder instanceof Fixed x ? x : null;
if (r != null) { r.authServices = s1; r.get(); } else { f.authServices = s1; f.get(); }
AtomicLong stale = new AtomicLong(), calls = new AtomicLong();
stop = false;
Thread flip = new Thread(() -> { boolean a = false; while (!stop) { Services s = (a = !a) ? s2 : s1; if (r != null) r.authServices = s; else f.authServices = s; Thread.onSpinWait(); } });
flip.setDaemon(true); flip.start();
Thread[] ts = new Thread[threads];
for (int i = 0; i < threads; i++) {
ts[i] = new Thread(() -> { long n = 0; while (!stop) { Result res = r != null ? r.get() : f.get(); if (res.driver().builtWith() != res.comparedAgainst()) stale.incrementAndGet(); n++; } calls.addAndGet(n); });
ts[i].setDaemon(true); ts[i].start();
}
Thread.sleep(ms); stop = true;
for (Thread t : ts) t.join(2000);
System.out.printf(" %,d calls, %,d stale pairings%n", calls.get(), stale.get());
return stale.get();
}
static void show(String label, Result res) {
System.out.printf(" %-6s compared against %-9s got driver built with %-9s -> %s%n", label,
res.comparedAgainst(), res.driver().builtWith(),
res.driver().builtWith() != res.comparedAgainst() ? "STALE (bug)" : "ok");
}
public static void main(String[] args) throws Exception {
int n = Math.max(4, Runtime.getRuntime().availableProcessors());
System.out.println("Deterministic (reader pinned between read A and read B):");
show("racy", pinned(new Racy()));
show("fixed", pinned(new Fixed()));
System.out.println("Unassisted stress (" + n + " readers, 3s, no hooks):");
System.out.println(" racy:"); stress(new Racy(), n, 3000);
System.out.println(" fixed:"); stress(new Fixed(), n, 3000);
}
}Results on JDK 24 / arm64 (10 readers, 3s per mode):
Deterministic (reader pinned between read A and read B):
racy compared against S2-admin got driver built with S1-client -> STALE (bug)
fixed compared against S2-admin got driver built with S2-admin -> ok
Unassisted stress (10 readers, 3s, no hooks):
racy: 96,079,721 calls, 389 stale pairings
fixed: 97,837,193 calls, 0 stale pairings
Across four runs the racy shape produced 377–746 stale pairings per ~100M calls (~1 in 200k); the fixed shape produced 0 across ~350M calls. The invariant checked is the precise one — the returned driver must have been built with the same services value the method compared against — so there are no false positives from authServices merely changing concurrently.
Two honest caveats: the stress mode rebinds continuously, which inflates the rate well above production, where rebinds cluster around client/admin construction. What it demonstrates is reachability without any injected hooks; the deterministic mode is what pins the interleaving itself. And this is the extracted shape, not AuthenticationSasl — asserting the invariant against the real class needs a way to see which services a driver was built with, i.e. a @VisibleForTesting accessor on HttpAuthenticationDriver. Probably not worth adding if you take the fix, since it makes the state unrepresentable.
Fix. Collapsing the pair into one immutable value behind a single volatile — a record Binding(HttpAuthenticationDriver driver, ClientAuthenticationServices services) — makes the fast path a single read and removes the interleaving by construction. That is the Fixed variant above, and it is what reports 0.
2. The rebuild-on-rebind fix and FIX C contradict each other.
The rebuild is justified as: "the driver holds no cross-request state (that lives in the per-request call context), so replacing it is safe."
That is no longer true in this PR. HttpAuthenticationDriver holds private final Authentication v5, and the body it holds is a fresh SaslAuthenticationV5 whose cachedRoleToken is — per its own comment — "the cross-request SASL-over-HTTP role-token cache". So rebuilding the driver discards a validated role token and forces the next request into a full Kerberos negotiation, which is the exact cost FIX C was added to avoid.
The impact is bounded (a rebind happens at client/admin construction, not per request), so this is a coherence problem more than a hot-path one. But the comment will be read as licence to rebuild freely, and it no longer holds.
There's a related consequence worth deciding on deliberately: the shim still carries saslRoleToken for the v4 newRequestHeader/getHeaders path, so a plugin instance now has two independent role-token caches that never share. A deployment exercising both paths negotiates Kerberos twice.
Both fall out if the cache lives on the shim rather than on the body — which is where v4 kept it, and saslRoleToken is already there and already volatile after this PR. The body would read/write it through the same SaslProviderFactory-style seam it already uses for the provider. That restores the comment's truth, survives rebuilds, and collapses the two caches into one.
3. The HTTP port drops v4's hasDataForHttp() guard.
v4:
if (authData.hasDataForHttp()) {
authData.getHttpHeaders().forEach(...);
}port:
conv.provider.getHttpHeaders().forEach(e -> headers.put(e.getKey(), e.getValue()));AuthenticationDataProvider.getHttpHeaders() defaults to returning null (and hasDataForHttp() to false), so this NPEs for any provider that doesn't override both. Harmless for the built-in path — SaslAuthenticationDataProvider returns true and a non-null set — but SaslAuthenticationV5's constructor and the SaslProviderFactory interface are both public, so the guard isn't purely defensive. Cheap to restore.
Minor / worth confirming: in JaxRsChallengeTransport.get, the whenComplete cancels with responseFuture.cancel(true) and is guarded on !responseFuture.isDone(). That guard assumes Jersey marks its Future done before invoking InvocationCallback.completed(...). If it doesn't, a successful round would issue an interrupting cancel against the worker thread that just delivered the response. cancel(false) would be immune to the ordering either way. I didn't chase Jersey's ordering, so flagging rather than asserting.
For disclosure: this is static analysis over the branch — I did not run the suites or sanityCheck locally.
|
Amending my own advice on finding 1, having now read the Copilot review that landed just before mine. Its comment on I raised a race in the fast path: a reader interleaving between the two volatile writes can pair the old driver with the new services. Collapsing them into a single So please don't read my My other two findings are unaffected. Finding 2 (the rebuild-on-rebind comment vs. Copilot's other three comments — the stale binary-only wording in the SASL v5 |
Main Issue: #25890
PIP: #25890
Motivation
PIP-478 migrates Pulsar's built-in authentication plugins onto an asynchronous, capability-segregated v5 SPI. Token, basic and OAuth2 landed with the earlier PRs; Athenz and SASL are the two that remain, and they are the ones that made the design earn its keep.
SASL is the interesting case. It is multi-round on both transports, and
SaslAuthenticationV5is the first production implementor of the framework HTTP authentication driver the core migration added — the piece that makesHttpAuthenticationDriver/AsyncHttpAuthenticationProvidera live extension point rather than an unused one. It is also the plugin whose credential work is most worth getting off the event loop: a GSSAPI exchange talks to a KDC.Modifications
AthenzAuthenticationV5— a single-pass role-token credential over both transports. The ZTS exchange and its cache stay on the v4 shim, which owns the Athenz SDK's transport; the body reads the current role token through a provider. This is the layering PIP-478 specifies for the credential-acquisition-heavy plugins: expose the async surface without reimplementing hard-won provider logic.SaslAuthenticationV5—BinaryAuthDataProvider+BinaryAuthChallengeHandlerfor the binary protocol,HttpAuthChallengeHandler+HttpAuthHeadersProviderfor SASL over HTTP. The per-brokerPulsarSaslClientlives in the exchange's call-context state slot, so one body serves the whole client while each connection keeps its own handshake state, and concurrent handshakes to different brokers cannot collide.Both shims hand their body over through
V5AuthenticationProvider, the seam #26317 introduced, so every built-in now works the same way and the seam's javadoc no longer has to except two of them.Two defects fixed alongside, both found reviewing the original version of this change:
JaxRsChallengeTransportleaked every successfully-completed JAX-RSResponse.InvocationCallback<Response>hands the caller an unclosed response, and reading only its headers neither consumes the entity nor releases the connection — so the success branch leaked one pooled connection per authentication round while only the timed-out branch closed. The driver runs at least one round on every admin request, so this was per request, not per client.AuthenticationSasl.clientandsaslRoleTokenwere plain fields, written bystart()/close()on the application thread and read from the challenge driver's Jersey continuation threads with no happens-before edge.And three from reviewing this rebase:
PulsarClientand aPulsarAdmin, and both bind — so whichever bound first won, and the other transport ran with services meant for its neighbour. The driver is rebuilt when the binding changes; it holds no cross-request state.completeAndClose's javadoc claimed it was package-private so the close contract could be asserted, and nothing asserted it — the leak fix above could have regressed silently.JaxRsResponseCloseTestnow pins both branches, mutation-verified against the original behaviour.completeAndCloseundocumented whiletoHeaderscarried a description of neither.Verifying this change
This change added tests and can be verified as follows:
SaslAuthenticationV5BinaryOffloadTest— the per-exchange SASL provider creation andevaluateChallengerun on the blocking executor, not the caller thread. Driven through the realV5BinaryAuthenticationDriverwith a deliberately-blocking fake provider.SaslAuthenticationV5HttpTest— the SASL-over-HTTP 401 → resubmit → 200 exchange through the framework driver.JaxRsResponseCloseTest— every completion path closes its response, including one arriving after the future already settled. Mutation-verified.AuthenticationAthenzTest— the async path preserves theGettingAuthenticationDataExceptionsubtype, driven through the production resolution path rather than a test-only seam.:pulsar-client-auth-athenz:test,:pulsar-client-auth-sasl:test,:pulsar-client-v5:test,:pulsar-client-original:test,quickCheckandsanityCheckpass locally; full CI green on the equivalent branch.Does this pull request potentially affect one of the following parts:
If the box was checked, please highlight the changes
The schema box is checked only to be safe: no serialized format changes, but
AuthenticationSaslandAuthenticationAthenzareSerializablepublic classes whose fields changed (addedvolatile, added the driver cache). TheirserialVersionUIDis unchanged and no field was removed or retyped.The threading model: Athenz and SASL credential work now runs on a blocking executor rather than on the calling thread. On the client that thread was already an executor; on paths with no client-owned executor — the proxy's broker connections — it now uses the shared fallback pool introduced in #26317 rather than the caller's Netty event loop.
Documentation
doc-requireddoc-not-neededdocdoc-completeInternal migration of two built-in plugins; no configuration or user-facing API changes.
Matching PR in forked repository
PR in forked repository: lhotari#253
Prepared with the assistance of Claude Code (Opus 5).