[feat][client] PIP-478: asynchronous v5 client authentication and TLS factory integration (core migration) - #26282
[feat][client] PIP-478: asynchronous v5 client authentication and TLS factory integration (core migration)#26282lhotari wants to merge 13 commits into
Conversation
… + server-side TLS factory integration (core migration) This is the core of PIP-478: migrate the client and server to the asynchronous v5 authentication interface and the pluggable server-side TLS factory. The legacy PIP-337 PulsarSslFactory and SecurityUtility code paths are replaced throughout client, admin, broker, proxy, websocket and functions-worker, with secure-by-default, SAN-only hostname matching. Rebased onto the reworked default TLS factory (apache#26271) and current master. Assisted-by: Claude Code (Opus 5)
…tory API The default TLS factory was reworked during the review of apache#26271, so two of the APIs this migration calls have moved: - JettyTlsFactory's two public builders now require an Executor for the rotation reload, so that it never runs inline on the factory's delivery thread. Each of the five call sites passes the executor that component already owns for TLS work: the broker's shared executor (WebService), the websocket / functions-worker scheduled executor, the proxy web server's dedicated TLS refresh executor, and the admin handler's SSL refresher. - TlsContextAcquisition's HTTP rotation connection-TTL constant and system property were dropped from apache#26271 as unused; their consumers (HttpClient and the v5 FrameworkHttpClientFactory) arrive here, so they are restored with this change. Assisted-by: Claude Code (Opus 5)
Under PIP-337 the web-service provider keys only reached Jetty's
SslContextFactory.setProvider(...), which is inert on a factory that overrides
getSslContext() with a pre-built context — so the shipped Conscrypt default never actually
selected a provider. PIP-478 routes those keys onto TlsPolicy.jsseProvider, where a
configured name is pinned and an unresolvable one fails startup. That makes the default
real for the first time.
conscrypt-openjdk-uber ships native libraries for x86_64 only (linux, macOS, Windows), so
with the default in force the broker, proxy and websocket web listeners fail to start on
aarch64 (Apple silicon, ARM servers) and s390x:
No java.security.Provider named 'Conscrypt' could be resolved ...
at JcaProviders.resolveNamedProvider
at TlsContexts.buildJdkContext
at WebService.<init>
Reproduced locally on macOS/aarch64 by AdminProxyHandlerKeystoreTLSTest and
ProxyAuthenticatedProducerConsumerTest; linux-x86_64 CI cannot catch it.
Drop the Conscrypt default from ServiceConfiguration.webServiceTlsProvider,
ProxyConfiguration.webServiceTlsProvider and WebSocketProxyConfiguration.tlsProvider, and
from broker.conf, standalone.conf, proxy.conf, websocket.conf and functions_worker.yml.
Unset means the JVM default, which is what deployments have effectively been running all
along; an operator who wants Conscrypt still configures it explicitly and still gets the
loud failure when it cannot be resolved.
DefaultBrokerTlsPolicyTest#theDefaultConfigurationPinsNoJsseProviderOnAnyPurpose locks this
down and is mutation-verified: restoring the Conscrypt default fails it.
Assisted-by: Claude Code (Opus 5)
…ts are removed The Configuration "Defaults" note still described webServiceTlsProvider and the WebSocket proxy's tlsProvider as shipping Conscrypt. Making those keys authoritative on the JSSE axis is what forced the defaults to be dropped, so state that, with the reason (x86_64-only natives) and why it is operationally a no-op (the defaults were inert under PIP-337). Add the matching entry to the behaviour-change list. Assisted-by: Claude Code (Opus 5)
TLS hostname verification is on by default in 5.0 and CN-based matching is removed, so a test server certificate that names its host only in the CN is now rejected with "No subject alternative DNS name matching localhost found". Two fixtures still had that shape: - tests/certificate-authority/ec/server.cert.pem (and the matching JKS) carried DNS:pulsar, DNS:pulsar.default, IP:127.0.0.1, IP:192.168.1.2 — no localhost — which failed TlsWithECCertificateFileTest in CI (Broker Group 2). - tests/certificate-authority/jks/broker.keystore.jks carried CN=localhost and no SAN at all. Regenerate both with a localhost SAN, and record in generate_keystore.sh why the broker keystore needs one while the client and proxy keystores deliberately do not: only the broker cert is presented as a TLS server certificate, and only server certificates are hostname-verified. These fixtures were regenerated in the branch behind apache#26271 but left out of that PR, since nothing there wires the factory in and the old certificates still passed. This is the change that makes hostname verification live, so they belong here. Assisted-by: Claude Code (Opus 5)
…ame verification is on
conf/client.conf now ships tlsEnableHostnameVerification=true (hostname verification is on by
default since 5.0), and PerformanceBaseArguments resolves that key through picocli's
descriptionKey. Every pulsar-perf invocation in a distribution therefore sees
tlsHostnameVerificationEnable=TRUE, which the V5 client-builder helper read as "the user wants
TLS" and answered by wiring a TlsPolicy — and PulsarClientBuilderV5#tlsPolicy unconditionally
flips useTls=true.
The result is a TLS handshake against a plaintext pulsar:// endpoint, which the broker closes:
WARN PulsarDecoder - TLS handshake failed ... SslHandshakeCompletionEvent(
StacklessClosedChannelException)
Suppressed: StacklessSSLHandshakeException: Connection closed while SSL/TLS handshake was
in progress
This is what failed CI - Integration - Cli (PerfToolTest.testConsume); it reproduced on both
runs of this content.
Hostname verification can no longer signal intent now that it is the default, so drop it from
the test. The remaining signals — a pulsar+ssl:// URL, an explicit trust-cert path, or
tlsAllowInsecureConnection=TRUE — all still mean what they did. The flag continues to configure
the policy once TLS is on for one of those reasons.
The decision moves into a package-private wantsTls() so it can be asserted directly;
PerfClientUtilsTest#hostnameVerificationAloneDoesNotEnableTls is mutation-verified (restoring
the hostname-verification signal fails it).
Assisted-by: Claude Code (Opus 5)
A ClusterData entry configures the broker's outbound connections to one remote cluster, and it already carries that cluster's own TLS material (brokerClientTls*). It had no way to name the PulsarTlsFactory that consumes it: factory selection was broker-level only, so a deployment could not drive connections to one remote cluster through a different factory — an HSM- or KMS-backed one, say — while the rest of the cluster used the default. Add brokerClientTlsFactoryClassName and brokerClientTlsFactoryConfig to ClusterData, and apply them to both broker-client legs a cluster entry drives: the binary-protocol replication client (configTlsSettings) and the cross-cluster admin HTTPS client (configAdminTlsSettings). They are the successors of the deprecated brokerClientSslFactoryPlugin / ...Params, whose javadoc now points at them. A blank per-cluster value inherits the broker-level brokerClientTlsFactoryClassName / brokerClientTlsFactoryConfig rather than falling back to the default factory. That differs from the TLS material in a cluster entry, which is taken wholesale, and the asymmetry is deliberate: the factory selects the mechanism that loads material rather than the material itself, so a deployment with a custom broker-client factory must not silently revert to the file-based default just because a cluster entry did not repeat the setting. It is the same reasoning that already keeps brokerClientSslProvider / brokerClientJsseProvider broker-level in these helpers — a silent downgrade of the TLS mechanism is a security regression. pulsar-admin clusters create/update gain --tls-factory-class-name and --tls-factory-config, replacing the removed PIP-337 --tls-factory-plugin flags. Both tests are mutation-verified: TestCmdClusters#testTlsFactoryOptions fails when the builder wiring is dropped, and ReplicatorTlsFactoryTest#perClusterTlsFactorySelects... covers cluster-only, broker-only and both-set, the last of which fails when precedence is inverted. Assisted-by: Claude Code (Opus 5)
…ient
Preparation for making the v5 authentication model the client's native driver. The bridge that
adapts a legacy v4 plugin into the v5 SPI lives in pulsar-client-v5, which depends on
pulsar-client — so the v4 client cannot reach it. Move it to where the v4 client can:
pulsar-client-v5 org.apache.pulsar.client.impl.v5.auth.LegacyV4AuthenticationAdapter
pulsar-client-v5 org.apache.pulsar.client.impl.v5.auth.TlsAuthentication
pulsar-client-v5 org.apache.pulsar.client.impl.v5.V5AuthenticationLoader
-> pulsar-client org.apache.pulsar.client.impl.auth.v5.*
V5AuthenticationLoader and its create(...) overloads widen to public, since callers are now in
another module. No behaviour changes.
Two adjustments the move forces:
- LegacyV4AuthenticationAdapter logged through slf4j, which is not on pulsar-client's main
compile classpath; it now uses slog (@CustomLog) like the rest of the module.
- The relocated tests used SimpleAuthInitContext / SimpleAuthCallContext, which are
package-private in pulsar-client-v5. They now build the same contexts through the public
V5AuthContexts factory. V5AuthenticationLoaderTest stays in pulsar-client-v5 because it
exercises V5ToV4AuthenticationAdapter, which does not move.
Assisted-by: Claude Code (Opus 5)
…en parameters Preparation for resolving authPluginClassName straight to a v5 plugin: the v5 token body could only be constructed from a Supplier<String>, so a client configured with authPluginClassName=...AuthenticationToken had no way to reach it by name. TokenAuthenticationV5 gains fromParams(String) and the tokenSupplier(String) parser behind it, accepting exactly the forms the v4 plugin accepted — token:<literal>, file:<uri>, a JSON object with a token member, or the bare token. The two serializable suppliers move onto it as LiteralTokenSupplier / FileTokenSupplier. The v4 AuthenticationToken now delegates its configure(String) to that parser rather than keeping a second copy, so the by-name path and the v4 shim cannot drift apart. Assisted-by: Claude Code (Opus 5)
david-streamlio
left a comment
There was a problem hiding this comment.
Reviewed the full diff against bab5c77631 (215 files) in a worktree at d4ce308dc9; ./gradlew quickCheck passes. This is an impressively disciplined migration — the offload discipline, the ctor-throw cleanup on the TLS initializers, the use-after-free pinning around newHandler/newEngine, and the generation/liveness guards in the ClientCnx state machine all read as carefully thought through. The notes below are what I found; the first three look like genuine functional regressions, the rest are consistency/robustness/doc items.
Per-cluster TLS factory selection does not reach the cross-cluster admin client
The PR describes ClusterData.brokerClientTlsFactoryClassName as selecting the factory "for both broker-client legs the entry drives — the binary replication client and the cross-cluster admin (HTTPS) client." The admin leg does not honour it.
In BrokerService.getClusterPulsarAdmin, configAdminTlsSettings(...) writes the resolved (cluster-preferred) value onto the builder's conf via adminBuilder.tlsFactoryClassName(...) / .tlsFactoryConfig(...). Then BrokerService.java:1918 calls pulsar.applyBrokerClientTlsFactoryToAdmin(builder), which at PulsarService.java:1915 is gated on — and parameterised by — the broker-level getConfiguration().getBrokerClientTlsFactoryClassName():
- broker-level set, cluster overrides it →
conf.setTlsFactory(ClientTlsFactorySupport.brokerClientTlsFactory(conf, /* broker-level */ factoryClassName))andconf.setTlsFactoryParams(parseFactoryConfig(/* broker-level */ ...)).resolveClientTlsFactoryshort-circuits on a non-nullconf.getTlsFactory(), so the per-cluster name and config thatconfigAdminTlsSettingsjust wrote are never read. The cluster override is silently dropped. - broker-level blank, cluster names a factory → the method returns early, and
AsyncHttpConnectorlater resolves by name throughresolveClientTlsFactory(conf, ..., /* brokerClientPurpose */ false). So the custom factory is not wrapped inBrokerClientPurposeFactory. By the reasoning inClientTlsFactorySupport#wrapBrokerClientPurpose, a compliant custom factory serving onlyBROKER_CLIENTthen returnsempty()for the transport'sCLIENT_DEFAULTrequest — a hard connect failure.
ReplicatorTlsFactoryTest#perClusterTlsFactorySelectsTheFactoryWhenTheBrokerLevelSettingIsUnset is a good test, but it only exercises getReplicationClient; nothing covers getClusterPulsarAdmin, which is why this is invisible in CI.
Admin-only OAuth2 loses its IdP TLS material
PulsarAdminImpl.java:466 builds the FrameworkHttpClientFactory with conf::getTlsFactory as the TLS supplier. Nothing ever sets a factory on that conf on the admin path: AsyncHttpConnector.resolveNewTlsFactory keeps its factory in its own field rather than on the conf, and it runs after auth.start() anyway. So hasTlsFactory() is false, FrameworkHttpClientFactory.configureTls takes the legacy branch, and because the purpose is CLIENT_OAUTH2 rather than CLIENT_DEFAULT it applies nothing — the IdP connection falls back to the platform default trust store.
Binding a factory at all is also what disables the fallback that would have handled this: FlowBase.resolveHttpClientFactory() only reaches StandaloneOAuth2HttpClientFactory (the one place idpTlsPolicy() is consumed) when httpClientFactory == null.
Net effect: a PulsarAdmin using OAuth2 with trustCertsFilePath / tlsCertFile / tlsKeyFile in its authParams silently stops honouring them. v4 honoured them via FlowBase.defaultHttpClient, and PulsarClientImpl explicitly preserves the behaviour through hasOAuth2IdpTlsMaterial() / foldOAuth2IdpPolicy — the admin path has no equivalent. AdminOnlyOAuth2AuthTest uses a plaintext mock IdP, and none of OAuth2IdpTlsFoldTest / OAuth2IdpTlsFrameworkClientTest / OAuth2IdpTlsPlaintextBrokerTest involves PulsarAdmin.
tlsCertFile / tlsKeyFile pairing is no longer validated
The removed FlowBase.defaultHttpClient threw on a half-configured pair:
if (hasCertFile != hasKeyFile) {
throw new IllegalArgumentException("Invalid TLS client certificate configuration: "
+ CONFIG_PARAM_CERT_FILE + " and " + CONFIG_PARAM_TLS_KEY_FILE + " must be provided together");
}FlowBase.idpTlsPolicy() (line 154) replaces it with if (isNotBlank(certFile) && isNotBlank(keyFile)), and hasOwnTlsMaterial() returns true when either is set. A config with only one of the two now yields a policy with no client identity and no error — the mTLS handshake to the IdP silently degrades instead of failing at startup. Nothing else in the tree validates the pair (TlsClientAuthFlow.fromParameters doesn't either, and TlsClientAuthFlowTest lost its factory-mocking wrapper).
tlsCertRefreshCheckDurationSec=0 now means two different things
DefaultBrokerTlsFactory.refreshIntervalSeconds maps <= 0 to 0, with a deliberate comment: "an operator who set 0 still gets no poll." The other three components do the opposite:
ProxyTlsFactories.java:144→DEFAULT_REFRESH_INTERVAL_SECONDS(60)ProxyServer.buildDefaultWebTlsFactory(websocket) → sameWorkerServer.java:392→ same
Under PIP-337 all three gated the refresh task on > 0, so 0 disabled polling everywhere. This is both a behaviour change and an inconsistency with the broker's documented handling of the same key.
Proxy startup race: listeners bind before the broker-client TLS is built
ProxyService.start() binds listenChannel and listenChannelTls and only then, at line 344, builds brokerClientTlsFactory / brokerClientSslContext / lookupClientTlsFactory. A connection accepted in that window hits either TlsContextAcquisition.withPinnedContext(service::getBrokerClientSslContext, ...) in DirectProxyHandler with a null context, or a null conf.getTlsFactory() from ProxyConnection.createClientConfiguration — the javadoc on getLookupClientTlsFactory() already acknowledges it is "transiently null while it is being built at startup". Under PIP-337 DirectProxyHandler built its factory lazily per remote host, so no such window existed. Moving the isTlsEnabledWithBroker() block above the binds closes it.
Per-cluster factory class and its config fall back independently
BrokerService.java:1756 resolveBrokerClientTlsFactory is applied separately to ...ClassName and ...Config. A ClusterData that sets only the class name therefore inherits the broker-level brokerClientTlsFactoryConfig — factory A's init params handed to factory B. Given the reasoning in the method's own javadoc (don't silently change the TLS mechanism), the pair should probably resolve atomically: if the cluster supplies a class name, its config wins even when blank.
The documented WARN is never emitted
ClusterData#getBrokerClientSslFactoryPlugin, the ClusterDataImpl @Schema descriptions, the PR body, and pip-478.md §1192 all say a stale value read from the store is "ignored with a WARN". Grepping every non-test main source for sslFactoryPlugin turns up only field declarations and comments — nothing logs. Since this is explicitly called out as the one place a stale PIP-337 value cannot fail loud, the WARN is the whole remediation signal.
pip-478.md contradicts itself and this PR
§1238 and §1241 still state that the ClusterData factory fields are removed from the metadata model along with their accessors and builder methods, that the pulsar-admin clusters options are removed, and that "per-cluster factory selection no longer exists — set the factory broker-level." §1192 and this PR do the opposite: deprecate-and-retain the old fields and add per-cluster selection plus --tls-factory-class-name / --tls-factory-config. The PIP is the normative spec and this PR already touches it, so those two sections should be updated in the same change.
Smaller items
ProxyServer.createTlsFactoryWebServer(line 239) leaks on partial init failure.WebService.createTlsFactoryWebServer,ServiceChannelInitializer.initializeTlsFactoryand the broker'sPulsarChannelInitializer.initializeTlsFactoryall wrap the post-createFactorysteps in a try/catch that disposes the subscription and closes the factory.ProxyServerdoesn't, and its caller rethrows asPulsarServerExceptionwithout ever reachingclose(), so the cert watchers survive a failed startup.- The v4 auth shims rebuild the driver per connection attempt.
AuthenticationToken,AuthenticationBasicandAuthenticationOAuth2each donew V5BinaryAuthenticationDriver(new XxxV5(...), authServices).newAuthenticationExchange(host)insidenewAuthenticationExchange. That constructs a freshAuthMetrics— two OpenTelemetry instrument builds — and re-runsensureInitialized()'sinitializeAsync().join()on every connection attempt, including reconnect storms.HttpAuthenticationDriver's own javadoc states the intended shape ("One instance is created per plugin and reused across requests"); caching the driver in a field would match it. ensureInitialized()joins on the calling thread in bothV5BinaryAuthenticationDriverandHttpAuthenticationDriver. The comments justify it by "the built-in bodies complete immediately", which holds for the built-ins, but nothing in theAuthenticationSPI javadoc makes that a contract — a third-party v5 plugin doing I/O ininitializeAsyncwould block whatever thread first opens an exchange, which is the hazard the PIP exists to remove. Worth either stating it as a hard contract oninitializeAsyncor hoisting init off the call path.LegacyV4AuthenticationAdapter.wraproutes by auth-method name. Only the literal"sasl"getsLegacyV4ChallengeResponseAdapter; every other name falls toLegacyV4CredentialAdapter, which does not exposeBinaryAuthChallengeHandler. A third-party v4 multi-round plugin under a different method name will fail the firstCommandAuthChallengewith "does not expose BinaryAuthChallengeHandler". Separately,LegacyV4TlsAdapterforwardsconfigure(...)but neverv4.start()orv4.close(), so a custom plugin reporting method name"tls"is neither started nor closed.LegacyV4CredentialAdapter.getHttpHeadersAsynccalls the deprecated no-argv4.getAuthData()even though theHttpAuthCallContextcarriesrequestUri(), dropping per-host credential selection.- Per-host client TLS material. The old client keyed a
PulsarSslFactoryper SNI host and fed itgetAuthData(host);DirectProxyHandlerdid the same per remote host. Both now share one factory/context with only aTlsEndpointhint. Is dropping per-host client identity intentional? If so it's probably worth a line in the PIP's upgrade notes. newConnectCommand()→buildConnectCommand(AuthData)silently breaks out-of-treeClientCnxsubclasses (all in-tree callers are updated). A deliberate 5.0 break, but it isn't in the PR's public-API inventory.PulsarClientProvider.authenticationTls(String, String)→authenticationTls()is a v5 public-API signature change not called out under Modifications.jsseProvideris missing fromconf/functions_worker.ymleven thoughWorkerConfiggained the field and broker/proxy/standalone/client all document it.- Deleted tests for still-present classes.
KeyStoreTlsTest,JettySslContextFactoryTestandJettySslContextFactoryWithKeyStoreTestare removed here, but the PIP-337 classes they cover (KeyStoreSSLContext, andJettySslContextFactory— that one is deleted, so its tests going is fine) remain in the tree until a later PR. Was there a reason not to keepKeyStoreTlsTestuntilKeyStoreSSLContextitself goes?
…ually take effect Review of apache#26282 found that ClusterData.brokerClientTlsFactoryClassName never reached the factory that gets built, in either direction: - With a broker-level factory configured, PulsarService.maybeApplyBrokerClientTlsFactory and applyBrokerClientTlsFactoryToAdmin both read ServiceConfiguration directly, built the broker-level factory, and left conf.tlsFactory non-null — so the cluster's selection, which BrokerService had already resolved onto the configuration, was silently discarded. - With the broker-level key blank, both methods returned early. The cluster's class name then rode the config into resolveClientTlsFactory with brokerClientPurpose=false, so the custom factory was never wrapped in BrokerClientPurposeFactory. A compliant BROKER_CLIENT-only factory answers the transport's CLIENT_DEFAULT request with empty, failing the connection. Both apply methods now read the resolved selection off the configuration the caller populated, falling back to the broker-level key, so the cluster value both wins and is purpose-wrapped. The class name and its configuration also resolve atomically now: a cluster that overrides only the class name previously inherited the broker-level brokerClientTlsFactoryConfig, handing factory A's init parameters to factory B. Also emit the WARN that ClusterData's deprecation javadoc, the @Schema descriptions and pip-478.md all promise for a stale PIP-337 brokerClientSslFactoryPlugin read from the store — nothing logged it, and it is the whole remediation signal for the one stale value that cannot fail loud. It fires once per cluster and names it. ReplicatorTlsFactoryTest now asserts a factory is actually built, not just that the name reaches the config, and covers getClusterPulsarAdmin — the leg nothing exercised, which is why this was invisible in CI. Mutation-verified: restoring either method's broker-level read fails them. Assisted-by: Claude Code (Opus 5)
…ually take effect Review of apache#26282 found that ClusterData.brokerClientTlsFactoryClassName never reached the factory that gets built, in either direction: - With a broker-level factory configured, PulsarService.maybeApplyBrokerClientTlsFactory and applyBrokerClientTlsFactoryToAdmin both read ServiceConfiguration directly, built the broker-level factory, and left conf.tlsFactory non-null — so the cluster's selection, which BrokerService had already resolved onto the configuration, was silently discarded. - With the broker-level key blank, both methods returned early. The cluster's class name then rode the config into resolveClientTlsFactory with brokerClientPurpose=false, so the custom factory was never wrapped in BrokerClientPurposeFactory. A compliant BROKER_CLIENT-only factory answers the transport's CLIENT_DEFAULT request with empty, failing the connection. Both apply methods now read the resolved selection off the configuration the caller populated, falling back to the broker-level key, so the cluster value both wins and is purpose-wrapped. The class name and its configuration also resolve atomically now: a cluster that overrides only the class name previously inherited the broker-level brokerClientTlsFactoryConfig, handing factory A's init parameters to factory B. Also emit the WARN that ClusterData's deprecation javadoc, the @Schema descriptions and pip-478.md all promise for a stale PIP-337 brokerClientSslFactoryPlugin read from the store — nothing logged it, and it is the whole remediation signal for the one stale value that cannot fail loud. It fires once per cluster and names it. ReplicatorTlsFactoryTest now asserts a factory is actually built, not just that the name reaches the config, and covers getClusterPulsarAdmin — the leg nothing exercised, which is why this was invisible in CI. Mutation-verified: restoring either method's broker-level read fails them. Two documentation fixes ride along, since they describe this same disposition: - pip-478.md contradicted itself and this PR on whether the PIP-337 ClusterData fields are removed. Five passages said removed — the breaking-change bullet, the "one silently-dropped case" bullet, the geo-replication note (which explained compatibility through Jackson lenient-dropping an unknown property, impossible for a retained field), the removal list's "nothing PIP-337 is retained (no @deprecated fields)", and the implementation-approach paragraph. All now describe the retained-and-ignored disposition, the successor fields, and the WARN that replaces lenient-dropping as the remediation signal. - conf/functions_worker.yml gains the jsseProvider documentation it was missing; WorkerConfig has the field and broker.conf, proxy.conf, standalone.conf and client.conf all document it. Assisted-by: Claude Code (Opus 5)
…rializable Moving the token suppliers onto TokenAuthenticationV5 renamed them from AuthenticationToken$SerializableTokenSupplier / $SerializableURITokenSupplier to TokenAuthenticationV5$LiteralTokenSupplier / $FileTokenSupplier. Java serialization resolves by class name, so carrying the original serialVersionUID across the move does nothing: an AuthenticationToken serialized by Pulsar 4.x fails to deserialize on 5.0 with ClassNotFoundException. The v4 Authentication interface extends Serializable, so that is a compatibility break. Nothing in-tree Java-serializes an Authentication — Functions and connectors carry authPluginClassName and authParams as strings — but the interface's Serializable contract is public, so retain the two 4.x inner classes as deserialization shims with their original UIDs. Each readResolve()s into the corresponding v5 supplier, so a restored plugin holds the new type and nothing downstream keeps the deprecated shim alive. AuthenticationTokenSerializationCompatTest reconstructs the 4.x wire form and round-trips it; mutation-verified by deleting the shim, which fails it. Assisted-by: Claude Code (Opus 5)
…P TLS material A PulsarAdmin using OAuth2 with trustCertsFilePath / tlsCertFile / tlsKeyFile in its authParams silently stopped honouring them: the IdP connection fell back to the platform default trust store with no client certificate. v4 honoured them (issue apache#24944), and PulsarClientImpl preserves the behaviour through hasOAuth2IdpTlsMaterial() / foldOAuth2IdpPolicy — the admin path had no equivalent. Two things combine to lose it. The framework HTTP client factory the admin binds reads its TLS material from conf.getTlsFactory(), and nothing on the admin path ever sets one — AsyncHttpConnector keeps the factory it resolves in its own field, and is built after auth.start() in any case — so the factory takes its legacy branch, which applies material only for CLIENT_DEFAULT while OAuth2 requests CLIENT_OAUTH2. And binding a factory at all is what disables the path that does work: FlowBase falls back to StandaloneOAuth2HttpClientFactory, the sole consumer of idpTlsPolicy(), only when no factory is bound. So for exactly this case — an OAuth2 plugin carrying IdP TLS material, with no client TLS factory available to serve it — the admin now leaves the plugin unbound and lets the flow self-provision its IdP-aware HTTP client. This also covers a plaintext http:// admin URL, where no client TLS factory is ever resolved; reordering admin construction to publish a factory would not have. The decision is a package-private static so it can be asserted directly rather than through the admin constructor. AdminOAuth2IdpTlsBindingTest covers custom trust, mTLS material, no material, and a non-OAuth2 plugin; mutation-verified by neutralising the guard. Assisted-by: Claude Code (Opus 5)
…ing its listeners ProxyService.start() bound listenChannel and listenChannelTls and only afterwards built brokerClientTlsFactory, brokerClientSslContext and lookupClientTlsFactory. A connection accepted in that window reached DirectProxyHandler with a null brokerClientSslContext, or took a null factory from ProxyConnection.createClientConfiguration. The window is new in this series: under PIP-337 the proxy held no broker-client TLS state at all (ProxyService had no SslFactory field) and DirectProxyHandler built its factory lazily per remote host, keyed in a Map<String, PulsarSslFactory>, so nothing could observe a half-built state. Master is unaffected. Move the whole isTlsEnabledWithBroker() block above the binds. serviceUrl / serviceUrlTls are computed after the binds because they need the actual bound port, so the representative ClientConfigurationData built here now sees them null — which is safe and already documented: createClientConfiguration's javadoc says that config is read only for its tls* fields, and ClientTlsFactorySupport never looks at the service URL. Noted at the call site so the coupling is not reintroduced. Also make LegacyV4TlsAdapter start and close the plugin it wraps. It forwarded configure(...) only, so a custom v4 plugin reporting the "tls" method name never had start() called — any credential loading it does there simply did not happen — and never had close() called, leaking whatever it held. The built-in AuthenticationTls has an empty start(), which is why this went unnoticed. Mutation-verified by dropping the forwarding. Assisted-by: Claude Code (Opus 5)
PIP: #25890 (
pip/pip-478.md)Motivation
This is the third implementation PR for PIP-478, following #26222 (the
pulsar-tls-factory-api/pulsar-http-client-apiSPI modules) and #26271 (the defaultFileBasedTlsFactoryimplementation).The two PRs so far were additive: the new SPI and its default implementation landed with no consumers, and PIP-337's
PulsarSslFactory/SecurityUtilitystack remained the TLS path actually in use. This PR is the migration itself — it wires the asynchronous v5 authentication interface and thePulsarTlsFactorySPI into the client, admin, broker, proxy, WebSocket proxy and functions worker, so the new SPI becomes the path that runs.That is also what turns the engine and provider selection added in #26271 into something an operator can configure end to end, and what makes PIP-478's secure-by-default posture (hostname verification on, SAN-only matching) real.
Modifications
Asynchronous v5 client authentication.
pulsar-client-api-v5gains theorg.apache.pulsar.client.api.v5.authSPI:Authentication,AuthenticationData,AuthChallenge/ChallengeResponse, the binary and HTTP challenge handlers, and the init/call contexts. Every operation that can perform I/O returns aCompletableFuture, so an authentication plugin never blocks an event-loop thread — the core motivation of the PIP.pulsar-clientimplements it inorg.apache.pulsar.client.impl.auth.v5:BinaryAuthenticationExchange(the binary-protocol challenge/response engine),HttpAuthenticationDriver+FrameworkHttpClient(the HTTP side, on thePulsarHttpClientSPI), the built-in token/basic plugins, andAuthMetrics.LegacyV4AuthenticationAdapterdrives an existing v4 plugin from the v5 engine, andV5ToV4AuthenticationAdapterlets a v5 plugin serve the v4 client. Existing third-party v4 plugins keep working unchanged.Server-side and client-side TLS factory integration.
PulsarService,BrokerService,PulsarChannelInitializerandWebServiceacquire TLS throughDefaultBrokerTlsFactory/JettyTlsFactoryinstead ofPulsarSslFactory.ProxyService,DirectProxyHandlerandAdminProxyHandler(JettySslContextFactory.Client), plusProxyTlsFactoriesfor the proxy's own purposes.ClientCnx,ConnectionPool,PulsarClientImpl,HttpClientand the admin HTTP connector.Configuration surface.
sslFactoryPlugin/sslFactoryPluginParams/brokerClientSslFactoryPlugin/brokerClientSslFactoryPluginParamsconfig keys are removed, together with theClientBuilder/PulsarAdminBuildersslFactoryPlugin(...)/sslFactoryPluginParams(...)methods and thepulsar-admin clusters --tls-factory-plugin/--tls-factory-plugin-paramsCLI options. A stale non-default value in a config file is rejected at startup with a migration pointer rather than silently ignored. TheClusterData.brokerClientSslFactoryPlugin/...Paramsaccessors are the one exception: they stay on the metadata schema for wire compatibility, now@Deprecated, and a value read from the store is ignored with a WARN rather than making a cluster unloadable.tlsFactoryClassName/tlsFactoryConfig(andbrokerClientTlsFactoryClassName/brokerClientTlsFactoryConfig) select aPulsarTlsFactoryby name, on the server configs and on the v4 client and admin builders.jsseProviderkey (client, broker, proxy, and thebrokerClient*variants) names the JSSE (SSLContext)java.security.Provider.tlsProviderstays overloaded for v4 parity: a value that is not a Netty engine literal is routed tojsseProvider, which wins when both are set.ClusterDatagainsbrokerClientTlsFactoryClassName/brokerClientTlsFactoryConfig, so a deployment can drive connections to one remote cluster through its own factory. A cluster entry already carries that cluster's TLS material; these select the factory that consumes it, for both broker-client legs the entry drives — the binary replication client and the cross-cluster admin (HTTPS) client. A blank value inherits the broker-level setting rather than reverting to the default factory: the factory selects the mechanism rather than the material, so a deployment with a custom broker-client factory must not silently downgrade because a cluster entry did not repeat it.pulsar-admin clusters create|updateexpose them as--tls-factory-class-name/--tls-factory-config.Default value changes (all called out in the PIP's Upgrade section):
tlsHostnameVerificationEnabled(broker/proxy/websocket),tlsEnableHostnameVerification(client),tlsEnableHostnameVerification(functions worker) — and CN-based matching is gone, so a server certificate must carry a matching SubjectAltName. This is the secure-by-default hardening PIP-478 exists to deliver; the remediation (reissue certificates with a SAN, or opt out per component) is documented in the PIP.webServiceTlsProvider(broker and proxy) and the WebSocket proxy'stlsProviderno longer default toConscrypt. Under PIP-337 that default only reached Jetty'sSslContextFactory.setProvider(...), which is inert on a factory that overridesgetSslContext()with a pre-built context — so it never actually selected a provider. This PR makes those keys authoritative on the JSSE axis, andconscrypt-openjdk-uberships native libraries for x86_64 only, so keeping the default would fail the web listener at startup on aarch64 (Apple silicon, ARM servers) and s390x. Unset selects the JVM default, which is what deployments have effectively been running; an operator who wants Conscrypt configures it explicitly and still gets a loud failure where it cannot be loaded.Test fixtures.
tests/certificate-authority/ec/server.cert.pemandjks/broker.keystore.jksare regenerated with alocalhostSubjectAltName — they previously identified the host only through the CN, which SAN-only verification rejects.generate_keystore.shrecords why only the broker keystore needs a SAN (the client and proxy keystores are client identities and are never hostname-verified).Not in this PR. The PIP-337 classes themselves (
PulsarSslFactory,DefaultPulsarSslFactory,SecurityUtility,KeyStoreSSLContext) still exist, now unused by production code; deleting them, along with migrating the Athenz and SASL plugins to the v5 SPI, are the remaining PRs in the series.Review follow-ups (since the first review)
The review on this PR found three functional regressions and a set of consistency items. All confirmed ones are fixed here, each mutation-verified:
BrokerServiceresolved the cluster's choice onto the configuration, but bothPulsarServiceapply methods readServiceConfigurationdirectly — so a broker-level factory silently discarded the cluster override, and a blank broker-level key left the custom factory unwrapped, making a compliantBROKER_CLIENT-only factory answerCLIENT_DEFAULTwith empty and fail the connection. The class name and its config now also resolve atomically.PulsarAdmindropped an OAuth2 plugin's IdP TLS material (regression vs v4, [feat][client] oauth2 trustcerts file and timeouts #24944). The framework HTTP client factory readsconf.getTlsFactory(), which the admin path never sets, and binding it disables theStandaloneOAuth2HttpClientFactoryfallback that honoursidpTlsPolicy(). Such a plugin is now left unbound — which also covers a plaintexthttp://admin URL.SslContext/factory. Master is unaffected: it held no broker-client TLS state and built per remote host lazily.AuthenticationTokenstayed deserializable (the supplier move renamed classes, which Java serialization resolves by name).LegacyV4TlsAdapternow starts and closes the v4 plugin it wraps.brokerClientSslFactoryPlugin; fivepip-478.mdpassages that still claimed theClusterDatafields were removed; andjsseProviderdocumented inconf/functions_worker.yml.One reported item is not a defect:
HttpAuthenticationDriver/AsyncHttpAuthenticationProviderlook unused because their implementor is the next PR in the series —AuthenticationSaslimplements the provider andSaslAuthenticationV5implementsHttpAuthChallengeHandler, which the driver consumes.Verifying this change
This change added tests and can be verified as follows:
ProxyTlsFactoriesTest,ProxyTlsFactoryMetricsTest,DefaultBrokerTlsPolicyTest,TlsFactorySupportTest).DefaultBrokerTlsPolicyTest#theDefaultConfigurationPinsNoJsseProviderOnAnyPurposelocks down the provider-default change;PerfClientUtilsTest#hostnameVerificationAloneDoesNotEnableTlslocks down that hostname verification being on by default does not imply TLS intent. Both were verified to fail against the unfixed code.TestCmdClusters#testTlsFactoryOptionsandReplicatorTlsFactoryTest#perClusterTlsFactorySelectsTheFactoryWhenTheBrokerLevelSettingIsUnsetcover the per-cluster factory: CLI parsing through to theClusterDatasent to the admin API, and the resolution rule across cluster-only, broker-only and both-set. Both were verified to fail against the unfixed code — the both-set case is what catches inverted precedence.TlsWithECCertificateFileTest, the keystore-TLS tests,TlsProducerConsumerTest,AdminApiTlsAuthTest,ReplicatorTlsTestand the proxy authentication tests.:pulsar-common:test :pulsar-broker-common:test :pulsar-client-original:test :pulsar-client-admin-original:test :pulsar-client-tools:test :pulsar-testclient:test :pulsar-websocket:test :pulsar-client-v5:test :pulsar-functions:pulsar-functions-worker:test— 2726 tests, 0 failures.assemble rat spotlessCheck checkstyleMain checkstyleTest checkBinaryLicensepasses.Does this pull request potentially affect one of the following parts:
If the box was checked, please highlight the changes
org.apache.pulsar.client.api.v5.authSPI, and addsbrokerClientTlsFactoryClassName/brokerClientTlsFactoryConfigtoClusterData(accessors plus builder methods); removes the v4ClientBuilder.sslFactoryPlugin(...)/sslFactoryPluginParams(...)and thePulsarAdminBuilderequivalents (a source-compatibility break on upgrade, inventoried in the PIP). TheClusterDatafactory-plugin accessors are deprecated and ignored rather than removed, so no existing metadata field changes meaning.webServiceTlsProvider/ the websockettlsProviderno longer default toConscrypt. Both are detailed under Modifications above and in the PIP's Upgrade section.pulsar-admin clusters create|updatelose the PIP-337--tls-factory-plugin/--tls-factory-plugin-paramsand gain--tls-factory-class-name/--tls-factory-config, which write the new per-clusterClusterDatafields.Documentation
doc-required— the configuration surface changes (newtlsFactoryClassName/tlsFactoryConfig/jsseProviderkeys, removedsslFactoryPlugin*keys) and the secure-by-default hostname-verification behaviour need to be reflected in the security documentation on the website.doc-not-neededdocdoc-completeMatching PR in forked repository
PR in forked repository: lhotari#249 (Personal CI — full matrix green)
This PR was prepared with the assistance of Claude Code (Opus 5); the change was reviewed and is submitted by a human contributor who takes responsibility for it, per the ASF Generative Tooling guidance.