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

@@ -70,6 +70,31 @@ Adding a raw hex value to a page stylesheet is a defect regardless of what the t
for. Four parallel token systems is what produced S5, and the `.field-hint` comment at for. Four parallel token systems is what produced S5, and the `.field-hint` comment at
`work-package-suite-styles.css:336` is the bug that resulted. Do not recreate it. `work-package-suite-styles.css:336` is the bug that resulted. Do not recreate it.
## The authentication rules
Sign-in is an LDAPS bind against the domain (`D13`, `docs/waves/decisions-2026-08-21.md`).
Four things about it are load-bearing and look like tidying-up if you do not know why:
- **The empty-password guard in `ldap_auth.verify` runs before `bind()`.** An LDAP
simple bind with an empty password is an *anonymous* bind and it SUCCEEDS. Remove
that check and a blank password authenticates as any username submitted. It looks
redundant because `login()` checks too. Both stay.
- **`validate=ssl.CERT_REQUIRED` with an explicit CA file.** Never `CERT_NONE`, never
the system trust store (which trusts five other self-signed CAs on this estate).
`CERT_NONE` still encrypts, so it fails silently - what it loses is the ability to
tell a real DC from someone harvesting domain passwords.
- **`AUTH_MAX_ATTEMPTS` is 2, and that is arithmetic, not taste.** Failures are real
domain binds counting against the AD lockout policy (5 here), and 2 workers double
it: 2 x 2 = 4 < 5. Raising it, or adding a worker, makes `/api/auth/login` a way to
lock colleagues out of Windows.
- **Connect to `prime.local`, never a DC name or an IP.** Every DC certificate carries
the domain name in its SAN; an IP fails hostname validation, and the only way to
force it is to disable the check above.
There is **no break-glass account** - a misconfiguration locks out everyone including
admins. And roles are LOCAL: the directory supplies identity, this app supplies
authorization. Never read a role from AD.
## Accessibility is in scope ## Accessibility is in scope
Approved Aug 14, 2026 (C1). Any component you rebuild ships accessible or it is not done: Approved Aug 14, 2026 (C1). Any component you rebuild ships accessible or it is not done:
@@ -90,10 +115,12 @@ A task is not done because the code is written. Every task file lists its own do
checks. In addition, for any task touching the frontend: checks. In addition, for any task touching the frontend:
1. Run the app locally: `uvicorn server.app:app` against a throwaway SQLite database. 1. Run the app locally: `uvicorn server.app:app` against a throwaway SQLite database.
Signing in needs a domain credential now (D13) a local run reaches `prime.local`
from the host with no extra configuration. A container needs the `outbound` network.
2. Exercise the affected flow at **390px** and at **1440px**. Field View at 390px is the 2. Exercise the affected flow at **390px** and at **1440px**. Field View at 390px is the
gloved-hands surface and is where the worst rendering was found. gloved-hands surface and is where the worst rendering was found.
3. Capture before and after screenshots into the PR. 3. Capture before and after screenshots into the PR.
4. Run the existing smoke test. It signs in, and so does `server/seed_demo.py` (S13, fixed at T1.6 - this line said otherwise until Aug 20 2026, a stale record). 4. Run the existing smoke test. It signs in, and so does `server/seed_demo.py` (S13, fixed at T1.6 - this line said otherwise until Aug 20 2026, a stale record). Since D13 both need a **domain** credential, and `WP_SMOKE_PASSWORD` is now a real Windows password - never put one on a command line.
If a done-when check cannot be verified, do not mark the task complete. Say which check If a done-when check cannot be verified, do not mark the task complete. Say which check
failed and why. failed and why.

View File

