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

@@ -107,17 +107,45 @@ These are the ways this change goes wrong, and each has a done-when check in wav
`532` password expired, `533` disabled, `775` locked) are useful in the log and must not
reach the response body.
### Open, to confirm in the PR rather than decide alone
### Answered August 21, 2026 — both were raised as open and both were decided
- **Break-glass.** Removing `password_hash` means an unreachable DC locks out *everyone*,
admins included. See `T10.2`.
- **Username ↔ `sAMAccountName` mapping.** Existing accounts were created by
`manage_users.py` with hand-typed usernames. Criterion 4 holds only where those match the
directory's `sAMAccountName`. The production account list must be checked against AD
before this deploys; a mismatch means an existing admin gets a *second*, JIT-provisioned
account at the default role instead of keeping their admin. `auth.find_user` already
matches on username **or** email case-insensitively, which covers some of the gap but not
all of it.
**Break-glass: none. LDAPS is the only way in.** Asked and reaffirmed after the lockout risk
was stated. There is no emergency local account, no env-var bypass, and no CLI-minted
session. The consequence is explicit and belongs in the runbook rather than being discovered:
**if the domain is unreachable, or `LDAP_CA_FILE` is wrong, or the required group is
misconfigured, nobody can sign in — including admins — and no amount of shell access fixes
it except correcting the configuration and restarting.** `T10.5`'s validate-on-save guard is
therefore not a nicety; with no fallback it is the only thing standing between a typo in the
group field and a total outage.
Three things follow, and they are done-when checks in wave 10 rather than advice:
- The startup log must state whether LDAP is configured and reachable, so a broken deploy is
visible in `docker compose logs api` and not only at the login box.
- `/api/health` stays exempt from auth (it already is) so the outage is diagnosable.
- The group setting cannot be saved without proving the saving admin is a member.
**Identity: bind on `sAMAccountName`, match on `sAMAccountName` *or* `mail`.** A simple bind
can only carry one identifier, and AD accepts the UPN form — so the bind is
`sAMAccountName@prime.local` and that is what the login box takes. Matching an existing local
row is a separate question, and it uses **both**: after a successful bind the directory's
`sAMAccountName` and `mail` are both read, and `auth.find_user` is extended to match a local
row on either, case-insensitively. That is what keeps an existing admin's role whether their
hand-typed username was `c.schaefer` or `c.schaefer@prime-controls.com`.
Two consequences worth knowing:
- The mail domain (`prime-controls.com`) is not the AD domain (`prime.local`), so `mail` is
never a valid bind string. It is a matching key only.
- If someone types an address at the login box, the local part is used as the
`sAMAccountName`**one** bind attempt, never several, because each failed bind counts
against the domain lockout policy. That assumes the mail local part equals the
`sAMAccountName`. Where it does not, the person must type their short logon name; this is
logged when it happens and documented in `T10.8`.
- The production `users` table should still be compared against AD before this deploys. A
row matching on neither key gets a *second*, JIT-provisioned account at the default role
rather than keeping its admin. Matching on two keys narrows that risk; it does not remove
it.
### Explicitly out of scope