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. ---