Files
Project-SDE-WP-Suite/docs/waves/decisions-2026-08-21.md
Cody Schaefer 0577660c86 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>
2026-08-21 14:06:49 -05:00

9.6 KiB
Raw Blame History

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. D1D12 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 sAMAccountNameone 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.