Files
mgmt/ldap-gateway/ldap_gateway/server.py
scott f4bd03dc52 Add read-only LDAP gateway microservice backed by Keycloak
New ldap-gateway/ service (Twisted + ldaptor) that exposes Keycloak users
and groups over LDAP v3 for legacy apps and Linux hosts (SSSD/NSS).

Design (see ldap-gateway/SPEC.md):
- Reads directly from the KC Admin API with an in-memory TTL cache +
  background refresh; fully stateless, no DB or queue.
- Service-bind read-only: only configured service accounts may bind; no
  end-user password auth. Writes return unwillingToPerform.
- Serves POSIX + inetOrgPerson entries (uid/gid/home, memberOf, group
  memberUid/member). UID/GID from a KC custom attribute else derived from
  the stable KC UUID.
- Pluggable IdentityResolver (LDAP_IDENTITY_SOURCE): username today, a
  cert_cn strategy stubbed for the future cert-CN-as-identity direction.

Build/deploy:
- build.sh builds and (optionally) pushes mgmt-ldap-gateway alongside
  backend/frontend.
- Helm: ldap-gateway deployment/service/configmap, gated by
  ldapGateway.enabled (off by default), reusing the shared KC secret.

Verified end to end against a live Keycloak: service bind, user/group
search, anonymous + bad-password denial, write rejection; container image
builds, runs as non-root, warms from KC, and serves searches. 125 tests
pass; ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:37:20 -07:00

116 lines
4.3 KiB
Python

"""Process entry point: wire the components together and run the Twisted reactor.
Composition (see ``CONTRACT.md`` "server.py" and ``SPEC.md`` §3/§10):
* load config -> identity resolver -> KC client -> cache -> directory holder
* perform an initial (best-effort) cache warm and swap it into the holder
* stand up the LDAP listener (plain/StartTLS) and, when TLS material is
configured, the LDAPS listener
* schedule a background ``LoopingCall`` that refreshes the cache off the
reactor thread (``deferToThread``) and atomically swaps the new snapshot in
* run the reactor
The server is deliberately resilient at startup: if the initial Keycloak warm
fails, we log and continue serving the empty-but-valid tree so the process
still becomes reachable (readiness can gate on the first successful warm).
"""
from __future__ import annotations
import logging
import os
from twisted.internet import reactor, ssl
from twisted.internet.task import LoopingCall
from twisted.internet.threads import deferToThread
from .cache import DirectorySnapshot, KeycloakCache
from .config import load_config
from .directory import DirectoryHolder
from .handlers import LDAPServerFactory
from .identity import get_identity_resolver
from .kc_client import KeycloakClient
logger = logging.getLogger(__name__)
def _configure_logging() -> None:
"""Configure root logging from the ``LOG_LEVEL`` env var (default ``INFO``)."""
level_name = os.environ.get("LOG_LEVEL", "INFO").strip().upper() or "INFO"
level = getattr(logging, level_name, logging.INFO)
logging.basicConfig(
level=level,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
def _log_refresh_error(failure: object) -> None:
"""Errback for the background refresh: log and swallow so the loop continues.
A failed background refresh must never tear down the ``LoopingCall`` or the
reactor — the cache keeps serving the last-good snapshot until the next tick.
"""
logger.error("Background Keycloak refresh failed: %s", failure)
def main() -> None:
"""Build the gateway, register listeners + refresh loop, and run the reactor."""
_configure_logging()
config = load_config()
resolver = get_identity_resolver(config)
client = KeycloakClient(config)
cache = KeycloakCache(client, config)
holder = DirectoryHolder(config, resolver)
# Initial warm. Best-effort: on failure the holder keeps its empty-but-valid
# tree and the server still starts (searches return zero results until a
# later refresh succeeds).
try:
snapshot: DirectorySnapshot = cache.refresh()
holder.swap(snapshot)
logger.info("Initial Keycloak cache warm succeeded")
except Exception: # noqa: BLE001 - never let a warm failure stop startup
logger.error(
"Initial Keycloak cache warm failed; starting with empty directory tree",
exc_info=True,
)
factory = LDAPServerFactory(holder, config)
# Plain/StartTLS listener. StartTLS is negotiated on the plain port by the
# LDAP server protocol; we always stand this up.
reactor.listenTCP(config.port, factory, interface=config.bind_host)
logger.info("Listening for LDAP (plain/StartTLS) on %s:%d", config.bind_host, config.port)
# LDAPS listener, only when both cert and key are configured.
if config.tls_cert and config.tls_key:
context_factory = ssl.DefaultOpenSSLContextFactory(config.tls_key, config.tls_cert)
reactor.listenSSL(
config.ldaps_port,
factory,
context_factory,
interface=config.bind_host,
)
logger.info("Listening for LDAPS on %s:%d", config.bind_host, config.ldaps_port)
else:
logger.info("LDAPS disabled (LDAP_TLS_CERT/LDAP_TLS_KEY not both set); plain/StartTLS only")
# Background refresh loop: run the blocking cache fetch in a worker thread,
# then swap the resulting snapshot into the holder on the reactor thread.
refresh_loop = LoopingCall(
lambda: deferToThread(cache.refresh).addCallbacks(holder.swap, _log_refresh_error)
)
refresh_loop.start(config.refresh_interval, now=False)
logger.info(
"Scheduled background Keycloak refresh every %d seconds",
config.refresh_interval,
)
logger.info("Starting reactor")
reactor.run()
if __name__ == "__main__":
main()