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>
158 lines
9.6 KiB
Markdown
158 lines
9.6 KiB
Markdown
# Decisions — August 21, 2026
|
||
|
||
One item, and it is the largest single change to the auth model since the login portal
|
||
shipped. Like the August 18 and August 20 sets it is a **new item** with its own `D` id,
|
||
not a reinterpretation of an existing one. `D1`–`D12` are taken; this is `D13`.
|
||
|
||
Raised by Cody Schaefer on Aug 21 2026 while asking how the suite handles HTTPS. Nothing in
|
||
`IMPLEMENTATION.md`, in `CR`/`F`/`S`/`A`/`B`/`C`, or in `docs/waves/backlog.md` covers
|
||
authentication against the domain — so this is new scope, and it gets a new ID rather than
|
||
being folded into the login-portal work that produced `server/auth.py`.
|
||
|
||
---
|
||
|
||
## D13 — Authentication moves to the domain over LDAPS
|
||
|
||
- **Amends:** the authentication model shipped in `DEPLOY-login-portal.md` (bcrypt hashes in
|
||
`users.password_hash`, verified in-process). That document describes what is being
|
||
replaced, not what is wrong — it was correct for a suite with no directory behind it.
|
||
- **Surface:** `server/` (`auth.py`, `app.py`, `models.py`, `manage_users.py`, `notify.py`,
|
||
a new `ldap_auth.py`, a new migration), `html/` (`login.html`, `login.js`, `admin.js`,
|
||
`users.js`), `requirements.txt`, `docker-compose.yml`, `Dockerfile`, deployment docs.
|
||
- **Wave:** 10 (`docs/waves/wave-10.md`). Depends on wave 9 merged, which it is.
|
||
|
||
### The decision
|
||
|
||
The suite stops storing passwords. A sign-in becomes a **simple bind to
|
||
`ldaps://prime.local:636`** as `<username>@prime.local` using the password the person typed.
|
||
A successful bind is the authentication. `users.password_hash` is dropped from the schema.
|
||
|
||
Four parts, all four required for the item to be done:
|
||
|
||
1. **LDAPS bind replaces local password verification.** `password_hash` is removed from the
|
||
model and from the database by migration. No password is stored, hashed or otherwise.
|
||
2. **Accounts are provisioned just-in-time.** A successful bind for a username with no
|
||
`users` row creates one, at the default role, with `full_name`/`email` read from the
|
||
directory.
|
||
3. **A required group gates login.** An AD group is configured; a bind that succeeds but
|
||
whose account is not in that group is refused. Membership is evaluated including nested
|
||
groups.
|
||
4. **Existing accounts keep their roles, and roles stay local.** An existing `admin` stays
|
||
an admin on first directory login. Granting admin to an existing account continues to
|
||
work from the Admin console. The directory supplies *identity*; this app supplies
|
||
*authorization*.
|
||
|
||
### Why LDAPS and not the certificate already in play
|
||
|
||
Recorded because the question was asked directly and the answer is not obvious.
|
||
|
||
The site's serving certificate is a **Let's Encrypt** cert (`CN=wp.controls.dev`, issued by
|
||
`Let's Encrypt YE2`, expiring 2026-11-08) held by an **OpenResty** instance at
|
||
`192.168.3.56` that is not part of this repo. It is a public domain-validated certificate.
|
||
It attests that whoever presented it controls DNS for `wp.controls.dev`; it carries no user
|
||
identity and no relationship to `prime.local`. There is no configuration that turns it into
|
||
a domain credential, so cert-based auth was never available "for free".
|
||
|
||
Client-certificate auth (mTLS) was considered and rejected for this wave: TLS terminates two
|
||
hops upstream at OpenResty, so the API never sees the handshake, and doing it in-app would
|
||
mean bypassing the proxy and losing the CSP/HSTS headers and static serving with it.
|
||
|
||
### What was verified before writing this (Aug 21 2026)
|
||
|
||
| Fact | Value |
|
||
|---|---|
|
||
| LDAPS reachable | `192.168.3.37:636` open, TLS 1.3, `TLS_AES_256_GCM_SHA384` |
|
||
| DC cert issuer | `CN=PRIME CONTROLS ISSUING CA 1, DC=prime, DC=local` |
|
||
| Root of that chain | `CN=PRIME CONTROLS ROOT CA` (self-signed, expires 2051-09-09) |
|
||
| Issuing CA expiry | 2036-09-09 |
|
||
| DC cert SAN | `DR-DC10Core.prime.local`, `prime.local`, `PRIME` |
|
||
| DCs published in `_ldap._tcp.prime.local` | six — `nla-dc10`, `lew-dc20`, `dr-dc30-core`, `lew-dc40`, `SABINEDC`, `dr-dc10core` |
|
||
| Chain validates against root+issuing bundle | yes — `Verify return code: 0 (ok)` |
|
||
|
||
Two consequences of that table, both binding on the build:
|
||
|
||
- **Connect to `prime.local`, not to a DC name or 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. Verified: `prime.local` gives `0 (ok)`; the raw IP
|
||
`192.168.3.37` gives `62 (hostname mismatch)`, because there is no IP SAN.
|
||
- **The trust anchor is a CA certificate, not a certificate issued to this app.** The API is
|
||
the TLS *client*; clients present nothing. It needs `PRIME CONTROLS ROOT CA` plus
|
||
`PRIME CONTROLS ISSUING CA 1` as a PEM bundle, which is public information. No CSR, no
|
||
enrollment, no private key, nothing to request from IT.
|
||
|
||
### Non-negotiables
|
||
|
||
These are the ways this change goes wrong, and each has a done-when check in wave 10.
|
||
|
||
- **An empty password must be rejected before `bind()` is called.** In LDAP a simple bind
|
||
with an empty password is an *anonymous* bind and it **succeeds**. Without an explicit
|
||
guard, a blank password authenticates as any username submitted. This is the single
|
||
highest-severity failure mode in the item and it gets its own test.
|
||
- **`validate=ssl.CERT_REQUIRED` with an explicit CA file.** Not `CERT_NONE`, and not the
|
||
system trust store. `CERT_NONE` still encrypts, so it fails silently — what it loses is
|
||
the ability to distinguish the real DC from an attacker who terminates the TLS session,
|
||
harvests the domain password and relays the bind onward. Since domain credentials now
|
||
cross that channel, a compromise escalates from "this app" to Windows, mail and file
|
||
shares. The system store is refused separately because it currently trusts five other
|
||
self-signed CAs (`prime-DR-CAPRIME-CA`, `prime-DR-CA_PRIME-CA`, `prime-DR-DC20-CA`,
|
||
`PRIME CONTROLS ISSUING CA 2`, and a stray `L55401TDKLY3.prime.local` machine cert in
|
||
Trusted Root).
|
||
- **The app's lockout must trip below the domain's.** `LOGIN_MAX_ATTEMPTS` currently writes
|
||
to the local `users` row. Once failures are binds, they count against the **AD** lockout
|
||
policy, so an unauthenticated caller hammering `/api/auth/login` can lock real domain
|
||
accounts out of Windows. The local throttle must stop calling the DC before the domain
|
||
threshold is reached.
|
||
- **Never leak which usernames exist.** `login()` today equalises response timing on purpose
|
||
so a caller cannot enumerate accounts. Directory error 49 sub-codes (`52e` bad password,
|
||
`532` password expired, `533` disabled, `775` locked) are useful in the log and must not
|
||
reach the response body.
|
||
|
||
### Answered August 21, 2026 — both were raised as open and both were decided
|
||
|
||
**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
|
||
|
||
- mTLS / client-certificate authentication (see above).
|
||
- Kerberos / SPNEGO single sign-on. It is the better long-term answer for domain-joined
|
||
desktops and needs a keytab, an SPN and browser trust configuration; it is not this item.
|
||
- Group-to-role mapping (e.g. an AD group that confers `project_admin`). Criterion 4 keeps
|
||
authorization local on purpose. Worth its own item later; logged in `backlog.md`.
|
||
- Replacing the Let's Encrypt certificate or changing anything on the OpenResty host.
|