T10.8 D13/D14 - documentation matches what the code now does

DEPLOY-login-portal.md was the most wrong and is rewritten. It described taking
a username/password portal live - bcrypt, and a first admin created with
`create-admin --password`. Every command in it now fails. It keeps its filename
and carries a note saying what it replaced, because an admin holding the old
copy needs to know why the steps stopped working rather than concluding the
deploy is broken. New content leads with the warning that there is no
break-glass, and puts verification BEFORE announcing the deploy - the log line,
the certificate check that binds nothing, then a real sign-in.

DEPLOYMENT.md: AUTH_RESET_* replaced with the LDAP variables; the users table
row no longer claims a password_hash column; "Self-service password reset"
replaced by a section saying there isn't one and pointing at Okta. New "Domain
authentication" section covering the three things that are not obvious - why
prime.local and never a DC or an IP, why the CA bundle is not a certificate
issued to this app (with the thumbprints and a Get-ChildItem line to rebuild
it), and why the outbound network stopped being optional - plus the lockout
arithmetic written out so the next person to raise AUTH_MAX_ATTEMPTS sees the
constraint rather than a magic 2.

server/README.md: endpoint table drops /api/auth/password and gains the role
route; the login-portal section becomes domain authentication; create-admin
becomes the two-step bootstrap (sign in, then promote).

CLAUDE.md: a new "authentication rules" section beside the token rule, for the
same reason that one exists - four things that look like tidying-up if you do
not know why. The empty-password guard that must run before bind(), CERT_REQUIRED
with an explicit CA file, AUTH_MAX_ATTEMPTS being arithmetic rather than taste,
and connecting to the domain name rather than a DC. Plus: no break-glass, and
roles are local - never read a role from AD.

Closed three done-when boxes that were open rather than ticked:

  T10.8  all of them
  T10.9  promote/demote verified against a real bind (Aug 24), not a stub
  T10.3  the Postgres round trip, on postgres:16-alpine

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-24 08:26:31 -05:00
parent 8d49fb9248
commit dc0cee240e
5 changed files with 315 additions and 111 deletions

View File

