From dc0cee240e97b045bc7cf4e39a432649194b315e Mon Sep 17 00:00:00 2001 From: Cody Schaefer Date: Mon, 24 Aug 2026 08:26:31 -0500 Subject: [PATCH] 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) --- CLAUDE.md | 29 ++++++- DEPLOY-login-portal.md | 169 +++++++++++++++++++++++++++-------------- DEPLOYMENT.md | 124 ++++++++++++++++++++++++------ docs/waves/wave-10.md | 17 +++-- server/README.md | 87 ++++++++++++++++----- 5 files changed, 315 insertions(+), 111 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e164d88..7a41bb9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/DEPLOY-login-portal.md b/DEPLOY-login-portal.md index 51e2663..9da294f 100644 --- a/DEPLOY-login-portal.md +++ b/DEPLOY-login-portal.md @@ -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 --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 --name "" +docker compose logs api | grep -i "LDAP auth" ``` -It prompts for a password (minimum 8 characters) and prints `Created admin: `. +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 --name "" --password "" +docker compose exec api openssl s_client -connect prime.local:636 -CAfile /app/server/certs/prime-ca-chain.pem &1 | grep "Verify return" ``` -Other CLI commands (run the same way): `list`, `create --role user`, -`reset-password `, `disable `, `enable `. +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 +``` + +It asks for **your** domain username and password, binds to confirm who you are, and +prints `: 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`. diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 5d4ae93..6d7fa62 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -69,10 +69,17 @@ BACKUP_ENC_PASSPHRASE= # details. # SMTP_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 `@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 &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 +``` + +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 diff --git a/docs/waves/wave-10.md b/docs/waves/wave-10.md index aa14446..d70fc70 100644 --- a/docs/waves/wave-10.md +++ b/docs/waves/wave-10.md @@ -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 diff --git a/server/README.md b/server/README.md index fbc988b..b6e4ff2 100644 --- a/server/README.md +++ b/server/README.md @@ -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 `@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 &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 [--role …]`, `demote `, +`disable `, `enable `. After that, admins manage accounts from the Admin +console. -Other commands: `create --role user`, `list`, `reset-password `, -`disable `, `enable `. 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. ---