GitHub user moelhoussein edited a discussion: [KPIP] LDAP Authentication —
UnboundID-backed pool with failover, request coalescing, and observability
## Abstract
This proposal replaces Kyuubi's JNDI-based LDAP authentication path with an
**UnboundID LDAP SDK** stack that adds (1) ordered server failover that covers
both bind and search operations, (2) a persistent pre-bound connection pool,
(3) result-code-driven failure classification, (4) request coalescing with a
short-TTL cache, and (5) a first-class metrics surface for both auth outcomes
and pool health. Existing config keys, providers, and fallback to ephemeral
connections are preserved.
---
## Q1. What are you trying to do?
Make Kyuubi's LDAP login path:
1. **Resilient** — if one of the configured LDAP servers goes offline, users do
not see auth failures. This must hold even when a server fails *during* an
in-progress request, not only at the moment a new connection is opened.
2. **Fast under load** — keep a small set of authenticated connections open and
reuse them, instead of opening a brand-new network connection for every single
login.
3. **Observable** — when something goes wrong, an operator can tell within
seconds whether it is "users typed the wrong password" or "the directory is
sick," without grepping logs.
4. **Honest** — different failure causes produce different, machine-readable
signals, instead of every problem looking like the same generic "access denied."
## Q2. How is it done today, and what are the limits of current practice?
Kyuubi's `LdapAuthenticationProviderImpl` uses the JDK's JNDI
(`com.sun.jndi.ldap.*`) opened via `InitialDirContext` per request.
| Limitation | Impact |
|---|---|
| JNDI's URL-list failover applies **only at initial context creation**
(bind/connect). If a SEARCH on an established context fails mid-flight, JNDI
does not retry against the next URL — the operation surfaces as
`CommunicationException` and the user sees a 403. | Server failures that occur
**after** the initial bind (mid-search server restart, dropped socket,
idle-connection reset by an intermediary) are not recovered, even though
another configured server is available. |
| No connection reuse — fresh TCP+TLS+BIND+SEARCH per request | Bursts hit
directory connection-rate limits → cascading auth failures |
| Error opacity — every directory condition surfaces as `NamingException` with
free-text | Wrong-password, refused-connection, TLS-handshake, timeout all
indistinguishable |
| No metrics — zero counters or gauges for LDAP outcomes or pool health |
Operators learn of outages from user tickets |
| No coalescing — N identical concurrent logins → N backend binds | Login-storm
amplification against the directory |
| No success caching — every JDBC reconnect re-binds | Steady-state directory
QPS = session-open QPS |
The first row is the most important: deployments that put a load balancer or
hostname-fronted cluster in front of multiple LDAP servers expect "configure
two URLs, get full failover." With JNDI today, that expectation only holds for
the bind step. A SEARCH that fails mid-operation is not retried elsewhere.
## Q3. What is new in your approach, and why do you think it will be successful?
### Core innovation: UnboundID SDK pool with classifier-aware coalescing cache
```
┌──────────────────────────────────────────────────────────┐
│ AuthenticationProviderFactory (recorder + lifecycle) │
└────────────────────────────┬─────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────┐
│ CachingLdapAuthenticationProvider │
│ Guava Cache<HMAC-SHA256(user+pw), Boolean> │
│ wasLeader flag → correct metric attribution for waiters │
└────────────────────────────┬─────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────┐
│ LdapAuthenticationProviderImpl / UnboundIdDirSearchFactory│
│ LdapAuthFailureClassifier → {invalid_credentials, │
│ invalid_input, │
│ infrastructure} │
└────────────────────────────┬─────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────┐
│ UnboundIdConnectionPool │
│ FailoverServerSet([url1, url2, …]) (ordered failover) │
│ LDAPConnectionPool(min, max) (pre-bound, autoReconnect)│
│ LoggingHealthCheck (WARN on connection discard) │
└──────────────────────────────────────────────────────────┘
```
### Key design decisions
**1. `FailoverServerSet` over JNDI URL-list — failover covers bind *and*
subsequent operations.** The pool obtains a connection through
`FailoverServerSet`, which advances to the next URL on any connection-level
failure. Because all auth work (bind + search) runs against a pooled connection
that is health-checked and replaced on failure, a server that goes down
mid-search results in the next checkout (or the same caller's retry through the
SDK's `autoReconnect`) landing on a different server. This closes the JNDI gap
where only the initial bind benefited from failover.
**2. Pre-bound pool with background health check.** All connections bound as
the service account at creation; per-request work is a single `bindRequest`
against a warm socket. Background thread validates idle connections against the
root DSE. `autoReconnect=true` so a server bounce does not drain the pool.
**3. Classifier-driven failure metrics.** `LDAPException.getResultCode()` is
mapped to exactly one of three stable buckets:
| Bucket | Triggered by | Who pages |
|---|---|---|
| `invalid_credentials` | `ResultCode.INVALID_CREDENTIALS` | Nobody — user
typed wrong password |
| `invalid_input` | Null/empty user or password, malformed DN | App team —
caller-side bug |
| `infrastructure` | Connect refused, TLS, timeout, server unavailable | **LDAP
/ directory on-call** |
**4. Request-coalescing TTL cache.** Guava `Cache.get(key, Callable)` collapses
N concurrent identical authentications into one backend bind. Leader executes;
waiters block on the `LoadingValueReference`. A `wasLeader` flag ensures each
coalesced waiter increments the correct success/failure counter — the easy bug
to ship without it would be a silent metric undercount on the waiter path.
**5. Lifecycle ordering invariant.** `MetricsSystem` is registered before
frontends in `KyuubiServer.initialize`. `CompositeService.stop` tears down in
reverse order, so frontends stop accepting auth requests before `MetricsSystem`
(and the pool) close. No race where the auth path increments a closed counter.
**6. Fallback parity.** If `bind.user` / `bind.password` are not configured,
the pool is not instantiated; the code falls back to ephemeral per-request
connections — behavioral parity with the JNDI path for the no-bind-user case.
### Why it will be successful
- **Drop-in replacement.** Same config namespace
(`kyuubi.authentication.ldap.*`), same provider hook; existing deployments work
without new config.
- **Battle-tested SDK.** UnboundID LDAP SDK is Apache-2.0 and widely deployed;
pool, failover, and health-check semantics are not reimplemented in tree.
- **Failure path is instrumented, not handwaved.** The classifier, the
per-bucket counters, and the connection-discard log line are specified before
implementation — the gap that motivates the proposal is the gap the design
measurably closes.
## Q4. Who cares? If you are successful, what difference will it make?
| Stakeholder | Before | After |
|---|---|---|
| End user | Auth failure on any LDAP hiccup; opaque error | Survives
single-server outages transparently, including failures mid-request |
| Kyuubi operator | No LDAP signal in metrics; relies on log grep |
Three-bucket failure counter + pool gauges; on-call can triage from a dashboard
in seconds instead of greping for stack traces |
| Directory / IAM team | Blamed for every auth failure, often wrongly | The
`infrastructure` counter is the only signal that should ever page them |
| Directory itself | N binds per user burst, full reconnect each | One bind per
coalesced burst; warm connections only |
| Postmortem authors | Outage scope inferable only from ticket volume | Pre /
during / after windows visible in Prometheus, with clear attribution |
Headline outcomes: **≥ 70% reduction in backend bind volume** at the directory
under steady-state load, **zero user-visible auth failures from any single LDAP
server outage** (including mid-request server loss), and incident-response time
bounded by dashboard read latency rather than log-grep cycle time.
## Q5. How will you measure success?
These are the implementation-level bars the code must clear before the feature
ships. Targets are verifiable from tests or short-run benchmarks — not from
long-term ops outcomes (which appear in Q4).
| Measurement | Target | How to verify |
|---|---|---|
| **Failure classification correctness** | 100% of injected `LDAPException`
result codes land in the correct bucket (`invalid_credentials` /
`invalid_input` / `infrastructure`) | Parameterized unit test per `ResultCode`
value |
| **Coalescing correctness** | N concurrent identical authentications → exactly
1 backend bind, and exactly N correctly-attributed metric increments (no waiter
under-count on success or failure) | Concurrency test using a
barrier-synchronized executor |
| **Failover correctness — initial connect** | With the first configured server
unreachable, the pool establishes connections against the next server with no
caller-visible failure | Integration test against an embedded directory pair |
| **Failover correctness — mid-request** | Connection drop *during* an
authenticated SEARCH does not surface as a user-facing failure; the pool
retries against another server | Integration test that closes the backing
socket mid-operation |
| **Pool reuse under load** | After warmup, a 1000-request burst opens ≤
`max.connections` sockets to the directory (not 1000) | Counter on the test
directory's connect handler |
| **Lifecycle safety** | No metric increment after `MetricsSystem.close()`; no
auth attempt after `pool.close()` | Lifecycle test asserting ordering invariant
|
| **Behavioral parity in the no-bind-user fallback** | When `bind.user` is
unset, the existing `LdapAuthenticationProviderImpl` test suite passes against
the new ephemeral-connection code path (same accept/reject decisions as JNDI) |
Re-run the existing suite, no test changes allowed |
| **Non-LDAP auth regression** | NONE / KERBEROS / CUSTOM / JDBC providers: 0
test failures | Existing auth provider suites |
| **Cache hit ratio under realistic burst** | ≥ 50% on the documented
burst-load benchmark | Benchmark suite added with the patch |
| **Backend bind reduction under realistic burst** | ≥ 70% vs. JNDI baseline on
the same benchmark | Same benchmark, comparing branch vs. baseline |
## Q6. What are the mid-term and final "exams"?
Phase-gate milestones. Each phase ships independently and is gated on a single
concrete demonstration.
### Phase 1: Pool + failover + classifier (mid-term)
- [ ] `UnboundIdConnectionPool` with `FailoverServerSet` instantiated on first
auth
- [ ] `LdapAuthFailureClassifier` + three failure-bucket counters
- [ ] Pool gauges (`numAvailable`, `numFailed/SuccessfulCheckouts`,
`numConnectionsClosed{Defunct,Expired}`)
- [ ] Fallback to ephemeral connections when no bind user is configured
- [ ] `LoggingHealthCheck` WARN on connection discard
**Exam:** with two LDAP servers configured and the first one killed
mid-request, no user sees an auth failure, and the `infrastructure` counter
increments for the failed connection.
### Phase 2: Coalescing cache + benchmark (final)
- [ ] `CachingLdapAuthenticationProvider` with HMAC-keyed Guava cache
- [ ] Correct metric attribution for coalesced waiters (leader / waiter
concurrency test)
- [ ] Burst-load benchmark added to the test suite
- [ ] User-facing documentation for all new config keys
- [ ] Operator runbook for interpreting the new metrics
**Exam:** the burst-load benchmark shows ≥ 70% reduction in backend bind QPS
vs. the JNDI baseline at the same user-session QPS, and the coalescing
concurrency test passes 100 consecutive runs without flaking.
## Q7. What are the risks?
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Cache TTL extends window for revoked credentials | Medium | Medium | Short
default TTL; documented; configurable to 0 |
| Pool starvation under sustained burst | Low | Medium | `checkoutTimeoutMs`
configurable; `numFailedCheckouts` alertable |
| Behavior delta vs. JNDI on edge-case directories | Medium | Low | Ephemeral
fallback path preserves legacy parity |
| Failed / unreachable server in the failover list is operationally silent — no
log or metric attributes the failure to a specific URL | Medium | Low |
Acknowledged gap — `DisconnectHandler` is follow-up (see Rejected Alternatives)
|
| New UnboundID SDK dependency surface | Low | Low | Apache-2.0; well-isolated;
no new transitive deps beyond the SDK jar |
| Lifecycle regression (closing pool with traffic in flight) | Low | High |
Ordering invariant documented + asserted by integration test |
---
## Implementation Roadmap
| Phase | Scope | Estimated effort |
|---|---|---|
| Phase 0 | Community discussion + design review | This KPIP |
| Phase 1 | UnboundID pool + `FailoverServerSet` + ephemeral fallback | |
| Phase 2 | Failure classifier + auth-outcome counters + pool gauges | |
| Phase 3 | Coalescing cache + correct waiter attribution + concurrency tests |
|
| Phase 4 | Docs, operator runbook, dashboard | |
| Phase 5 | Staging soak ||
---
## Configuration surface
All new keys default to safe values; existing deployments work without changes.
| Key | Type | Default | Purpose |
|---|---|---|---|
| `kyuubi.authentication.ldap.pool.min.connections` | int | `1` | Warm-pool
lower bound |
| `kyuubi.authentication.ldap.pool.max.connections` | int | `10` | Upper bound;
checkout fails if exhausted |
| `kyuubi.authentication.ldap.pool.health.check.interval` | duration | `60s` |
Root-DSE validation cadence |
| `kyuubi.authentication.ldap.pool.checkout.timeout` | duration | `5s` | Max
wait for a connection |
| `kyuubi.authentication.ldap.cache.enabled` | bool | `true` | Cache master
switch |
| `kyuubi.authentication.ldap.cache.ttl` | duration | `5m` | Reuse window |
| `kyuubi.authentication.ldap.cache.max.size` | int | `10000` | Entry cap (LRU
eviction) |
## Metrics surface
Counters (Prometheus `_total` via `DropwizardExports`):
- `authentication.success.ldap`
- `authentication.failure.ldap.{invalid_credentials, invalid_input,
infrastructure}`
- `authentication.cache.hit.ldap` / `authentication.cache.miss.ldap`
Gauges (live from `LDAPConnectionPoolStatistics`):
- `numAvailableConnections`, `numFailedCheckouts`, `numSuccessfulCheckouts`
- `numConnectionsClosedDefunct`, `numConnectionsClosedExpired`
---
## Rejected Alternatives
### Alternative 1: Stay on JNDI, add a retry decorator
Does not address the core gap — JNDI fails over only on the initial bind, not
on operations against an already-established context. Retrying at the call-site
level either reopens the bind (full TCP+TLS+BIND cost on the failure path) or
re-uses the same broken context. Retries on `INVALID_CREDENTIALS` are actively
harmful (account lockout). Does not enable pooling without re-implementing SDK
pool semantics in tree.
### Alternative 2: Apache Directory `ldap-client-api`
Smaller deployed footprint, less battle-tested pool implementation, no
equivalent of `FailoverServerSet`'s ordered-failover semantic at the
`ServerSet` layer.
### Alternative 3: Per-server failure attribution via wrapping
`FailoverServerSet`
Deferred, not rejected. The SDK does not expose per-server failure events on
the public `ServerSet` API; getting them requires either a `DisconnectHandler`
(works only for already-established connections) or wrapping every
`getConnection` call. Tracked as follow-up.
### Alternative 4: Cache successful auths only, not failures
Defeats the coalescing benefit on the case that hurts most — credential
stuffing or thundering-herd of wrong passwords. Short TTL bounds the cost.
### Alternative 5: Cache keyed by user only with a separate "verified" map of
passwords
Would require holding password material in memory beyond the bind boundary.
HMAC-keyed boolean cache is strictly weaker on attack surface.
EDIT: Reference Implementation in this PR: #7506
---
## References
- [UnboundID LDAP SDK](https://ldap.com/unboundid-ldap-sdk-for-java/) —
Apache-2.0
GitHub link: https://github.com/apache/kyuubi/discussions/7497
----
This is an automatically sent email for [email protected].
To unsubscribe, please send an email to:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]