@@ -14,12 +14,12 @@ browser → NGINX ──serves──> static site (index.html, …)
| Method | Path | Purpose |
|--------|------|---------|
| GET | `/api/health` | liveness check (unauthenticated) |
| POST | `/api/auth/login` | sign in (`{username, password}`) — sets the session cookie |
| POST | `/api/auth/login` | sign in (`{username, password}`) — binds against the domain, sets the session cookie |
| POST | `/api/auth/logout` | clear the session cookie |
| GET | `/api/auth/me` | the logged-in user |
| POST | `/api/auth/password` | change your own password |
| GET | `/api/auth/users` | list accounts (**admin**) |
| POST | `/api/auth/users` | create an account (**admin**) |
| POST | `/api/auth/users` | pre-create an account (**admin**) — optional; accounts self-provision on first sign-in |
| POST | `/api/auth/users/{id}/role` | change an account's permissions role (**admin**) |
| DELETE | `/api/auth/users/{id}` | delete an account (**admin**) |
| POST | `/api/sops` | create/update a SOP (upsert by `id`) |
| GET | `/api/sops` | list SOP summaries |
@@ -40,9 +40,14 @@ fields (name, number, status, …) are promoted to columns for listing/filtering
---
## Login portal (user accounts)
## Sign-in (domain authentication, D13)
The suite is gated by a username/password login. Sign-in issues a signed JWT
**The suite stores no passwords.** Signing in performs an LDAPS **simple bind** to
`ldaps://prime.local:636` as `<sAMAccountName>@prime.local` using the password the
person typed — their Windows password. A successful bind is the authentication.
See `server/ldap_auth.py`; the schema has no `password_hash` column.
Sign-in issues a signed JWT
that rides in an **HttpOnly, SameSite=Lax** cookie (`wp_session`); the cookie is
marked **Secure** automatically whenever the request arrives over HTTPS (via
NGINX's `X-Forwarded-Proto`). There is no server-side session store — each
@@ -53,8 +58,43 @@ with `401` unless a valid session cookie is present (see `auth_gate` in
`app.py`). The static pages additionally include `auth-guard.js`, which redirects
to `login.html` when there's no session — that's for UX, not protection.
Passwords are stored only as **bcrypt** hashes (`server/auth.py`). Roles are
`admin` (may manage users) and `user`.
**The directory supplies identity; this app supplies authorization.** Roles live in
the local `users` table and are never read from AD — so an existing admin stays an
admin. Roles are `admin`, `project_super_user`, `project_admin`, `project_user`.
**Accounts are created on first successful sign-in.** Anyone who binds successfully
and is in the required group gets a `users` row at `project_user` with **no project
access** — they can sign in and will see nothing until an admin grants access. That
is least privilege, and it is deliberate; the creation is written to the audit log
so it is visible rather than silent.
**A required AD group gates sign-in.** `LDAP_REQUIRED_GROUP` (a group name or a full
DN; nested groups count). Empty means any domain account may sign in.
**There is no password reset and no break-glass.** The login page links to
`https://primecontrols.okta.com/` for password self-service. If the domain is
unreachable, or `LDAP_CA_FILE` is wrong, or the required group is misconfigured,
**nobody can sign in, including admins** — the API logs one line at startup saying
whether LDAP is configured and reachable, so check `docker compose logs api` first.
**Connect to the domain name, never a DC hostname or an IP.** Every DC certificate
carries `prime.local` in its SAN, so the domain name both passes hostname validation
and round-robins across all six DCs. An IP fails with `hostname mismatch` — there is
no IP SAN — and the only way to force it through is to disable validation, which
must never happen: domain passwords cross this link.
**The trust anchor is a CA certificate, not one issued to this app.** The API is the
TLS *client*, and clients present nothing. `server/certs/prime-ca-chain.pem` holds
`PRIME CONTROLS ROOT CA` + `PRIME CONTROLS ISSUING CA 1` — public certificates, no
private key, nothing to request from IT. Override the path with `LDAP_CA_FILE`.
Diagnose the connection without touching an account (no bind, so it cannot
contribute to a lockout):
```bash
docker compose exec api openssl s_client -connect prime.local:636 -CAfile /app/server/certs/prime-ca-chain.pem </dev/null 2>&1 | grep "Verify return"
# want: Verify return code: 0 (ok)
```
### Set the signing secret
@@ -65,25 +105,34 @@ without it the API uses a random per-process key, so logins reset on restart.
python -c "import secrets; print(secrets.token_urlsafe(48))"
```
### Create the first admin
### Bootstrap the first admin
The `/api/auth/users` endpoint needs an existing admin, so bootstrap one from a
shell (run from the **project root**, like uvicorn):
Two steps, in this order. There is no `create-admin` any more — there is no password
to set and no account to create.
```bash
python -m server.manage_users create-admin alice --name "Alice Smith"
# prompts for a password (min 8 chars)
# 1. Sign in to the app once. That provisions your account at project_user.
# 2. Promote it:
docker compose exec api python -m server.manage_users promote alice
```
In Docker:
It prompts for **your** domain username and password, binds to confirm who you are,
and prints `alice: project_user -> admin`.
```bash
docker compose exec api python -m server.manage_users create-admin alice --name "Alice Smith"
```
Other commands: `list`, `promote <user> [--role …]`, `demote <user>`,
`disable <user>`, `enable <user>`. After that, admins manage accounts from the Admin
console.
Other commands: `create <user> --role user`, `list`, `reset-password <user>`,
`disable <user>`, `enable <user>`. After that, admins can add users through the
API (or you can keep using the CLI).
**Every command that changes anything requires a domain bind** (D14), prompted —
there is deliberately no `--password` flag, which would put a live domain password
into shell history and `ps` output. `list` needs no credential so an outage stays
diagnosable. The bind here does **not** apply the required-group gate, so a mistyped
group cannot lock you out of the tool that fixes it.
Be clear on what the bind is worth: anyone with a shell here can still write to the
`users` table with `psql`. It is defence in depth and, mostly, **accountability**
every role change now writes an audit row naming a person, which shell changes
previously did not.
---