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:
@@ -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
|
||||
|
||||
|
||||
@@ -82,8 +82,10 @@ account, `last_login_at`, the session cookie. Rework the throttle so the local c
|
||||
failure mode to avoid is this endpoint being usable to lock domain accounts out of Windows.
|
||||
Log directory error-49 sub-codes for diagnosis; return the same generic message regardless.
|
||||
|
||||
Decide the break-glass question here (see **Open** in the decision doc) and record the answer
|
||||
in this file before implementing, because it changes this task's shape.
|
||||
**Break-glass: none — decided Aug 21, see the decision doc.** LDAPS is the only way in, so
|
||||
this task adds no fallback path. What it must add instead is *visibility*: a startup log line
|
||||
stating whether LDAP is configured and whether the DC answered, because with no fallback a
|
||||
misconfigured deploy is indistinguishable from a forgotten password at the login box.
|
||||
|
||||
**Done when:**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user