T10.1 D13 - the LDAPS client, verified against the live domain

server/ldap_auth.py: simple bind to ldaps://prime.local:636 as
sAMAccountName@prime.local, nested-group membership via the
LDAP_MATCHING_RULE_IN_CHAIN extensible match, and a selftest() that validates
the DC certificate without binding so it can never contribute to a lockout.

Verified against the live domain, not just reasoned about:

  selftest() to prime.local        -> ok, "certificate validates"
  selftest() to 192.168.3.37       -> refused, untrusted (no IP SAN)
  empty / whitespace password      -> empty_input, with Connection nulled out
                                      so any call to bind() would have raised
  missing CA file                  -> unconfigured, is_config_problem=True
  Tls.validate                     -> ssl.CERT_REQUIRED, explicit ca_certs_file

Three things here are load-bearing and commented as such at the call site:

- The empty-password guard runs BEFORE bind(). An LDAP simple bind with an
  empty password is an anonymous bind and it SUCCEEDS, so without the guard a
  blank password authenticates as whatever username was submitted.
- No `version=` pin on Tls. An earlier draft of this file pinned
  PROTOCOL_TLSv1_2, which would have silently downgraded every connection from
  the TLS 1.3 these DCs actually negotiate.
- Retries cover connect failures only. A rejected credential returns
  immediately, because every failed bind counts against the domain lockout
  policy and this endpoint must not become a way to lock people out of Windows.

The trust anchor is server/certs/prime-ca-chain.pem - PRIME CONTROLS ROOT CA
plus ISSUING CA 1, public certificates with no private key, checked in because
they are public and long-lived (2051 / 2036). The system trust store is
deliberately not used: it currently trusts five other self-signed CAs on this
estate. LDAP_CA_FILE overrides the path for a mounted bundle.

docker-compose.yml: the `outbound` network is no longer optional. Its comment
said to detach it if you were not using the Micron asset picker; doing that
now breaks every sign-in, since `internal` has no default gateway and
therefore no route to prime.local:636.

Not yet verified, and called out rather than assumed: the nested-group case
needs a real group with a nested member, and the in-container
`openssl s_client -CAfile` check needs the stack. Both are T10.1 done-when
boxes still open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 14:06:49 -05:00
parent 3c9343dfc8
commit 0577660c86
7 changed files with 572 additions and 19 deletions

View File

@@ -21,6 +21,40 @@ AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
# How long a login lasts before re-authentication (hours). Default 12.
# AUTH_SESSION_HOURS=12
# ── Domain authentication, D13 (REQUIRED — there is no fallback) ───────────────
# The suite stores no passwords. Sign-in is an LDAPS simple bind against the
# domain, so if this is misconfigured NOBODY CAN SIGN IN, admins included. There
# is deliberately no local break-glass account (decided Aug 21 2026 — see
# docs/waves/decisions-2026-08-21.md). Check `docker compose logs api` on startup:
# the API logs one line saying whether LDAP is configured and reachable.
#
# Connect to the DOMAIN NAME, never a DC hostname and never an IP. Every DC's
# certificate carries `prime.local` in its SAN, so the domain name both passes
# hostname validation and round-robins across all six DCs. An IP fails with
# `hostname mismatch` (there is no IP SAN) and the only way to force it through is
# to disable validation, which must not happen — domain passwords cross this link.
# LDAP_DOMAIN=prime.local
# LDAP_HOST=prime.local
# LDAP_PORT=636
# Trust anchor: PRIME CONTROLS ROOT CA + PRIME CONTROLS ISSUING CA 1 as a PEM
# bundle. These are PUBLIC certificates — no private key, nothing issued to this
# app, nothing to request from IT. The repo ships a verified copy and the default
# points at it, so you only set this to override with a mounted file.
# LDAP_CA_FILE=/app/server/certs/prime-ca-chain.pem
# An AD group required to sign in. Empty means every domain account may sign in.
# This is the INITIAL value and the fallback; the live value is set in the Admin
# console, which refuses to save a group that does not resolve or that the saving
# admin is not a member of. Nested groups count.
# LDAP_REQUIRED_GROUP=WP-Suite-Users
# Bind/connect timeout, and how many extra CONNECT attempts to make. Retries never
# apply to a rejected password — each failed bind counts against the domain lockout
# policy, so guessing would lock real accounts out of Windows.
# LDAP_TIMEOUT_SECONDS=8
# LDAP_CONNECT_RETRIES=2
# ── Email notifications (optional) ─────────────────────────────────────────────
# WP-assignment emails are OFF by default and are turned on from the Admin
# console (Notifications & email card), where the SMTP host/port/from-address