D13 / wave 10 - the decision and the task file, before any code
Authentication moves to the domain over LDAPS. Nothing in the spec covered auth against a directory, so per CLAUDE.md this is new scope with a new `D` id rather than a widened old one. D1-D12 were taken. Records what was verified against the live environment on Aug 21 rather than assumed: LDAPS on 636 with TLS 1.3, the DC cert chain up through PRIME CONTROLS ISSUING CA 1 to a root valid until 2051, six DCs in the SRV record, and `prime.local` in every DC cert's SAN - which is why the client connects to the domain name and not to a DC or an IP. Also records why the certificate already serving the site is not usable for this: it is a Let's Encrypt DV cert held by an OpenResty host outside this repo, and it carries no relationship to prime.local. The question was asked directly and the answer is not obvious, so it belongs in the record. Eight tasks, T10.1 through T10.8. Two questions are left open in the decision doc to be answered rather than guessed - break-glass access when the DC is unreachable, and whether existing usernames match sAMAccountName. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
129
docs/waves/decisions-2026-08-21.md
Normal file
129
docs/waves/decisions-2026-08-21.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# 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.
|
||||
|
||||
### Open, to confirm in the PR rather than decide alone
|
||||
|
||||
- **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.
|
||||
|
||||
### 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.
|
||||
Reference in New Issue
Block a user