Accounts are not created here any more. D13 provisions them on first successful
sign-in, so create-admin and create were redundant - and worse than redundant,
because a hand-typed username can end up matching no directory identity at all.
Removing them means every row now originates from a bind, which closes that
class of problem for everything except the rows the old CLI already made.
promote and demote replace them. Bootstrapping the first admin is now two steps
in order: sign in once, which provisions the account at project_user, then
promote your own sAMAccountName.
Every state-changing command requires a prompted domain bind. No --password
flag on anything, deliberately: that would put a live domain password into shell
history and into ps output for every other user on the box. `list` needs no
credential so an outage stays diagnosable.
Two deliberate divergences from the API, both commented at the code:
- The bind does NOT apply the login group gate. If a mistyped required group
locks everyone out of the console, this tool must still work, or the only
route to fixing the lockout is the thing the lockout prevents.
- Changing your OWN role is permitted. set_user_role in app.py forbids it to
stop an admin locking themselves out of the console; here it is the entire
bootstrap path. Allowed, and recorded with {"self": true}.
Kept from set_user_role: the last-admin guard, and clearing auto_add_projects
on promotion to admin (an admin already reaches every project, so the flag
would sit there invisible and spring back on demotion).
What this is worth, said plainly in the module docstring rather than implied:
anyone with a shell here can still write to the users table with psql or
sqlite3, so the bind is defence in depth and mostly ACCOUNTABILITY. Before
this, every role change from a shell was invisible in AuditLog while the same
change through the console was recorded. Now both are recorded and both name a
person. Any-domain-user was accepted as sufficient knowing that.
Verified: the three removed commands are rejected as invalid choices; list runs
with no credential; a state-changing command with LDAP misconfigured refuses
rather than proceeding unauthenticated; promote, demote, self-promotion, the
last-admin guard, the unknown-account message, and one audit row per change all
behave, with the bind stubbed.
Left open rather than ticked: none of this has been run against a real bind.
authenticate_operator was stubbed for the logic tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
211 lines
12 KiB
Markdown
211 lines
12 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.
|
||
|
||
---
|
||
|
||
## D14 — The CLI authenticates against the domain, and stops creating accounts
|
||
|
||
- **Amends:** `D13` criterion 4, which said role granting keeps working *from the Admin
|
||
console*. It said nothing about `manage_users.py`, which had no authentication of any
|
||
kind. Requiring one is a new requirement, so it gets its own id rather than widening
|
||
criterion 4.
|
||
- **Surface:** `server/manage_users.py`
|
||
- **Task:** `T10.9`
|
||
|
||
### The decision
|
||
|
||
1. **`create-admin` and `create` are removed.** `D13` provisions accounts on first
|
||
successful sign-in, so creating them by hand is redundant. Removing them also closes a
|
||
class of problem: every row now originates from a bind, so a username cannot be typed
|
||
in wrong and end up orphaned from the directory identity it was meant to match. That
|
||
risk now applies only to rows the old CLI already created.
|
||
2. **`promote` and `demote` replace them.** The directory supplies identity; this app
|
||
supplies authorization, and this is where authorization is assigned from a shell.
|
||
3. **Every state-changing command requires a domain bind.** Prompted, via `getpass`.
|
||
There is deliberately no `--password` flag: that would put a live domain password into
|
||
shell history and into `ps` output for every other user on the box.
|
||
4. **`list` needs no credential**, so an outage stays diagnosable.
|
||
|
||
### Bootstrapping the first admin, which changed shape
|
||
|
||
Two steps, in order: **sign in once** (which provisions the account at `project_user`),
|
||
then **`promote <sAMAccountName>`**. Before D14 the first admin was created with a
|
||
password; there is no password now, and no account to create.
|
||
|
||
### What this is worth, stated plainly
|
||
|
||
Anyone with a shell on the api container can still write to the `users` table directly
|
||
with `psql` or `sqlite3`. So the bind is **defence in depth and, mostly,
|
||
ACCOUNTABILITY** — not a security boundary. Before D14 every role change made from a
|
||
shell was invisible in `AuditLog` while the same change through the console was recorded;
|
||
now both are recorded and both name a person. Any-domain-user was accepted as sufficient
|
||
(no privileged group exists on this estate, and machine access is already restricted to a
|
||
few people), which was decided knowing the above.
|
||
|
||
### Two deliberate divergences from the API
|
||
|
||
- **The bind does NOT apply the login group gate.** If a mistyped required group locks
|
||
everyone out of the console, this tool has to still work — otherwise the only route to
|
||
fixing the lockout is the thing the lockout prevents.
|
||
- **Changing your OWN role is permitted here.** `set_user_role` in `app.py` forbids it to
|
||
stop an admin locking themselves out of the console. Here it is the entire bootstrap
|
||
path, so it is allowed and recorded with `{"self": true}` in the audit detail.
|
||
|
||
The last-admin guard is kept, matching `set_user_role`: an app with no admin cannot be
|
||
administered, and there is no password login left to recover through.
|