@@ -1,103 +1,154 @@
# Deploy: Work Package Suite — login portal update # Deploy: Work Package Suite — domain sign-in (D13)
Instructions for the **Portainer admin** to take the new secure login portal live. Instructions for the **Portainer admin** to take domain authentication live.
No prior context needed. No prior context needed.
**Repo:** `Project-SDE-WP-Suite` (primegit) — changes are merged to **`main`**. **Repo:** `Project-SDE-WP-Suite` (primegit) — changes are merged to **`main`**.
**What changed:** the app now has a username/password login. Going live needs: > **This document replaced an earlier one.** Until Aug 24 2026 it described taking a
1. one new environment variable, > **username/password login portal** live: bcrypt hashes, an `AUTH_SECRET_KEY`, and a
2. a **rebuild** of the stack (not just a restart), and > first admin created with `manage_users create-admin <user> --password …`. All of
3. creating the first admin account. > that is gone. The app no longer stores a password of any kind, `create-admin` no
> longer exists, and following the old steps will fail at the first command. The
> superseded design is recorded in `docs/waves/decisions-2026-08-21.md` (D13).
**What changed:** signing in is now an **LDAPS bind against `prime.local`**. People
use their **Windows password**. The suite stores no credential, there is no password
reset, and accounts create themselves on first sign-in.
Going live needs:
1. two environment variables,
2. a **rebuild** of the stack (not just a restart),
3. one network check, and
4. promoting the first admin.
> **Why a rebuild (not a restart):** both the **nginx/webserver** and **api** images > **Why a rebuild (not a restart):** both the **nginx/webserver** and **api** images
> bake the code in at build time (`COPY html/` and `COPY server/` in their > bake the code in at build time (`COPY html/` and `COPY server/`). A plain restart
> Dockerfiles). A plain restart will **not** pick up the new code — the images must > will **not** pick up the new code — the images must be **rebuilt** from latest `main`.
> be **rebuilt** from the latest `main`.
--- ---
## 1. Add an environment variable to the stack ## ⚠ Read this before you start
In the stack's **Environment variables** section, add: **There is no break-glass account.** If the directory is unreachable, the CA bundle
path is wrong, or the required group is misconfigured, **nobody can sign in —
including you.** That was a deliberate decision, not an oversight. Recovery is to fix
the configuration and restart; there is no local password to fall back on.
So: do step 3 before you tell anyone the deploy is done.
---
## 1. Add environment variables to the stack
In the stack's **Environment variables** section:
| Name | Value | Notes | | Name | Value | Notes |
|------|-------|-------| |------|-------|-------|
| `AUTH_SECRET_KEY` | a long random string | **Required.** Signs the login session cookies. | | `AUTH_SECRET_KEY` | a long random string | **Required.** Unchanged — still signs the session cookies. Keep the existing value; changing it signs everyone out. |
| `AUTH_SESSION_HOURS` | `12` | *Optional.* Hours a login lasts before re-auth (defaults to 12). | | `LDAP_REQUIRED_GROUP` | `CN=Prime Employees,OU=Prime Distribution and Security Groups,DC=prime,DC=local` | AD group required to sign in. A full DN is best — it skips a directory lookup. Nested groups count. Leave empty to allow any domain account. |
| `AUTH_SESSION_HOURS` | `12` | *Optional.* Unchanged. |
Generate the secret on the host with: You do **not** need to set `LDAP_HOST`, `LDAP_DOMAIN` or `LDAP_CA_FILE`. Their
defaults are correct for this estate, and the CA bundle ships inside the image.
```bash **Do not point `LDAP_HOST` at a domain controller's name or at an IP address.** It is
openssl rand -base64 48 set to `prime.local` on purpose: every DC's certificate carries that name in its SAN,
``` so the domain name both validates and load-balances across all six DCs. An IP fails
certificate validation outright, and the only way to force it through is to switch
validation off — which would let anyone on the network intercept **domain passwords**.
> If `AUTH_SECRET_KEY` is **not** set, the app still starts but falls back to a random `AUTH_RESET_MINUTES` and `AUTH_RESET_COOLDOWN_SECONDS` can be deleted if present.
> per-process key — logins then reset on every restart and break across the 2 gunicorn They configured the password-reset email, which no longer exists.
> workers. It must be set to a fixed value.
The existing database variables (`POSTGRES_*`) are unchanged.
--- ---
## 2. Pull latest `main`, rebuild, and redeploy ## 2. Pull latest `main`, rebuild, and redeploy
- Pull the latest commit on `main` and redeploy the stack **with image rebuild enabled** - Pull the latest commit on `main` and redeploy **with image rebuild enabled**.
(e.g. "Re-pull and redeploy" / force rebuild). This rebuilds both the `webserver` and - The new Python dependency (`ldap3`) is in `requirements.txt` and installs during
`api` images. the rebuild.
- New Python dependencies (`bcrypt`, `PyJWT`) are in `requirements.txt` and install - A database migration drops the `users.password_hash` column. It runs automatically
automatically during the rebuild. at container start. **Every account, role and project membership is preserved**
- The `users` table is created automatically on API startup — **no DB migration needed.** it removes one column, not any rows.
--- ---
## 3. Verify the containers ## 3. Verify BEFORE announcing it
- Confirm `wp_api` and the webserver container are both **running**. **a. Did the API start at all?**
- If `wp_api` fails to start, check its **Logs**. (A missing `AUTH_SECRET_KEY` only logs a
warning — it won't crash — but please confirm it's set.)
---
## 4. Create the first admin account
The login system needs one admin user in the production (Postgres) database. Open the
**`wp_api`** container's **Console** (`/bin/sh`) and run:
```bash ```bash
python -m server.manage_users create-admin <username> --name "<Full Name>" docker compose logs api | grep -i "LDAP auth"
``` ```
It prompts for a password (minimum 8 characters) and prints `Created admin: <username>`. You want:
Non-interactive alternative: ```
LDAP auth enabled — ldaps://prime.local:636, domain prime.local, CA /app/server/certs/prime-ca-chain.pem, required group: CN=Prime Employees,…
```
If it says `LDAP auth DISABLED`, stop — nobody will be able to sign in. The message
names the reason.
**b. Can the container actually reach a domain controller?** This opens a TLS session
and validates the certificate **without binding**, so it touches no account and
cannot contribute to any lockout:
```bash ```bash
python -m server.manage_users create-admin <username> --name "<Full Name>" --password "<password>" 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"
``` ```
Other CLI commands (run the same way): `list`, `create <user> --role user`, Want `Verify return code: 0 (ok)`. If you get a connection error, the `api` container
`reset-password <user>`, `disable <user>`, `enable <user>`. is missing the `outbound` network — `internal` has no default gateway and blocks the
LAN as well as the internet. If you get `62 (hostname mismatch)`, something is
pointing at an IP instead of `prime.local`.
**c. Sign in.** Use your own Windows username and password.
--- ---
## 5. Confirm it works ## 4. Promote the first admin
1. Load the site's normal URL — it should redirect to a **login page**. Roles are stored locally and are not read from AD, so someone has to be made an admin
2. Sign in with the admin account from step 4. once. Sign in first — that creates your account — then:
3. That admin can then add all other users from the in-app **Admin → User
administration** page (top-right **Admin** link), so no further shell access is needed. ```bash
docker compose exec api python -m server.manage_users promote <your-sAMAccountName>
```
It asks for **your** domain username and password, binds to confirm who you are, and
prints `<user>: project_user -> admin`.
Other commands: `list` (needs no credential), `demote`, `disable`, `enable`.
`create-admin`, `create` and `reset-password` no longer exist.
After that, admins manage everyone else from the in-app **Admin → User
administration** page. No further shell access needed.
--- ---
## What people will notice
- They sign in with their **Windows password**, not an app password.
- **"Forgot password?"** now goes to `https://primecontrols.okta.com/`. The app cannot
reset a password it does not hold.
- The **Change password** item is gone from the top-right menu.
- Anyone in the required group can sign in **without being added first** — their
account is created automatically. They will see **no projects** until an admin
grants access, which is intentional. New accounts appear in the Admin console and
each one is recorded in the audit log.
- Two wrong passwords and the app stops trying for a while. That is deliberate: every
failed attempt is a real domain bind and counts against the **AD lockout policy**,
so the app stops well short of locking anyone out of Windows.
## Reference — what's in this release ## Reference — what's in this release
- `server/auth.py`bcrypt password hashing, JWT session cookie, the request gate. - `server/ldap_auth.py`the LDAPS client: bind, nested-group check, certificate validation.
- `server/app.py``/api/auth/*` endpoints + middleware that refuses every `/api` data - `server/app.py``login()` binds instead of comparing a hash; password endpoints removed.
route without a valid session. - `server/auth.py` — sessions and roles only; no hashing, no reset tokens.
- `server/manage_users.py` — the CLI used in step 4. - `server/certs/prime-ca-chain.pem` — the CA bundle that validates the DC certificate.
- `html/login.html`, `html/auth-guard.js` — login page and per-page guard. - `server/manage_users.py``promote` / `demote`, each requiring a domain bind.
- `html/admin.html` / `admin.js`Admin Console gated on the admin role, with the user - `html/login.html`, `login.js`one view; "Forgot password?" points at Okta.
administration UI. - Migration `b7e4f1a20c93` — drops `users.password_hash`.
- Sessions are stateless: a signed JWT in an **HttpOnly, SameSite=Lax** cookie, marked
**Secure** automatically when served over HTTPS (via `X-Forwarded-Proto` from nginx).

