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>
116 lines
4.3 KiB
Python
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()
|