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

@@ -35,6 +35,18 @@ services:
# default and enabled from the Admin console; this is the only email
# secret and it is never stored in the DB. Leave unset until configured.
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
# D13 — domain authentication. REQUIRED: the suite stores no passwords and
# has no local fallback, so a wrong value here means nobody can sign in.
# Connect to the DOMAIN NAME, never a DC or an IP (SAN + DNS round-robin
# across six DCs). See server/ldap_auth.py and DEPLOYMENT.md.
LDAP_DOMAIN: ${LDAP_DOMAIN:-prime.local}
LDAP_HOST: ${LDAP_HOST:-prime.local}
# Trust anchor for the DC certificate — public CA certs, baked into the image
# at server/certs/. Override only to point at a mounted bundle.
LDAP_CA_FILE: ${LDAP_CA_FILE:-/app/server/certs/prime-ca-chain.pem}
# AD group required to sign in. Empty = any domain account. This is the
# initial value; the live one is set in the Admin console (T10.5).
LDAP_REQUIRED_GROUP: ${LDAP_REQUIRED_GROUP:-}
# Optional — read-only SQL Server connection to the Micron asset catalog,
# which backs the asset picker in the work package creator. Leave unset and
# the picker cleanly falls back to manual entry (see server/assets_db.py).
@@ -46,10 +58,15 @@ services:
condition: service_healthy # waits for postgres to accept connections
networks:
- internal
# Reaching the Micron database means leaving this compose project, and
# `internal` is deliberately egress-free. `outbound` is attached to the api
# container ONLY — the database and backup containers stay sealed. Detach it
# again if you are not using the Micron asset picker.
# Reaching the Micron database — and, since D13, the domain controllers —
# means leaving this compose project, and `internal` is deliberately
# egress-free. `outbound` is attached to the api container ONLY; the database
# and backup containers stay sealed.
#
# DO NOT DETACH THIS. It used to be optional ("detach it if you are not using
# the Micron asset picker"), but authentication now needs a route to
# prime.local:636. Without it every sign-in fails and there is no local
# password fallback to fall back to.
- outbound
db:
@@ -113,7 +130,7 @@ networks:
# An ordinary bridge network, i.e. one that HAS a default gateway. `internal`
# above removes the gateway entirely, which blocks not just the internet but
# the LAN and the VPN too — so the api container needs this second network to
# reach the Micron asset database. Attached to `api` alone: `db` and `backup`
# remain on `internal` only and still have no way off the host.
# Detach it from api if you are not using the Micron asset picker.
# reach the domain controllers (LDAPS, D13) and the Micron asset database.
# Attached to `api` alone: `db` and `backup` remain on `internal` only and
# still have no way off the host. Required — see the note on the api service.
driver: bridge