View File

@@ -69,10 +69,17 @@ BACKUP_ENC_PASSPHRASE=<strong-random-passphrase>
# details. # details.
# SMTP_PASSWORD=<smtp-app-password> # SMTP_PASSWORD=<smtp-app-password>
# OPTIONAL — password-reset link lifetime (minutes) and the per-account send # OPTIONAL — the AD group required to sign in (D13). A group NAME or a full DN;
# cooldown (seconds). Defaults shown; both only matter once email is enabled. # nested groups count. Empty means any domain account may sign in. This is the
# AUTH_RESET_MINUTES=60 # initial value; the live one is set in the Admin console.
# AUTH_RESET_COOLDOWN_SECONDS=120 # LDAP_REQUIRED_GROUP=CN=Prime Employees,OU=Prime Distribution and Security Groups,DC=prime,DC=local
#
# OPTIONAL — the rest of the directory settings. The defaults are correct for this
# estate and you should not normally set them. NEVER point LDAP_HOST at a DC name
# or an IP: see § Domain authentication below.
# LDAP_DOMAIN=prime.local
# LDAP_HOST=prime.local
# LDAP_CA_FILE=/app/server/certs/prime-ca-chain.pem
``` ```
The API builds its own DB connection string from the `POSTGRES_*` The API builds its own DB connection string from the `POSTGRES_*`
@@ -257,7 +264,7 @@ users on the same project see the same server-stored SOP and Work Packages.
| `sops` | project SOP baselines | `project_id` → projects, `name`, `number`, `complete`, `data` (full SOP JSON) | | `sops` | project SOP baselines | `project_id` → projects, `name`, `number`, `complete`, `data` (full SOP JSON) |
| `work_packages` | individual IWPs | `project_id` → projects, `sop_id` → sops, `parent_id` (split instances), `number`, `subject`, `type`, `status`, `assignee_id` (owner), `issued_at`, `archived_at`, `data` (full WP JSON) | | `work_packages` | individual IWPs | `project_id` → projects, `sop_id` → sops, `parent_id` (split instances), `number`, `subject`, `type`, `status`, `assignee_id` (owner), `issued_at`, `archived_at`, `data` (full WP JSON) |
| `comments` | feedback from any page | `source`, `sop_id`, `wp_id`, `step`, `author`, `text`, `extra` | | `comments` | feedback from any page | `source`, `sop_id`, `wp_id`, `step`, `author`, `text`, `extra` |
| `users` | login accounts | `username`, `password_hash` (bcrypt), `role`, `full_name`, `email`, `is_active`, `auto_add_projects` + `auto_add_role` (default membership on new projects), login-lockout + `token_version` fields | | `users` | login accounts (no password — D13) | `username` (sAMAccountName), `role`, `full_name`, `email`, `is_active`, `auto_add_projects` + `auto_add_role` (default membership on new projects), login-lockout + `token_version` fields |
| `project_members` | per-project access control | `user_id` → users, `project_id` → projects | | `project_members` | per-project access control | `user_id` → users, `project_id` → projects |
| `audit_log` | append-only activity trail | `actor`, `action`, `entity_type`, `entity_id`, `project_id`, `summary`, `detail` | | `audit_log` | append-only activity trail | `actor`, `action`, `entity_type`, `entity_id`, `project_id`, `summary`, `detail` |
| `notifications` | in-app record + email outbox | `user_id`, `kind`, `wp_id`, `subject`, `status` (pending / sent / failed / skipped) | | `notifications` | in-app record + email outbox | `user_id`, `kind`, `wp_id`, `subject`, `status` (pending / sent / failed / skipped) |
@@ -350,27 +357,96 @@ TLS / From address and flips the master toggle.
package contents — so customer IP stays behind the login. package contents — so customer IP stays behind the login.
- Use the card's **Send test email** button to confirm SMTP before enabling. - Use the card's **Send test email** button to confirm SMTP before enabling.
### Self-service password reset ### Password reset — there isn't one
Turning email on also enables **Forgot password** on the login page. Until then the D13 removed local passwords entirely. **Turning email on no longer affects sign-in.**
link explains that an admin must reset it (`server/manage_users.py`, or the Admin The login page's "Forgot password?" links to `https://primecontrols.okta.com/`, which
console's **Reset password** button). is the only self-service route; the app cannot reset a credential it does not hold.
- The emailed link carries a short-lived signed token — `AUTH_RESET_MINUTES` Email still carries WP-assignment notifications and the critical-reopen mail.
(default 60). It is **single-use**: completing a reset bumps the account's
`token_version`, which both burns the link and signs out that user's other ---
sessions. A completed reset also clears any login lockout.
- `/api/auth/forgot-password` answers **identically for unknown accounts**, so it ## Domain authentication (D13)
can't be used to discover usernames. Misses are recorded in the audit log
(`password_reset_miss`) instead. Sign-in is an **LDAPS simple bind** as `<sAMAccountName>@prime.local`. There is no
- One reset mail per account+client per `AUTH_RESET_COOLDOWN_SECONDS` (default 120) password in the database and **no break-glass account**. If the domain is
so the form can't be used to flood someone's inbox. The throttle is per worker unreachable, `LDAP_CA_FILE` is wrong, or the required group is misconfigured,
and in-memory; the token expiry is the real control. **nobody can sign in, including admins.**
- Reset mails are sent **immediately, not through the notifications outbox** — a
reset link must never be persisted where an admin could read it and take over an **First thing to check on any sign-in problem** — the API logs one line at startup
account. saying whether LDAP is configured, and `/api/health` stays unauthenticated so the
- Set `app_base_url` in the admin card, or the emailed link will be relative and stack is diagnosable while nobody can log in:
therefore useless.
```bash
docker compose logs api | grep -i "LDAP auth"
# LDAP auth enabled — ldaps://prime.local:636, domain prime.local, …
# LDAP auth DISABLED — CA bundle not found at '…'. No one can sign in.
curl https://wp-suite.company.local/api/health # → {"ok": true}
```
Then prove the certificate path, without binding — this touches no account and so
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)
```
### Three things that are not obvious
**Connect to the domain name, never a DC 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 published in `_ldap._tcp.prime.local`. An IP gives
`Verify return code: 62 (hostname mismatch)` because there is no IP SAN — and the
only way to force it through is to disable validation. Do not. Domain passwords
cross this link, and an unvalidated one can be terminated by anyone on the network
who then harvests them.
**The CA bundle is not a certificate issued to this app.** The API is the TLS
*client*; clients verify, they do not present. `server/certs/prime-ca-chain.pem`
contains `PRIME CONTROLS ROOT CA` (valid to 2051) and `PRIME CONTROLS ISSUING CA 1`
(2036) — public certificates with no private key. There is nothing to request from
IT, no CSR and no enrollment. Rebuild it from any domain-joined machine with:
```powershell
Get-ChildItem Cert:\LocalMachine\Root, Cert:\LocalMachine\CA |
Where-Object { $_.Thumbprint -in
'C371E91C430A12051029527C443B1EF683675CF3', # PRIME CONTROLS ROOT CA
'4F7506105228C73DF64181ACA20AD9783437EC8B' } # PRIME CONTROLS ISSUING CA 1
```
exporting each as Base-64 and concatenating them into one file.
**The `outbound` network is required.** `internal` has no default gateway, which
blocks the LAN and the VPN as well as the internet, so the `api` container cannot
reach `prime.local:636` without it. Its comment used to say it was optional if you
were not using the Micron asset picker; detaching it now breaks every sign-in.
### Accounts
Accounts are **created on first successful sign-in**, at `project_user` with **no
project access** — the person signs in and sees nothing until an admin grants it.
Roles are local and never read from AD, so an existing admin keeps admin.
The first admin is bootstrapped in two steps: sign in once, then
```bash
docker compose exec api python -m server.manage_users promote <sAMAccountName>
```
which prompts for *your* domain credential. `list`, `demote`, `disable` and `enable`
are the other commands; `create-admin` and `create` no longer exist.
### The lockout arithmetic
`AUTH_MAX_ATTEMPTS` defaults to **2**, and that is a safety limit rather than a
preference. Failures are now domain binds, so they count against the **AD account
lockout policy** (5 on this estate). The throttle is per-process and the API runs 2
gunicorn workers, so a local limit of N allows up to 2N binds to reach a DC: 2 × 2 = 4,
one under the threshold. **Raising this, or adding a worker, means redoing that
arithmetic** — otherwise `/api/auth/login` becomes a way for anyone, unauthenticated,
to lock a colleague out of Windows.
## Permissions roles ## Permissions roles

View File

@@ -132,7 +132,7 @@ is criterion 4 and must keep working.
**Done when:** **Done when:**
- [ ] `grep -rn "password_hash\|hash_password\|verify_password\|password_problem" server/` returns nothing outside the migration - [ ] `grep -rn "password_hash\|hash_password\|verify_password\|password_problem" server/` returns nothing outside the migration
- [ ] `alembic upgrade head` then `downgrade -1` round-trips on SQLite and on Postgres - [x] `alembic upgrade head` then `downgrade -1` round-trips on SQLite and on Postgres (16.15, the compose image — Aug 24; needed a pre-existing T8.6 migration bug fixed first, see `495d87d`)
- [ ] the migration's `downgrade()` recreates the column nullable, not `NOT NULL` — there are no hashes to put back - [ ] the migration's `downgrade()` recreates the column nullable, not `NOT NULL` — there are no hashes to put back
- [ ] `token_version` still invalidates sessions, exercised by a role change - [ ] `token_version` still invalidates sessions, exercised by a role change
- [ ] `manage_users.py list`, `disable`, `enable` still work; `reset-password` is gone - [ ] `manage_users.py list`, `disable`, `enable` still work; `reset-password` is gone
@@ -331,12 +331,13 @@ happens when the DC is unreachable, whatever `T10.2` decides.
**Done when:** **Done when:**
- [ ] every new env var is documented in `server/.env.example` and `DEPLOYMENT.md` - [x] every new env var is documented in `server/.env.example` and `DEPLOYMENT.md`
- [ ] the CA bundle procedure is reproducible by an admin who has not read this thread - [x] the CA bundle procedure is reproducible by an admin who has not read this thread — thumbprints and a `Get-ChildItem` one-liner in `DEPLOYMENT.md`
- [ ] `DEPLOY-login-portal.md` no longer instructs anyone to set a password - [x] `DEPLOY-login-portal.md` no longer instructs anyone to set a password — rewritten, with a note saying what it replaced so an admin holding the old copy is not misled
- [ ] the DC-unreachable behaviour is stated explicitly - [x] the DC-unreachable behaviour is stated explicitly, with the diagnostic commands
- [ ] `IMPLEMENTATION.md` section 4 lists wave 10 - [x] `IMPLEMENTATION.md` section 4 lists wave 10
- [ ] no doc still claims passwords are stored as bcrypt hashes - [x] no doc still claims passwords are stored as bcrypt hashes (swept; remaining matches all say the opposite)
- [x] `CLAUDE.md` carries the four load-bearing auth rules, next to the token rule
--- ---
@@ -369,4 +370,4 @@ that would pull FastAPI and the whole application into a CLI startup for one INS
- [x] the last active admin cannot be demoted - [x] the last active admin cannot be demoted
- [x] an unknown account gives an error that says accounts are made on first sign-in - [x] an unknown account gives an error that says accounts are made on first sign-in
- [x] every change writes an `AuditLog` row naming the operator - [x] every change writes an `AuditLog` row naming the operator
- [ ] verified against a real domain bind rather than a stubbed `authenticate_operator` - [x] verified against a real domain bind `promote` and `demote` confirmed working Aug 24 2026

View File

@@ -14,12 +14,12 @@ browser → NGINX ──serves──> static site (index.html, …)
| Method | Path | Purpose | | Method | Path | Purpose |
|--------|------|---------| |--------|------|---------|
| GET | `/api/health` | liveness check (unauthenticated) | | 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 | | POST | `/api/auth/logout` | clear the session cookie |
| GET | `/api/auth/me` | the logged-in user | | GET | `/api/auth/me` | the logged-in user |
| POST | `/api/auth/password` | change your own password |
| GET | `/api/auth/users` | list accounts (**admin**) | | 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**) | | DELETE | `/api/auth/users/{id}` | delete an account (**admin**) |
| POST | `/api/sops` | create/update a SOP (upsert by `id`) | | POST | `/api/sops` | create/update a SOP (upsert by `id`) |
| GET | `/api/sops` | list SOP summaries | | 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 that rides in an **HttpOnly, SameSite=Lax** cookie (`wp_session`); the cookie is
marked **Secure** automatically whenever the request arrives over HTTPS (via marked **Secure** automatically whenever the request arrives over HTTPS (via
NGINX's `X-Forwarded-Proto`). There is no server-side session store — each 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 `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. 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 **The directory supplies identity; this app supplies authorization.** Roles live in
`admin` (may manage users) and `user`. 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 ### 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))" 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 Two steps, in this order. There is no `create-admin` any more — there is no password
shell (run from the **project root**, like uvicorn): to set and no account to create.
```bash ```bash
python -m server.manage_users create-admin alice --name "Alice Smith" # 1. Sign in to the app once. That provisions your account at project_user.
# prompts for a password (min 8 chars) # 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 Other commands: `list`, `promote <user> [--role …]`, `demote <user>`,
docker compose exec api python -m server.manage_users create-admin alice --name "Alice Smith" `disable <user>`, `enable <user>`. After that, admins manage accounts from the Admin
``` console.
Other commands: `create <user> --role user`, `list`, `reset-password <user>`, **Every command that changes anything requires a domain bind** (D14), prompted —
`disable <user>`, `enable <user>`. After that, admins can add users through the there is deliberately no `--password` flag, which would put a live domain password
API (or you can keep using the CLI). 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.
--- ---