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
`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
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:
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
gloved-hands surface and is where the worst rendering was found.
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
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.
**Repo:** `Project-SDE-WP-Suite` (primegit) — changes are merged to **`main`**.
**What changed:** the app now has a username/password login. Going live needs:
1. one new environment variable,
2. a **rebuild** of the stack (not just a restart), and
3. creating the first admin account.
> **This document replaced an earlier one.** Until Aug 24 2026 it described taking a
> **username/password login portal** live: bcrypt hashes, an `AUTH_SECRET_KEY`, and a
> first admin created with `manage_users create-admin <user> --password …`. All of
> 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
> bake the code in at build time (`COPY html/` and `COPY server/` in their
> Dockerfiles). A plain restart will **not** pick up the new code — the images must
> be **rebuilt** from the latest `main`.
> bake the code in at build time (`COPY html/` and `COPY server/`). A plain restart
> will **not** pick up the new code — the images must be **rebuilt** from 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 |
|------|-------|-------|
| `AUTH_SECRET_KEY` | a long random string | **Required.** Signs the login session cookies. |
| `AUTH_SESSION_HOURS` | `12` | *Optional.* Hours a login lasts before re-auth (defaults to 12). |
| `AUTH_SECRET_KEY` | a long random string | **Required.** Unchanged — still signs the session cookies. Keep the existing value; changing it signs everyone out. |
| `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
openssl rand -base64 48
```
**Do not point `LDAP_HOST` at a domain controller's name or at an IP address.** It is
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
> per-process key — logins then reset on every restart and break across the 2 gunicorn
> workers. It must be set to a fixed value.
The existing database variables (`POSTGRES_*`) are unchanged.
`AUTH_RESET_MINUTES` and `AUTH_RESET_COOLDOWN_SECONDS` can be deleted if present.
They configured the password-reset email, which no longer exists.
---
## 2. Pull latest `main`, rebuild, and redeploy
- Pull the latest commit on `main` and redeploy the stack **with image rebuild enabled**
(e.g. "Re-pull and redeploy" / force rebuild). This rebuilds both the `webserver` and
`api` images.
- New Python dependencies (`bcrypt`, `PyJWT`) are in `requirements.txt` and install
automatically during the rebuild.
- The `users` table is created automatically on API startup — **no DB migration needed.**
- Pull the latest commit on `main` and redeploy **with image rebuild enabled**.
- The new Python dependency (`ldap3`) is in `requirements.txt` and installs during
the rebuild.
- A database migration drops the `users.password_hash` column. It runs automatically
at container start. **Every account, role and project membership is preserved**
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**.
- 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:
**a. Did the API start at all?**
```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
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`,
`reset-password <user>`, `disable <user>`, `enable <user>`.
Want `Verify return code: 0 (ok)`. If you get a connection error, the `api` container
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**.
2. Sign in with the admin account from step 4.
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.
Roles are stored locally and are not read from AD, so someone has to be made an admin
once. Sign in first — that creates your account — then:
```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
- `server/auth.py`bcrypt password hashing, JWT session cookie, the request gate.
- `server/app.py``/api/auth/*` endpoints + middleware that refuses every `/api` data
route without a valid session.
- `server/manage_users.py` — the CLI used in step 4.
- `html/login.html`, `html/auth-guard.js` — login page and per-page guard.
- `html/admin.html` / `admin.js`Admin Console gated on the admin role, with the user
administration UI.
- 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).
- `server/ldap_auth.py`the LDAPS client: bind, nested-group check, certificate validation.
- `server/app.py``login()` binds instead of comparing a hash; password endpoints removed.
- `server/auth.py` — sessions and roles only; no hashing, no reset tokens.
- `server/certs/prime-ca-chain.pem` — the CA bundle that validates the DC certificate.
- `server/manage_users.py``promote` / `demote`, each requiring a domain bind.
- `html/login.html`, `login.js`one view; "Forgot password?" points at Okta.
- Migration `b7e4f1a20c93` — drops `users.password_hash`.

View File

@@ -69,10 +69,17 @@ BACKUP_ENC_PASSPHRASE=<strong-random-passphrase>
# details.
# SMTP_PASSWORD=<smtp-app-password>
# OPTIONAL — password-reset link lifetime (minutes) and the per-account send
# cooldown (seconds). Defaults shown; both only matter once email is enabled.
# AUTH_RESET_MINUTES=60
# AUTH_RESET_COOLDOWN_SECONDS=120
# OPTIONAL — the AD group required to sign in (D13). A group NAME or a full DN;
# nested groups count. Empty means any domain account may sign in. This is the
# initial value; the live one is set in the Admin console.
# 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_*`
@@ -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) |
| `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` |
| `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 |
| `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) |
@@ -350,27 +357,96 @@ TLS / From address and flips the master toggle.
package contents — so customer IP stays behind the login.
- 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
link explains that an admin must reset it (`server/manage_users.py`, or the Admin
console's **Reset password** button).
D13 removed local passwords entirely. **Turning email on no longer affects sign-in.**
The login page's "Forgot password?" links to `https://primecontrols.okta.com/`, which
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`
(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
can't be used to discover usernames. Misses are recorded in the audit log
(`password_reset_miss`) instead.
- One reset mail per account+client per `AUTH_RESET_COOLDOWN_SECONDS` (default 120)
so the form can't be used to flood someone's inbox. The throttle is per worker
and in-memory; the token expiry is the real control.
- 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
account.
- Set `app_base_url` in the admin card, or the emailed link will be relative and
therefore useless.
Email still carries WP-assignment notifications and the critical-reopen mail.
---
## Domain authentication (D13)
Sign-in is an **LDAPS simple bind** as `<sAMAccountName>@prime.local`. There is no
password in the database and **no break-glass account**. If the domain is
unreachable, `LDAP_CA_FILE` is wrong, or the required group is misconfigured,
**nobody can sign in, including admins.**
**First thing to check on any sign-in problem** — the API logs one line at startup
saying whether LDAP is configured, and `/api/health` stays unauthenticated so the
stack is diagnosable while nobody can log in:
```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

View File

@@ -132,7 +132,7 @@ is criterion 4 and must keep working.
**Done when:**
- [ ] `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
- [ ] `token_version` still invalidates sessions, exercised by a role change
- [ ] `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:**
- [ ] 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
- [ ] `DEPLOY-login-portal.md` no longer instructs anyone to set a password
- [ ] the DC-unreachable behaviour is stated explicitly
- [ ] `IMPLEMENTATION.md` section 4 lists wave 10
- [ ] no doc still claims passwords are stored as bcrypt hashes
- [x] every new env var is documented in `server/.env.example` and `DEPLOYMENT.md`
- [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`
- [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
- [x] the DC-unreachable behaviour is stated explicitly, with the diagnostic commands
- [x] `IMPLEMENTATION.md` section 4 lists wave 10
- [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] 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
- [ ] 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 |
|--------|------|---------|
| 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.
---