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:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user