Compare commits

..

2 Commits

Author SHA1 Message Date
64eac0cbbb Merge branch 'fix/material-items-boolean-default': the Postgres deploy hotfix
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:05:27 -07:00
8f280d4bd1 D6 hotfix - the material_items migration crashed Postgres at deploy
server_default=sa.text('1') on a Boolean: SQLite coerces integer 1, Postgres
refuses it (DatatypeMismatch: column 'active' is of type boolean but default
expression is of type integer) - so 'verified end-to-end on a scratch DB' was
true and insufficient, because the scratch DB was SQLite. Found in production
2026-08-21: the wp.controls.dev api container crash-looped on alembic upgrade
and the site served static pages with a 502 API until the table was created
by hand from the db container (identical DDL, alembic_version stamped to
a1b8c6d4e2f9, so this fixed migration is a no-op there).

Now sa.true() - which the location-taxonomy migration next door used
correctly all along, and which is why IT applied to production without
incident. materials_check gains the static pin: every Boolean server_default
in every migration must be sa.true()/sa.false(). Verified: alembic --sql
offline render for the postgresql dialect emits DEFAULT true; the full chain
still applies on a scratch SQLite.

Items: D6 (the migration), CR-013 surface. Probe: materials_check +1 static
check (its browser half was env-blocked today - headless browser would not
start; the fix is exercised entirely by the static half and the two renders).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:05:27 -07:00
37 changed files with 790 additions and 2771 deletions

View File

@@ -70,31 +70,6 @@ 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:
@@ -115,12 +90,10 @@ 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). Since D13 both need a **domain** credential, and `WP_SMOKE_PASSWORD` is now a real Windows password - never put one on a command line.
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).
If a done-when check cannot be verified, do not mark the task complete. Say which check
failed and why.

View File

@@ -1,154 +1,103 @@
# Deploy: Work Package Suite — domain sign-in (D13)
# Deploy: Work Package Suite — login portal update
Instructions for the **Portainer admin** to take domain authentication live.
Instructions for the **Portainer admin** to take the new secure login portal live.
No prior context needed.
**Repo:** `Project-SDE-WP-Suite` (primegit) — changes are merged to **`main`**.
> **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.
**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.
> **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/`). A plain restart
> will **not** pick up the new code — the images must be **rebuilt** from latest `main`.
> 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`.
---
## ⚠ Read this before you start
## 1. Add an environment variable to the stack
**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:
In the stack's **Environment variables** section, add:
| Name | Value | Notes |
|------|-------|-------|
| `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. |
| `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). |
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.
Generate the secret on the host with:
**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**.
```bash
openssl rand -base64 48
```
`AUTH_RESET_MINUTES` and `AUTH_RESET_COOLDOWN_SECONDS` can be deleted if present.
They configured the password-reset email, which no longer exists.
> 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.
---
## 2. Pull latest `main`, rebuild, and redeploy
- 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.
- 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.**
---
## 3. Verify BEFORE announcing it
## 3. Verify the containers
**a. Did the API start at all?**
```bash
docker compose logs api | grep -i "LDAP auth"
```
You want:
```
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
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)`. 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.
- 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. Promote the first admin
## 4. Create the first admin account
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:
The login system needs one admin user in the production (Postgres) database. Open the
**`wp_api`** container's **Console** (`/bin/sh`) and run:
```bash
docker compose exec api python -m server.manage_users promote <your-sAMAccountName>
python -m server.manage_users create-admin <username> --name "<Full Name>"
```
It asks for **your** domain username and password, binds to confirm who you are, and
prints `<user>: project_user -> admin`.
It prompts for a password (minimum 8 characters) and prints `Created admin: <username>`.
Other commands: `list` (needs no credential), `demote`, `disable`, `enable`.
`create-admin`, `create` and `reset-password` no longer exist.
Non-interactive alternative:
After that, admins manage everyone else from the in-app **Admin → User
administration** page. No further shell access needed.
```bash
python -m server.manage_users create-admin <username> --name "<Full Name>" --password "<password>"
```
Other CLI commands (run the same way): `list`, `create <user> --role user`,
`reset-password <user>`, `disable <user>`, `enable <user>`.
---
## What people will notice
## 5. Confirm it works
- 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.
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.
---
## Reference — what's in this release
- `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`.
- `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).

View File

@@ -69,17 +69,10 @@ BACKUP_ENC_PASSPHRASE=<strong-random-passphrase>
# details.
# SMTP_PASSWORD=<smtp-app-password>
# 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
# 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
```
The API builds its own DB connection string from the `POSTGRES_*`
@@ -91,34 +84,13 @@ ignored whenever the three `POSTGRES_*` values are present.
Generate a strong password with `openssl rand -base64 32`.
> **Portainer note:** for a Git-based stack the stack's **Environment variables**
> section is not merely an alternative to `.env` — it is the ONLY route, because
> Portainer does not read a local `.env` at all. Every value the compose file
> references as `${VAR}` has to be set there or it arrives empty.
>
> The full list, and what an empty one costs you:
>
> | Variable | Required? | If unset |
> |---|---|---|
> | `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` | **yes** | the stack will not start |
> | `AUTH_SECRET_KEY` | **yes** | compose fails fast; the API refuses to start |
> | `LDAP_REQUIRED_GROUP` | **effectively yes** | no group gate — **every account in the domain may sign in**. Silent: sign-in works, so nothing looks wrong. |
> | `BACKUP_ENC_PASSPHRASE` | before real data | dumps are written unencrypted |
> | `SMTP_PASSWORD` | only with email on | notifications are recorded and never sent |
> | `MICRON_DB_URL` | optional | the asset picker degrades to manual entry |
>
> Paste values raw — it is a form field, not a shell, so no surrounding quotes.
> Quotes are not stripped and become part of the value: a quoted
> `LDAP_REQUIRED_GROUP` will not resolve, and a quoted `MICRON_DB_URL` will not
> parse.
>
> **`MICRON_DB_URL` must be URL-encoded** (`@` → `%40`, `#` → `%23`, `/` → `%2F`)
> because it is a full connection URL. `LDAP_REQUIRED_GROUP` must NOT be encoded —
> it is an LDAP distinguished name, and its spaces and commas are legal as they are.
> **Portainer note:** for a Git-based stack these go in the stack's
> **Environment variables** section (Portainer doesn't read a local `.env`).
> Set `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` / `AUTH_SECRET_KEY` /
> `BACKUP_ENC_PASSPHRASE` (and `SMTP_PASSWORD`, if you enable email) there.
`POSTGRES_PASSWORD`, `AUTH_SECRET_KEY`, `BACKUP_ENC_PASSPHRASE`, `SMTP_PASSWORD` and
the password inside `MICRON_DB_URL` are the only credentials in the system, and none
of them appears in the compose file or in git.
These are the only credentials in the system, and they never appear in the
compose file or in git.
## 3. Point your reverse proxy at the nginx container
@@ -285,7 +257,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 (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 |
| `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 |
| `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) |
@@ -324,56 +296,6 @@ docker compose up -d --build webserver # front-end change (html/) — rebuild
docker compose up -d --build api # backend change (server/)
```
## Before deploying the D13 auth change — take a backup first
**Timing is the whole point.** The container's start command is
`alembic upgrade head && exec gunicorn`, so migrations run *seconds after you
redeploy*. Migration `b7e4f1a20c93` drops `users.password_hash`. There is no window
afterwards: back up **before** you redeploy, not after.
```bash
# 1. Record what you are rolling back TO. Do this first; it is easy to forget
# and impossible to reconstruct under pressure.
git -C /path/to/repo rev-parse --short HEAD
# 2. One-shot dump, using the sidecar that is already running.
docker compose exec backup sh /scripts/db-backup.sh
# -> ./backups/wpsuite-<UTC timestamp>.sql.gz[.enc] on the host
# 3. Take it OUT of the rotation. The scheduled job prunes to the newest
# BACKUP_KEEP (default 14) files matching wpsuite-*.sql.gz*, so on a daily
# cadence this dump is deleted in a fortnight. A prefix that does not match
# the glob is enough to protect it.
docker compose exec backup sh -c 'cd /backups && cp "$(ls -1t wpsuite-*.sql.gz* | head -1)" "pre-d13-$(ls -1t wpsuite-*.sql.gz* | head -1)"'
# 4. Prove it is readable BEFORE you deploy. An untested dump is not a backup.
docker compose exec backup sh -c 'openssl enc -d -aes-256-cbc -pbkdf2 -pass env:BACKUP_ENC_PASSPHRASE -in /backups/pre-d13-*.sql.gz.enc | gunzip -c | grep -c "INSERT INTO public.users"'
# (drop the openssl stage for an unencrypted .sql.gz)
```
### Two things about this particular dump
**It is the last copy of every password hash that will ever exist.** After the
migration the column is gone; this file is where those bcrypt hashes live from then
on. Make sure `BACKUP_ENC_PASSPHRASE` is set before step 2 — the script warns loudly
if it is not, and writes plaintext — and decide deliberately how long to keep the
file. bcrypt is not plaintext, but it is crackable offline given time and a copy.
**Restoring the database is not, by itself, a rollback.** The new code has no
`password_hash` in its model and the old code requires it, so a restore without a
matching code rollback leaves you with a schema and an application that disagree. A
real rollback is both, in this order:
```bash
# redeploy the commit from step 1 (Portainer: point the stack back and rebuild)
docker compose exec backup sh /scripts/db-restore.sh /backups/pre-d13-wpsuite-<ts>.sql.gz.enc
```
`db-restore.sh` dumps are taken with `--clean --if-exists`, so restoring **drops and
recreates** objects before loading. It overwrites whatever is currently there.
---
## Backups & retention
A **`backup` sidecar** (in `docker-compose.yml`) runs `pg_dump` on a schedule and
@@ -428,96 +350,27 @@ 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.
### Password reset — there isn't one
### Self-service password reset
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.
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).
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.
- 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.
## Permissions roles

View File

@@ -89,11 +89,6 @@ be built as written, or cannot be built once, until something else lands.
| 7 | The creator | `docs/waves/wave-7.md` | `B7` `A1` `CR-015` `A2` `A6` `CR-014` `CR-007` `B6` `S1`(creator) `F6` `D1` `D2` `D3` `D4` `D5` `D8` `D9` `D10` |
| 8 | Kitting and material | `docs/waves/wave-8.md` | `CR-009` `CR-010` `CR-011` `CR-012` `CR-013` `D6` `D10` |
| 9 | Verification and cleanup | `docs/waves/wave-9.md` | `CR-008` `CR-017` `S6` `S7` `C1` `C2` `C4` `D7` |
| 10 | Domain authentication over LDAPS | `docs/waves/wave-10.md` | `D13` |
Wave 10 was added on August 21, 2026 and is not part of the original nine-wave sequence. It
is new scope (`docs/waves/decisions-2026-08-21.md`), not a reinterpretation of anything
above, and it depends only on wave 9 being merged rather than on any particular item in it.
**Waves 1 through 4 produce almost no field-visible change.** That is deliberate and it is
roughly the first third of the effort. It is called out here because the Micron team is

View File

@@ -35,18 +35,6 @@ services:
# default and enabled from the Admin console; this is the only email
# secret and it is never stored in the DB. Leave unset until configured.
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
# D13 — domain authentication. REQUIRED: the suite stores no passwords and
# has no local fallback, so a wrong value here means nobody can sign in.
# Connect to the DOMAIN NAME, never a DC or an IP (SAN + DNS round-robin
# across six DCs). See server/ldap_auth.py and DEPLOYMENT.md.
LDAP_DOMAIN: ${LDAP_DOMAIN:-prime.local}
LDAP_HOST: ${LDAP_HOST:-prime.local}
# Trust anchor for the DC certificate — public CA certs, baked into the image
# at server/certs/. Override only to point at a mounted bundle.
LDAP_CA_FILE: ${LDAP_CA_FILE:-/app/server/certs/prime-ca-chain.pem}
# AD group required to sign in. Empty = any domain account. This is the
# initial value; the live one is set in the Admin console (T10.5).
LDAP_REQUIRED_GROUP: ${LDAP_REQUIRED_GROUP:-}
# Optional — read-only SQL Server connection to the Micron asset catalog,
# which backs the asset picker in the work package creator. Leave unset and
# the picker cleanly falls back to manual entry (see server/assets_db.py).
@@ -58,15 +46,10 @@ services:
condition: service_healthy # waits for postgres to accept connections
networks:
- internal
# Reaching the Micron database — and, since D13, the domain controllers —
# means leaving this compose project, and `internal` is deliberately
# egress-free. `outbound` is attached to the api container ONLY; the database
# and backup containers stay sealed.
#
# DO NOT DETACH THIS. It used to be optional ("detach it if you are not using
# the Micron asset picker"), but authentication now needs a route to
# prime.local:636. Without it every sign-in fails and there is no local
# password fallback to fall back to.
# Reaching the Micron database means leaving this compose project, and
# `internal` is deliberately egress-free. `outbound` is attached to the api
# container ONLY — the database and backup containers stay sealed. Detach it
# again if you are not using the Micron asset picker.
- outbound
db:
@@ -130,7 +113,7 @@ networks:
# An ordinary bridge network, i.e. one that HAS a default gateway. `internal`
# above removes the gateway entirely, which blocks not just the internet but
# the LAN and the VPN too — so the api container needs this second network to
# reach the domain controllers (LDAPS, D13) and the Micron asset database.
# Attached to `api` alone: `db` and `backup` remain on `internal` only and
# still have no way off the host. Required — see the note on the api service.
# reach the Micron asset database. Attached to `api` alone: `db` and `backup`
# remain on `internal` only and still have no way off the host.
# Detach it from api if you are not using the Micron asset picker.
driver: bridge

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -554,98 +554,3 @@ deliberately deferred.
entry rather than a drive-by.
- **Suggested wave or follow-up:** next housekeeping pass, with the check
widened so it cannot recur.
### BL-026 — CLOSED 2026-08-21 (removed; nothing referenced it)
- **Found during:** `T10.3` (D13), stripping the password code paths
- **Where:** `server/notify.py`, `send_now()`
- **What:** `send_now` sends one message immediately, outside the outbox queue. Its
only caller was `forgot_password`, because a reset link must not sit in a queue.
`T10.3` deleted that endpoint, so the function now has no callers anywhere in
`server/` or `tests/` — verified by grep, not assumed.
- **Resolution:** deleted. Raised as a judgement call between "remove it" and "keep
it as the documented immediate-send path"; answered on Aug 21 — remove it. Nothing
in `server/` or `tests/` referenced it, and its docstring explained itself entirely
in terms of password resets, which no longer exist. Keeping an unused sender that
bypasses the outbox is a liability, not an asset: the next person to need immediate
mail should write it against the requirement they actually have.
- **Note:** `send_email` (the raw SMTP call it wrapped) is untouched and still used by
the outbox.
### BL-027 — Okta exists on this estate; OIDC is a live alternative to the LDAPS bind
- **Found during:** `T10.6` (D13), repointing "Forgot password?" at
`https://primecontrols.okta.com/`
- **Where:** authentication as a whole — `server/ldap_auth.py`, `server/app.py` `login()`
- **What:** D13 chose an LDAPS simple bind, decided before it was known that the company
runs an Okta tenant. Okta presumably federates to `prime.local` (which is why the
Windows password is still the one that binds), but its existence means an OIDC
authorization-code flow is available in principle. That would be strictly better on
three counts the LDAPS design cannot match: this app would never see a password at all,
MFA would come for free, and the domain-lockout hazard that forced
`AUTH_MAX_ATTEMPTS` down to 2 would disappear entirely, because failed attempts would
land on Okta rather than on a bind this endpoint makes.
- **Why not now:** D13 was decided and reaffirmed, T10.1T10.4 are built and verified
against the live domain, and swapping the mechanism mid-wave is exactly the reordering
`CLAUDE.md` forbids. Recording it is not the same as reopening it.
- **Suggested wave or follow-up:** its own item and its own decision, with Nick and
whoever administers the Okta tenant. Not a widening of D13.
### BL-028 — `assets_check` fails on any machine that has `MICRON_DB_URL` set
- **Found during:** `T10.7` (D13), running the full suite
- **Where:** `tests/assets_check.py`, the "no `MICRON_DB_URL`" case
- **What:** the check asserts `/api/assets` answers `configured:false` when the catalog
is not configured, but `start_server` passes the ambient environment through. On a
developer machine whose `.env` sets `MICRON_DB_URL` — which is the normal state for
anyone who has ever used the asset picker — the API is genuinely configured, returns
real Micron tags, and three checks fail. Nothing is wrong with the app; the test's
premise is violated by the environment it runs in.
- **Fix:** SET `MICRON_DB_URL` empty for that server — do not pop it. `server/db.py`
calls `load_dotenv()` at import, and python-dotenv only skips keys already present in
`os.environ`, so a *popped* variable is restored from the developer's `.env` inside the
subprocess and the test runs against the real catalog anyway. An empty string counts as
present and therefore wins. `start_server` does exactly this for `LDAP_REQUIRED_GROUP`
(`T10.7`), after the pop-based version was caught doing the wrong thing.
- **Why not now:** it is not this wave's defect and the fix belongs with whoever owns
the asset picker's tests. Recorded so the failure is not mistaken for D13 fallout.
- **Suggested wave or follow-up:** next housekeeping pass.
### BL-029 — `generalinfo_check` flags a pre-existing `rgba()` in the creator stylesheet
- **Found during:** `T10.7` (D13), running the full suite
- **Where:** `html/wp-creation-styles.css:929` — `box-shadow:0 8px 24px rgba(20,30,50,.18)`
- **What:** `generalinfo_check`'s token-rule check reports "no colour literal was added
to the creator's stylesheet" and fails on `rgba(`. The literal predates this wave —
last touched by `8efe624` (F6) — and `git diff main...HEAD` shows the file untouched
by the LDAPS branch.
- **The real question is which is wrong.** `C4`'s recorded exception allows rgba
**alphas** as opacity recipes, which is arguably what a shadow is; if so the check is
too strict and should match a colour literal rather than the `rgba(` token. If not,
the shadow needs a token. Either way it is a one-line change plus a decision, and
the decision is not this wave's to make.
- **Why not now:** drive-by fixes to the token system are what `CLAUDE.md` forbids, and
this one needs the C4 exception interpreted rather than guessed.
- **Suggested wave or follow-up:** next housekeeping pass, with `C4` re-read first.
### BL-030 — `token_version` is now near-vestigial; decide whether it earns its place
- **Found during:** `T10.7` (D13), closing the done-when that assumed a role change bumps it
- **Where:** `server/models.py` (`User.token_version`), `server/auth.py` (`create_token`,
`get_current_user`), `server/manage_users.py` (`_set_active`)
- **What:** `token_version` existed to invalidate live sessions when a password changed.
D13 removed passwords, and **nothing in `app.py` bumps it any more** — not
`set_user_role`, not `set_user_active`. Nor do they need to: `get_current_user` loads
the account from the database on every request, so a role change or a deactivation
takes effect on the next request regardless. `T10.3`'s note that "role changes and
deactivation should bump it" describes an intention, not the code.
- **Its one remaining trigger** is the bump added to `manage_users._set_active` in
`T10.9`, which is belt-and-braces rather than load-bearing — `is_active` alone already
refuses the request.
- **The question:** is there still a case for invalidating a live cookie *without* also
disabling the account? If yes, wire it to something (a "sign out everywhere" control is
the usual shape) and say so. If no, the column, the claim, and the check are three
places carrying a mechanism nothing triggers.
- **Why not now:** it is a design question about session handling, not an auth-wave bug,
and the mechanism works correctly — it is exercised in `ldap_auth_check`.
- **Suggested wave or follow-up:** next housekeeping pass.

View File

@@ -1,210 +0,0 @@
# Decisions — August 21, 2026
One item, and it is the largest single change to the auth model since the login portal
shipped. Like the August 18 and August 20 sets it is a **new item** with its own `D` id,
not a reinterpretation of an existing one. `D1``D12` are taken; this is `D13`.
Raised by Cody Schaefer on Aug 21 2026 while asking how the suite handles HTTPS. Nothing in
`IMPLEMENTATION.md`, in `CR`/`F`/`S`/`A`/`B`/`C`, or in `docs/waves/backlog.md` covers
authentication against the domain — so this is new scope, and it gets a new ID rather than
being folded into the login-portal work that produced `server/auth.py`.
---
## D13 — Authentication moves to the domain over LDAPS
- **Amends:** the authentication model shipped in `DEPLOY-login-portal.md` (bcrypt hashes in
`users.password_hash`, verified in-process). That document describes what is being
replaced, not what is wrong — it was correct for a suite with no directory behind it.
- **Surface:** `server/` (`auth.py`, `app.py`, `models.py`, `manage_users.py`, `notify.py`,
a new `ldap_auth.py`, a new migration), `html/` (`login.html`, `login.js`, `admin.js`,
`users.js`), `requirements.txt`, `docker-compose.yml`, `Dockerfile`, deployment docs.
- **Wave:** 10 (`docs/waves/wave-10.md`). Depends on wave 9 merged, which it is.
### The decision
The suite stops storing passwords. A sign-in becomes a **simple bind to
`ldaps://prime.local:636`** as `<username>@prime.local` using the password the person typed.
A successful bind is the authentication. `users.password_hash` is dropped from the schema.
Four parts, all four required for the item to be done:
1. **LDAPS bind replaces local password verification.** `password_hash` is removed from the
model and from the database by migration. No password is stored, hashed or otherwise.
2. **Accounts are provisioned just-in-time.** A successful bind for a username with no
`users` row creates one, at the default role, with `full_name`/`email` read from the
directory.
3. **A required group gates login.** An AD group is configured; a bind that succeeds but
whose account is not in that group is refused. Membership is evaluated including nested
groups.
4. **Existing accounts keep their roles, and roles stay local.** An existing `admin` stays
an admin on first directory login. Granting admin to an existing account continues to
work from the Admin console. The directory supplies *identity*; this app supplies
*authorization*.
### Why LDAPS and not the certificate already in play
Recorded because the question was asked directly and the answer is not obvious.
The site's serving certificate is a **Let's Encrypt** cert (`CN=wp.controls.dev`, issued by
`Let's Encrypt YE2`, expiring 2026-11-08) held by an **OpenResty** instance at
`192.168.3.56` that is not part of this repo. It is a public domain-validated certificate.
It attests that whoever presented it controls DNS for `wp.controls.dev`; it carries no user
identity and no relationship to `prime.local`. There is no configuration that turns it into
a domain credential, so cert-based auth was never available "for free".
Client-certificate auth (mTLS) was considered and rejected for this wave: TLS terminates two
hops upstream at OpenResty, so the API never sees the handshake, and doing it in-app would
mean bypassing the proxy and losing the CSP/HSTS headers and static serving with it.
### What was verified before writing this (Aug 21 2026)
| Fact | Value |
|---|---|
| LDAPS reachable | `192.168.3.37:636` open, TLS 1.3, `TLS_AES_256_GCM_SHA384` |
| DC cert issuer | `CN=PRIME CONTROLS ISSUING CA 1, DC=prime, DC=local` |
| Root of that chain | `CN=PRIME CONTROLS ROOT CA` (self-signed, expires 2051-09-09) |
| Issuing CA expiry | 2036-09-09 |
| DC cert SAN | `DR-DC10Core.prime.local`, `prime.local`, `PRIME` |
| DCs published in `_ldap._tcp.prime.local` | six — `nla-dc10`, `lew-dc20`, `dr-dc30-core`, `lew-dc40`, `SABINEDC`, `dr-dc10core` |
| Chain validates against root+issuing bundle | yes — `Verify return code: 0 (ok)` |
Two consequences of that table, both binding on the build:
- **Connect to `prime.local`, not to a DC name 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. Verified: `prime.local` gives `0 (ok)`; the raw IP
`192.168.3.37` gives `62 (hostname mismatch)`, because there is no IP SAN.
- **The trust anchor is a CA certificate, not a certificate issued to this app.** The API is
the TLS *client*; clients present nothing. It needs `PRIME CONTROLS ROOT CA` plus
`PRIME CONTROLS ISSUING CA 1` as a PEM bundle, which is public information. No CSR, no
enrollment, no private key, nothing to request from IT.
### Non-negotiables
These are the ways this change goes wrong, and each has a done-when check in wave 10.
- **An empty password must be rejected before `bind()` is called.** In LDAP a simple bind
with an empty password is an *anonymous* bind and it **succeeds**. Without an explicit
guard, a blank password authenticates as any username submitted. This is the single
highest-severity failure mode in the item and it gets its own test.
- **`validate=ssl.CERT_REQUIRED` with an explicit CA file.** Not `CERT_NONE`, and not the
system trust store. `CERT_NONE` still encrypts, so it fails silently — what it loses is
the ability to distinguish the real DC from an attacker who terminates the TLS session,
harvests the domain password and relays the bind onward. Since domain credentials now
cross that channel, a compromise escalates from "this app" to Windows, mail and file
shares. The system store is refused separately because it currently trusts five other
self-signed CAs (`prime-DR-CAPRIME-CA`, `prime-DR-CA_PRIME-CA`, `prime-DR-DC20-CA`,
`PRIME CONTROLS ISSUING CA 2`, and a stray `L55401TDKLY3.prime.local` machine cert in
Trusted Root).
- **The app's lockout must trip below the domain's.** `LOGIN_MAX_ATTEMPTS` currently writes
to the local `users` row. Once failures are binds, they count against the **AD** lockout
policy, so an unauthenticated caller hammering `/api/auth/login` can lock real domain
accounts out of Windows. The local throttle must stop calling the DC before the domain
threshold is reached.
- **Never leak which usernames exist.** `login()` today equalises response timing on purpose
so a caller cannot enumerate accounts. Directory error 49 sub-codes (`52e` bad password,
`532` password expired, `533` disabled, `775` locked) are useful in the log and must not
reach the response body.
### Answered August 21, 2026 — both were raised as open and both were decided
**Break-glass: none. LDAPS is the only way in.** Asked and reaffirmed after the lockout risk
was stated. There is no emergency local account, no env-var bypass, and no CLI-minted
session. The consequence is explicit and belongs in the runbook rather than being discovered:
**if the domain is unreachable, or `LDAP_CA_FILE` is wrong, or the required group is
misconfigured, nobody can sign in — including admins — and no amount of shell access fixes
it except correcting the configuration and restarting.** `T10.5`'s validate-on-save guard is
therefore not a nicety; with no fallback it is the only thing standing between a typo in the
group field and a total outage.
Three things follow, and they are done-when checks in wave 10 rather than advice:
- The startup log must state whether LDAP is configured and reachable, so a broken deploy is
visible in `docker compose logs api` and not only at the login box.
- `/api/health` stays exempt from auth (it already is) so the outage is diagnosable.
- The group setting cannot be saved without proving the saving admin is a member.
**Identity: bind on `sAMAccountName`, match on `sAMAccountName` *or* `mail`.** A simple bind
can only carry one identifier, and AD accepts the UPN form — so the bind is
`sAMAccountName@prime.local` and that is what the login box takes. Matching an existing local
row is a separate question, and it uses **both**: after a successful bind the directory's
`sAMAccountName` and `mail` are both read, and `auth.find_user` is extended to match a local
row on either, case-insensitively. That is what keeps an existing admin's role whether their
hand-typed username was `c.schaefer` or `c.schaefer@prime-controls.com`.
Two consequences worth knowing:
- The mail domain (`prime-controls.com`) is not the AD domain (`prime.local`), so `mail` is
never a valid bind string. It is a matching key only.
- If someone types an address at the login box, the local part is used as the
`sAMAccountName`**one** bind attempt, never several, because each failed bind counts
against the domain lockout policy. That assumes the mail local part equals the
`sAMAccountName`. Where it does not, the person must type their short logon name; this is
logged when it happens and documented in `T10.8`.
- The production `users` table should still be compared against AD before this deploys. A
row matching on neither key gets a *second*, JIT-provisioned account at the default role
rather than keeping its admin. Matching on two keys narrows that risk; it does not remove
it.
### Explicitly out of scope
- mTLS / client-certificate authentication (see above).
- Kerberos / SPNEGO single sign-on. It is the better long-term answer for domain-joined
desktops and needs a keytab, an SPN and browser trust configuration; it is not this item.
- Group-to-role mapping (e.g. an AD group that confers `project_admin`). Criterion 4 keeps
authorization local on purpose. Worth its own item later; logged in `backlog.md`.
- Replacing the Let's Encrypt certificate or changing anything on the OpenResty host.
---
## D14 — The CLI authenticates against the domain, and stops creating accounts
- **Amends:** `D13` criterion 4, which said role granting keeps working *from the Admin
console*. It said nothing about `manage_users.py`, which had no authentication of any
kind. Requiring one is a new requirement, so it gets its own id rather than widening
criterion 4.
- **Surface:** `server/manage_users.py`
- **Task:** `T10.9`
### The decision
1. **`create-admin` and `create` are removed.** `D13` provisions accounts on first
successful sign-in, so creating them by hand is redundant. Removing them also closes a
class of problem: every row now originates from a bind, so a username cannot be typed
in wrong and end up orphaned from the directory identity it was meant to match. That
risk now applies only to rows the old CLI already created.
2. **`promote` and `demote` replace them.** The directory supplies identity; this app
supplies authorization, and this is where authorization is assigned from a shell.
3. **Every state-changing command requires a domain bind.** Prompted, via `getpass`.
There is deliberately no `--password` flag: that would put a live domain password into
shell history and into `ps` output for every other user on the box.
4. **`list` needs no credential**, so an outage stays diagnosable.
### Bootstrapping the first admin, which changed shape
Two steps, in order: **sign in once** (which provisions the account at `project_user`),
then **`promote <sAMAccountName>`**. Before D14 the first admin was created with a
password; there is no password now, and no account to create.
### What this is worth, stated plainly
Anyone with a shell on the api container can still write to the `users` table directly
with `psql` or `sqlite3`. So the bind is **defence in depth and, mostly,
ACCOUNTABILITY** — not a security boundary. Before D14 every role change made from a
shell was invisible in `AuditLog` while the same change through the console was recorded;
now both are recorded and both name a person. Any-domain-user was accepted as sufficient
(no privileged group exists on this estate, and machine access is already restricted to a
few people), which was decided knowing the above.
### Two deliberate divergences from the API
- **The bind does NOT apply the login group gate.** If a mistyped required group locks
everyone out of the console, this tool has to still work — otherwise the only route to
fixing the lockout is the thing the lockout prevents.
- **Changing your OWN role is permitted here.** `set_user_role` in `app.py` forbids it to
stop an admin locking themselves out of the console. Here it is the entire bootstrap
path, so it is allowed and recorded with `{"self": true}` in the audit detail.
The last-admin guard is kept, matching `set_user_role`: an app with no admin cannot be
administered, and there is no password login left to recover through.

View File

@@ -1,399 +0,0 @@
# Wave 10 — Domain authentication over LDAPS
**Items:** `D13`, `D14`
**Depends on:** wave 9 merged (it is — `a8e28bf`)
One item, eight tasks. The item is stated in `docs/waves/decisions-2026-08-21.md`; read it
before starting, particularly the **Non-negotiables** section, which is where this change
goes wrong if it goes wrong.
**This wave is field-visible in one direction only.** Nobody gains a screen. People sign in
with their Windows password instead of an app password, the "Forgot password?" link
disappears, and admins stop issuing passwords. Everything else is invisible — which means
the done-when checks are the only evidence the wave worked.
> **BEFORE PRODUCTION: `LDAP_REQUIRED_GROUP` must be set.** It is empty by default,
> and empty means *no group gate* — every account in `prime.local` may sign in. The
> successful live sign-in on Aug 24 was made without it, so it proved the bind, the
> certificate chain and JIT provisioning, but **not** the group check: that path never
> executed. The group gate is covered against the fake directory in `ldap_auth_check`
> and has never run against the real directory. The startup line says which state you
> are in — `required group: (none configured — every domain account may sign in)`.
**Build order is task order** for once. `T10.1` is a standalone module with no callers and
should merge first; nothing else can be tested until it exists.
---
### T10.1 — The LDAPS client
- **Items:** `D13` (1)
- **Depends on:** nothing
- **Blocks:** T10.2, T10.4, T10.5, T10.7
- **Surface:** `server/`
- **Files:** new `server/ldap_auth.py`, `server/requirements.txt`, `docker-compose.yml`,
`Dockerfile`, `server/.env.example`
**Problem:** There is no directory client in the repo. `ldap3` is not a dependency, the API
container has no CA bundle, and the `api` service sits on the `internal` network, which has
no default gateway and therefore no route to `192.168.3.x` at all.
**Do:** Add `ldap3` (pinned, per the convention at the top of `requirements.txt`). Write
`server/ldap_auth.py` exposing two functions and nothing else:
- `verify(username, password) -> LdapResult | None` — simple bind as
`f"{username}@{domain}"` against `ldaps://{host}:636`.
- `member_of(conn, group) -> bool` — nested-group aware, via
`LDAP_MATCHING_RULE_IN_CHAIN` (`1.2.840.113556.1.4.1941`). `memberOf` alone is direct
membership only and will wrongly refuse anyone in a nested group.
Configuration by environment: `LDAP_HOST` (default `prime.local`), `LDAP_DOMAIN` (default
`prime.local`), `LDAP_CA_FILE`, `LDAP_REQUIRED_GROUP`, `LDAP_TIMEOUT_SECONDS`. Attach the
`outbound` network to `api` in `docker-compose.yml` — the same reason `assets_db.py` needed
it, and the comment there already explains the gateway-less `internal` network. Mount or
`COPY` the PEM bundle and point `LDAP_CA_FILE` at it.
Connect to **`prime.local`**, never a DC hostname and never an IP. See the decision doc for
why: SAN coverage plus round-robin across six DCs in one move. Wrap the bind in a retry
across resolved addresses, because round-robin will hand out a rebooting DC's address.
**Done when:**
- [x] `ldap3` is pinned to an exact version in `requirements.txt`
- [x] an empty or whitespace-only password returns failure **without calling `bind()`** — proven with `Connection` nulled, so any call to `bind()` would raise
- [x] an empty username returns failure without calling `bind()`
- [x] `Tls` is constructed with `validate=ssl.CERT_REQUIRED` and an explicit `ca_certs_file`
- [x] no code path sets `CERT_NONE`, and none falls back to the system trust store — asserted by an AST walk in `ldap_auth_check`, not a grep: the docstring names it to explain the ban
- [~] `member_of` returns true for an account in a **nested** child of the required group — **partially closed Aug 24.** The transitive matching rule was exercised against the live directory for a DIRECT member (`CN=Prime Employees`, 2,003 members) and returned a match, so the rule and the filter are correct on this estate. A genuinely NESTED case (member of a group that is a member of the required group) still has no test, because no such account was to hand. `member_of` now also warns loudly if the transitive query returns nothing where a direct check succeeds, which is how a broken nested lookup would announce itself rather than refusing real members silently.
- [x] a bind against `192.168.3.37` (raw IP) fails hostname validation rather than silently passing — `selftest()` to the raw IP returns `untrusted`; to `prime.local`, `0 (ok)`
- [ ] `docker compose exec api openssl s_client -connect prime.local:636 -CAfile $LDAP_CA_FILE` reports `Verify return code: 0 (ok)`
- [x] the module imports cleanly with no LDAP env set (unconfigured is a first-class state, as with `MICRON_DB_URL`)
---
### T10.2 — The login path binds instead of hashing
- **Items:** `D13` (1)
- **Depends on:** T10.1
- **Blocks:** T10.3, T10.4
- **Surface:** `server/`
- **Files:** `server/app.py` (`login`), `server/auth.py`
**Problem:** `login()` at `server/app.py:681` calls `auth.verify_password` against
`user.password_hash`. The lockout counter it maintains is about to start counting *domain*
bind failures, which changes what that counter is for.
**Do:** Replace the credential check with `ldap_auth.verify`. Keep the surrounding shape —
the deliberate timing equalisation, the generic `401`, the `403` for a disabled local
account, `last_login_at`, the session cookie. Rework the throttle so the local counter trips
**before** the domain policy is reached and short-circuits without calling the DC: the
failure mode to avoid is this endpoint being usable to lock domain accounts out of Windows.
Log directory error-49 sub-codes for diagnosis; return the same generic message regardless.
**Break-glass: none — decided Aug 21, see the decision doc.** LDAPS is the only way in, so
this task adds no fallback path. What it must add instead is *visibility*: a startup log line
stating whether LDAP is configured and whether the DC answered, because with no fallback a
misconfigured deploy is indistinguishable from a forgotten password at the login box.
**Done when:**
- [x] the API logs one line at startup saying whether LDAP is configured and which group gates sign-in — configuration only, so an unreachable DC cannot hang startup. This is what `DEPLOYMENT.md`, `DEPLOY-login-portal.md` and `server/README.md` all send people to first; it was specified in this task on Aug 21 and not actually written until Aug 24.
- [x] a correct domain password signs in and sets the session cookie — confirmed against the live domain Aug 24, and in `ldap_auth_check`
- [x] a wrong password is refused with the generic message
- [x] a blank password is refused (guards `T10.1` from the caller's side too)
- [x] a user not in the required group is refused even though the bind succeeded
- [x] `is_active = false` locally still refuses, independent of the directory — a disabled account is refused 403 even though the bind succeeds
- [x] the local throttle trips below the domain lockout threshold and stops calling the DC — proved by presenting a CORRECT password once the budget is spent: a 429 for a credential that would otherwise work is only possible if the throttle runs before the directory is consulted
- [x] no response body distinguishes "no such user" from "wrong password"
- [x] error-49 sub-codes appear in the log and nowhere in any response — asserted both ways: `_err49` parses a real AD message, and no sub-code appears in any response body
---
### T10.3 — Drop `password_hash`
- **Items:** `D13` (1)
- **Depends on:** T10.2
- **Blocks:** T10.6, T10.8
- **Surface:** `server/`
- **Files:** `server/models.py`, new migration, `server/app.py`, `server/auth.py`,
`server/manage_users.py`
**Problem:** With binds doing the work, every password code path is dead weight and a
liability. It is also the criterion that makes this item irreversible, so it lands on its own
commit.
**Do:** Remove `password_hash` from `models.User` and drop the column in a new Alembic
revision with `down_revision = 'a1b8c6d4e2f9'` (the current head — confirm with
`alembic heads` rather than trusting this line). Remove from `auth.py`: `hash_password`,
`verify_password`, `password_problem`, `MIN_PASSWORD_LEN`, `_COMMON_PASSWORDS`,
`create_reset_token`, `decode_reset_token`, `RESET_MINUTES`. Remove from `app.py`:
`/api/auth/forgot-password`, `/api/auth/reset-password`, `/api/auth/reset-available`,
`/api/auth/password`, `/api/auth/users/{user_id}/password`, and the `_reset_last` throttle
with `reset_body`. Remove `reset-password` from `manage_users.py` and the password prompt
from `create` / `create-admin`.
`token_version` **stays.** It is still the session-revocation mechanism — role changes and
deactivation should bump it even though password changes no longer exist.
**Do not** remove the account-management endpoints themselves. `/api/auth/users/{id}/role`
is criterion 4 and must keep working.
**Done when:**
- [x] `grep -rn "password_hash\|hash_password\|verify_password\|password_problem" server/` returns nothing outside the migration — only the migration and one docstring naming the dropped column
- [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`)
- [x] the migration's `downgrade()` recreates the column nullable, not `NOT NULL` — there are no hashes to put back — verified on SQLite and Postgres
- [x] `token_version` still invalidates an already-issued session — but **not** via a role change, which was the wrong premise. Nothing in `app.py` bumps it any more: `get_current_user` re-reads the account every request, so `role` and `is_active` changes take effect immediately without it. Its one remaining trigger is `manage_users` on disable. Tested by bumping it directly: the old cookie 401s and every other session is untouched. See `BL-030`.
- [x] `manage_users.py list`, `disable`, `enable` still work; `reset-password` is gone
- [x] no CLI command prompts for a password — superseded by `T10.9`, which removed `create-admin` and `create` outright; the first admin is now bootstrapped by signing in and then `promote`
---
### T10.4 — Just-in-time provisioning, without trampling existing accounts
- **Items:** `D13` (2, 4)
- **Depends on:** T10.2
- **Blocks:** T10.7
- **Surface:** `server/`
- **Files:** `server/app.py` (`login`), `server/auth.py`
**Problem:** Criteria 2 and 4 pull in opposite directions. Provisioning on first login must
create accounts that do not exist, and must not touch the role of accounts that do — an
existing `admin` signing in for the first time after this wave must still be an admin
afterwards.
**Do:** On a successful bind that passes the group check, look the account up with
`auth.find_user` (already case-insensitive across username **and** email). If it exists,
update only `last_login_at` and — if empty locally — `full_name` and `email` from the
directory. **Never write `role`.** If it does not exist, create it at
`ROLE_PROJECT_USER` with `full_name`/`email` from the directory.
**A JIT account gets NO project access, and that is deliberate.** An earlier draft of this
task said to honour the `auto_add_projects` machinery so a new account "lands in the right
projects" — that was wrong about how the flag works. `auto_add_projects` is evaluated when a
**project** is created (`app.py:245`), marking accounts that should join every *new* job; it
cannot retroactively add a new account to existing ones. There is no correct default, so
least privilege applies: the account exists, can sign in, and sees nothing until someone
grants access. That is a real UX cliff — a successful sign-in into an empty app — so it has
to be visible to admins rather than silent, which is what the `AuditLog` row is for.
Note the flush-order warning in the `models.py` docstring: `create_user` in `app.py` handles
account-then-membership correctly in one flush — follow it if you add rows.
Write an `AuditLog` row for each JIT creation. An account appearing without an administrator
creating it is exactly the kind of event that record exists for.
**Done when:**
- [x] an unknown username with a valid bind and group membership gets a `users` row at `project_user`
- [x] `full_name` and `email` are populated from the directory on creation
- [x] an existing `admin` signing in is still `admin` afterwards — asserted, not assumed — asserted in `ldap_auth_check` against a real server
- [x] an existing account with a locally-set `full_name` does not have it overwritten
- [x] a JIT account has NO `ProjectMember` rows and sees no projects
- [x] the new account appears in the Admin console user list so access can be granted — asserted through `GET /api/auth/users`, the request the console makes
- [x] each JIT creation writes an `AuditLog` row
- [x] a failed bind creates **no** row
- [x] a bind that succeeds but fails the group check creates **no** row
---
### T10.5 — CLOSED, NOT BUILT (Aug 24 2026): the required group stays an env var
- **Items:** `D13` (3)
- **Status:** **won't build.** `LDAP_REQUIRED_GROUP` in the environment is the answer.
**Do not build this later by reading the original task and assuming it was skipped.**
It was proposed, examined and rejected on purpose, and the reasoning is below.
**What it was going to be:** the required group moved out of the environment into an
Admin console setting, with a validate-on-save guard that resolved the group in the
directory and confirmed the saving admin was a member.
**Why it is not being built:**
1. **The console requirement was invented here, not asked for.** D13 criterion 3 says
*"An AD group is configured"* — not "configurable from the console". The env var
satisfies the criterion as written.
2. **The validate-on-save guard existed only to defend against a risk the console
itself introduced.** A feature whose complexity exists to defend against itself is
usually the wrong feature.
3. **The lockout scenario it defended against is already handled.** A group that does
not resolve raises `LookupError` in `member_of`, which `verify()` maps to
`GROUP_NOT_FOUND`, which `is_config_problem` classifies as ours — so `login()`
answers **503**, not 401, and the log says *"required group 'X' does not resolve in
DC=prime,DC=local — refusing the sign-in. This is a configuration fault, not a bad
password."* A genuine non-member still gets 401. The two are already distinguishable
in both the log and the response.
4. **A redeploy is deliberate and reviewable; a text box is not.** The group is set
once and effectively never changes — it is not SMTP configuration.
5. **The console version creates a circular failure.** Fixing a lockout would require
the console the lockout prevents you from reaching. Editing the env var does not.
**What is genuinely lost, and accepted:**
- **Discoverability.** An app admin cannot see which group is required without
Portainer or shell access. A read-only line in the Admin console diagnostics would
give the useful half without the dangerous half; it was offered and declined on
Aug 24 as not needed.
- **Deploy-time validation.** Nothing confirms the group resolves until the first
sign-in attempt. This is not fixable: resolving a group needs an authenticated
search, anonymous bind is disabled on this estate, and there is no service account
by design. The first-attempt 503 is the earliest possible detection.
### T10.6 — Strip the password UI
- **Items:** `D13` (1)
- **Depends on:** T10.3
- **Blocks:** nothing
- **Surface:** `html/`
- **Files:** `html/login.html`, `html/login.js`, `html/admin.js`, `html/users.js`,
`html/auth-guard.js`
**Problem:** `login.html` has three views — sign-in, forgot-password, set-new-password — and
two of them now point at endpoints that no longer exist. `auth-guard.js` has a
change-password dialog, and the user-admin UI has a "reset password" action per row.
**Decided Aug 21: "Forgot password?" is KEPT and repointed at
`https://primecontrols.okta.com/`.** An earlier draft of this task removed the link, and a
plain sentence saying "contact IT" was proposed instead. Okta is the better answer — it is a
real self-service path, and with no app password and no break-glass it is the only recovery
route that exists. Note this is the first sign of an Okta tenancy on this estate; see the
new backlog entry.
Mechanics that matter: it is a plain `<a href>`, not a form post, so the `form-action 'self'`
in the CSP does not apply and no `navigate-to` directive is set — off-origin link navigation
is allowed as-is. `target="_blank"` needs `rel="noopener noreferrer"`, and there must be NO
click handler on `#forgot-link`: the old one called `preventDefault()` to swap views, and
leaving it would silently swallow the navigation.
**Do:** Remove the `#view-forgot` and `#view-reset` sections and the reset-token handling in
`login.js`, and repoint `#forgot-link` as above. Remove the change-password dialog from `auth-guard.js`
(`backlog.md:180` refers to it) and the per-row password reset from the users UI. Keep the
sign-in form, and relabel the password field's hint to say it is the Windows/domain password
— people need to know which password to type.
Keep the role-granting controls exactly as they are. That is criterion 4.
**Done when:**
- [x] `grep -rn "forgot\|reset-password\|new-password" html/` returns nothing but prose
- [x] "Forgot password?" opens `https://primecontrols.okta.com/` in a new tab — by inspection of the markup: `target="_blank"` with `rel="noopener noreferrer"`
- [x] `#forgot-link` has NO click handler (a `preventDefault()` would swallow the navigation)
- [x] a 503 from the login endpoint says sign-in is unavailable, not that the password is wrong
- [x] the sign-in form still submits, and a failure still announces through `role="alert"` (`login.html` already does this correctly — do not regress it) — `url_state_check` drives the real form end to end; the `role="alert"` region is untouched
- [x] the password field says which password to enter — see the screenshots
- [x] no dead `<a href="#">` or handler remains for a removed view
- [x] granting admin to an existing user still works from the console — asserted through `POST /api/auth/users/{id}/role`, the request `users.js` sends, and the role really changes
- [x] exercised at 390px and at 1440px, screenshots in the PR — `docs/reference/baseline/before-wave10/` and `after-wave10/`, captured Aug 24 with `tests/baseline_shots.py`. "Before" comes from a detached worktree at `main` (`a8e28bf`) so each half was shot against its own server.
- [x] no raw hex added to any stylesheet (the token rule) — no CSS was added at all — the hint reuses the `.hint` class the page already had
---
### T10.7 — Make the suite testable without a domain controller
- **Items:** `D13` (1, 2, 3)
- **Depends on:** T10.1, T10.4
- **Blocks:** T10.8
- **Surface:** `tests/` + `server/`
- **Files:** `server/ldap_auth.py`, `tests/browser_check.py`, `tests/launcher_check.py`,
`tests/console_dialogs_check.py`, `tests/url_state_check.py`, `server/smoketest.py`,
`server/seed_demo.py`, new `tests/ldap_auth_check.py`
**Problem:** This is the task most likely to be underestimated. Four existing checks build
users with `password_hash=auth.hash_password(PW)` and sign in over HTTP;
`console_dialogs_check.py` drives the password-reset prompt specifically. `smoketest.py` and
`seed_demo.py` both sign in. None of them can reach a DC, and CI has no domain.
**Do:** Make the bind injectable — a module-level seam in `ldap_auth.py` that a test can
substitute (a fake directory: usernames, passwords, groups, full names), selected by an env
var that is refused when a real `DATABASE_URL` is configured, mirroring how
`auth._load_secret` refuses an ephemeral key in production. Port the four checks onto it.
Delete the password-reset half of `console_dialogs_check.py` — the flow it covers no longer
exists — and say so in the PR rather than leaving a skipped test.
Add `tests/ldap_auth_check.py` covering the non-negotiables from the decision doc: empty
password, empty username, `CERT_REQUIRED` asserted by inspecting the constructed `Tls`,
nested group membership, group-check refusal creating no user, and existing-admin role
preservation.
**Done when:**
- [x] the stub backend cannot be selected when a non-SQLite `DATABASE_URL` is set — asserted by a test
- [x] `tests/ldap_auth_check.py` covers the cases above — 20/20
- [x] the empty-password case is proved by nulling `Connection`, so any call to `bind()` would raise — it asserts the guard returns *before* the transport, not merely that the result is a failure
- [x] `smoketest.py` and `seed_demo.py` document which credentials they now need (T10.8)
- [x] the removed password-reset checks are called out, not silently dropped — `console_dialogs_check.py`'s docstring records the coverage loss and where the prompt kit is still covered
- [x] the full `tests/` suite passes with no DC reachable — **1284/1288 checks across 40 files**, Aug 24. Two files fail, both proven pre-existing and unrelated (`BL-028` `assets_check`, `BL-029` `generalinfo_check`): `git diff main...HEAD` shows this branch touches neither file. `token_check.py` is not a member of the suite — it is a capture/diff tool that requires `--out` or `--compare`, and a sweep script that runs it bare gets a usage message and exit 2.
**Scope note.** This task was estimated as far larger than it turned out to be. The
premise was that four checks sign in and would all need the seam; in fact `seed()` mints
a token with `auth.create_token()` and sets the cookie directly, so **no** browser check
signs in except `url_state_check`'s deep-link case. `browser_check` and `launcher_check`
needed one kwarg deleted each — and since 39 files import `seed`/`start_server` from
`browser_check`, that single line unblocked nearly the whole suite.
---
### T10.8 — Documentation and the deploy runbook
- **Items:** `D13`
- **Depends on:** T10.3, T10.5, T10.7
- **Blocks:** nothing
- **Surface:** docs
- **Files:** `DEPLOYMENT.md`, `server/README.md`, `DEPLOY-login-portal.md`, `CLAUDE.md`,
`IMPLEMENTATION.md`
**Problem:** `DEPLOY-login-portal.md` documents creating the first admin with a password and
is the page an admin will reach for. `DEPLOYMENT.md` describes `AUTH_SECRET_KEY` and SMTP but
knows nothing about a directory. `CLAUDE.md`'s verification section tells anyone touching the
frontend to run the smoke test, which changes here.
**Do:** Document the LDAP variables, how to produce the CA bundle from the two thumbprints
in the decision doc, and the `prime.local`-not-an-IP rule with the reason. Add the
`openssl s_client -CAfile` check as the first-line diagnostic. Rewrite the
`DEPLOY-login-portal.md` bootstrap step: the first admin is now an existing directory account
promoted with `manage_users.py`, not an account created with a password. State plainly what
happens when the DC is unreachable, whatever `T10.2` decides.
**Done when:**
- [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
---
### T10.9 — D14: the CLI authenticates, and stops creating accounts
- **Items:** `D14`
- **Depends on:** T10.3
- **Blocks:** T10.8
- **Surface:** `server/`
- **Files:** `server/manage_users.py`
**Problem:** `manage_users.py` writes to the `users` table with no authentication at all.
It also still offers `create-admin` / `create`, which are redundant now that accounts
provision themselves — and worse than redundant, because a hand-typed username can end up
matching no directory identity.
**Do:** As stated in `D14`. Remove the two create commands, add `promote` / `demote`, gate
every state-changing command on a prompted domain bind, and write an `AuditLog` row naming
the operator. Write the audit row by hand rather than importing `log_event` from `app.py`
that would pull FastAPI and the whole application into a CLI startup for one INSERT.
**Done when:**
- [x] `create-admin`, `create` and `reset-password` are rejected as invalid choices
- [x] `list` works with no credential and with no LDAP configured
- [x] a state-changing command with LDAP misconfigured refuses instead of proceeding
- [x] there is no `--password` flag on any command
- [x] `promote` raises a role; `demote` returns an account to `project_user`
- [x] promoting YOURSELF is allowed and recorded with `self: true`
- [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
- [x] verified against a real domain bind — `promote` and `demote` confirmed working Aug 24 2026

View File

@@ -60,6 +60,63 @@
.then(function () { window.location.replace('login.html'); });
};
// Change-password dialog (uses POST /api/auth/password, which requires the
// current password). Available from the top-right pill on any page.
window.wpChangePassword = function () {
if (document.getElementById('wp-pw-modal')) return;
var ov = document.createElement('div');
ov.id = 'wp-pw-modal';
ov.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;' +
'justify-content:center;z-index:10002;padding:20px;font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
var inp = 'width:100%;padding:9px 10px;margin-bottom:12px;border:1px solid var(--cds-border-strong);border-radius:4px;font-size:14px;';
var lbl = 'display:block;font-size:12px;color:var(--cds-text-secondary);margin-bottom:4px;';
ov.innerHTML =
'<div style="background:var(--cds-layer);color:var(--cds-text-primary);border-radius:10px;max-width:380px;width:100%;box-shadow:0 12px 40px rgba(20,30,50,.3);overflow:hidden;">' +
'<div style="padding:14px 18px;border-bottom:1px solid var(--cds-border-subtle);font-weight:700;">Change password</div>' +
'<div style="padding:16px 18px;">' +
'<div id="wp-pw-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin-bottom:12px;"></div>' +
'<label style="' + lbl + '">Current password</label>' +
'<input id="wp-pw-cur" type="password" autocomplete="current-password" style="' + inp + '">' +
'<label style="' + lbl + '">New password (at least 12 characters)</label>' +
'<input id="wp-pw-new" type="password" autocomplete="new-password" style="' + inp + '">' +
'<label style="' + lbl + '">Confirm new password</label>' +
'<input id="wp-pw-new2" type="password" autocomplete="new-password" style="' + inp + 'margin-bottom:0;">' +
'</div>' +
'<div style="padding:12px 18px;border-top:1px solid var(--cds-border-subtle);display:flex;gap:8px;justify-content:flex-end;">' +
'<button type="button" id="wp-pw-cancel" style="padding:8px 14px;border:1px solid var(--cds-border-strong);background:var(--cds-layer);border-radius:6px;cursor:pointer;font-weight:600;">Cancel</button>' +
'<button type="button" id="wp-pw-save" style="padding:8px 14px;border:none;background:var(--cds-interactive-01);color:var(--cds-text-on-color);border-radius:6px;cursor:pointer;font-weight:600;">Update password</button>' +
'</div>' +
'</div>';
function close() { var m = document.getElementById('wp-pw-modal'); if (m) m.remove(); }
function msg(text, ok) {
var el = document.getElementById('wp-pw-msg');
el.style.display = 'block'; el.textContent = text;
el.style.background = ok ? 'var(--wp-status-success-bg)' : 'var(--wp-status-error-bg)'; el.style.color = ok ? 'var(--wp-status-success-text)' : 'var(--cds-support-error)';
}
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
document.body.appendChild(ov);
document.getElementById('wp-pw-cancel').onclick = close;
document.getElementById('wp-pw-cur').focus();
document.getElementById('wp-pw-save').onclick = function () {
var cur = document.getElementById('wp-pw-cur').value;
var n1 = document.getElementById('wp-pw-new').value;
var n2 = document.getElementById('wp-pw-new2').value;
if (!cur || !n1) { msg('Please fill in every field.', false); return; }
if (n1.length < 12) { msg('New password must be at least 12 characters.', false); return; }
if (n1 !== n2) { msg('New passwords do not match.', false); return; }
fetch('/api/auth/password', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current_password: cur, new_password: n1 })
})
.then(function (r) { return r.json().catch(function () { return null; }).then(function (j) { return { ok: r.ok, status: r.status, j: j }; }); })
.then(function (res) {
if (res.ok) { msg('Password updated.', true); setTimeout(close, 1200); }
else { msg((res.j && res.j.detail) || ('Could not update (HTTP ' + res.status + ').'), false); }
})
.catch(function () { msg('Could not reach the server.', false); });
};
};
// ── permissions helpers ────────────────────────────────────────────────────
// The server enforces all of this; these are for hiding controls the signed-in
// user can't use, so nobody clicks a button just to get a 403.

View File

@@ -112,16 +112,47 @@
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
</div>
<div class="hint">Use your Windows password — the same one you use to sign in to your computer.</div>
<button id="submit" type="submit">Sign in</button>
</form>
<!-- D13: there is no app password to reset. Self-service goes to Okta.
A plain external link, not a form post — CSP sets form-action 'self'
and does not set navigate-to, so link navigation off-origin is allowed.
rel="noopener noreferrer" because target="_blank" without it hands the
opened page a window.opener handle back to this one. -->
<p class="center"><a href="https://primecontrols.okta.com/" id="forgot-link" class="link"
target="_blank" rel="noopener noreferrer">Forgot password?</a></p>
<p class="center"><a href="#" id="forgot-link" class="link">Forgot password?</a></p>
</section>
<!-- FORGOT PASSWORD (email reset) -->
<section id="view-forgot" style="display:none">
<h1>Reset password</h1>
<p class="sub">We'll email you a link to set a new one.</p>
<div id="forgot-unavailable" class="note" style="display:none">
Password reset by email isn't switched on yet. Contact your project admin and
they'll set a new password for you. Once you're signed in you can change it
yourself from the menu in the top-right corner.
</div>
<form id="forgot-form" autocomplete="on">
<div class="field">
<label for="forgot-username">Username or email</label>
<input id="forgot-username" type="text" autocomplete="username" required>
</div>
<button id="forgot-submit" type="submit">Email me a reset link</button>
</form>
<p class="center"><a href="#" id="back-to-login" class="link">← Back to sign in</a></p>
</section>
<!-- SET A NEW PASSWORD (arrived from the emailed link) -->
<section id="view-reset" style="display:none">
<h1>Set a new password</h1>
<p class="sub">Choose a password you don't use anywhere else.</p>
<form id="reset-form" autocomplete="on">
<div class="field">
<label for="new-password">New password</label>
<input id="new-password" type="password" autocomplete="new-password" autofocus required>
</div>
<div class="hint">At least 12 characters.</div>
<div class="field">
<label for="new-password2">Confirm new password</label>
<input id="new-password2" type="password" autocomplete="new-password" required>
</div>
<button id="reset-submit" type="submit">Set password &amp; sign in</button>
</form>
<p class="center"><a href="#" id="reset-to-login" class="link">← Back to sign in</a></p>
</section>
<p class="foot">Authorized use only · BTG / Pilot</p>

View File

@@ -1,20 +1,26 @@
/* Login page logic for the Work Package Suite.
One view. Sign in posts to /api/auth/login, the server authenticates by binding
to the domain over LDAPS (D13), and on success sets an HttpOnly session cookie —
not readable from here, which is the point — after which we redirect to ?next=
or the home page. The password entered is the person's WINDOWS password.
Three views on one page:
• sign in posts to /api/auth/login. On success the server sets an
HttpOnly session cookie (not readable here — that's the
point) and we redirect to ?next= or the home page.
• forgot password posts to /api/auth/forgot-password, which emails a
single-use link. Only offered when the server reports
email is actually configured (/api/auth/reset-available);
otherwise we say to ask an admin.
• set a new password shown when the page is opened as login.html?reset=<token>
from that email. Posts to /api/auth/reset-password.
There is no forgot-password flow and no reset view: the suite holds no password
to reset. "Forgot password?" is a plain external link to Okta in login.html, so
there is deliberately no click handler for it here — one that called
preventDefault() would swallow the navigation. */
The reset token stays in the URL only until it's used; on success we strip it
from the address bar so it isn't left in history or copied out of the bar. */
(function () {
'use strict';
var errorBox = document.getElementById('error');
var okBox = document.getElementById('ok');
function show(el) { if (el) el.style.display = ''; }
function hide(el) { if (el) el.style.display = 'none'; }
function byId(id) { return document.getElementById(id); }
function showError(msg) {
@@ -22,6 +28,11 @@
errorBox.textContent = msg;
errorBox.classList.add('show');
}
function showOk(msg) {
errorBox.classList.remove('show');
okBox.textContent = msg;
okBox.classList.add('show');
}
function clearBanners() {
errorBox.classList.remove('show');
okBox.classList.remove('show');
@@ -38,6 +49,10 @@
return 'index.html';
}
function resetToken() {
try { return new URLSearchParams(location.search).get('reset') || ''; } catch (e) { return ''; }
}
function postJson(url, payload) {
return fetch(url, {
method: 'POST',
@@ -55,11 +70,18 @@
return (typeof d === 'string' && d) ? d : fallback;
}
function view(which) {
clearBanners();
['login', 'forgot', 'reset'].forEach(function (v) {
(which === v ? show : hide)(byId('view-' + v));
});
}
// ── sign in ────────────────────────────────────────────────────────────────
var form = byId('login-form');
var submitBtn = byId('submit');
// Guarded: an unguarded addEventListener on null would break sign-in itself if a
// cached older login.html were served.
// Guarded because a cached older login.html may not have the reset views; an
// unguarded addEventListener on null would break sign-in itself.
if (!form || !submitBtn) return;
form.addEventListener('submit', function (e) {
e.preventDefault();
@@ -76,10 +98,6 @@
if (res.status === 401) showError('Invalid username or password.');
else if (res.status === 403) showError(detail(res, 'Your account is disabled.'));
else if (res.status === 429) showError(detail(res, 'Too many failed attempts. Try again later.'));
// 503 means the directory is unreachable or misconfigured — OUR fault, not a
// wrong password. Saying so stops people hunting for a password they no
// longer have while a deploy is broken.
else if (res.status === 503) showError(detail(res, 'Sign-in is temporarily unavailable. Contact IT.'));
else showError(detail(res, 'Sign-in failed (HTTP ' + res.status + ').'));
submitBtn.disabled = false;
submitBtn.textContent = 'Sign in';
@@ -91,4 +109,107 @@
});
});
// ── forgot password ────────────────────────────────────────────────────────
var resetAvailable = null; // null = not checked yet
function checkResetAvailable() {
if (resetAvailable !== null) return Promise.resolve(resetAvailable);
return fetch('/api/auth/reset-available')
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) { resetAvailable = !!(j && j.enabled); return resetAvailable; })
.catch(function () { resetAvailable = false; return false; });
}
(byId('forgot-link') || {addEventListener: function(){}}).addEventListener('click', function (e) {
e.preventDefault();
view('forgot');
// Prefill from the sign-in box so nobody types their username twice.
var u = byId('username').value.trim();
if (u) byId('forgot-username').value = u;
checkResetAvailable().then(function (enabled) {
// With email off there's nothing to submit — say so and hide the form.
(enabled ? hide : show)(byId('forgot-unavailable'));
(enabled ? show : hide)(byId('forgot-form'));
if (enabled) byId('forgot-username').focus();
});
});
(byId('back-to-login') || {addEventListener: function(){}}).addEventListener('click', function (e) {
e.preventDefault();
view('login');
});
var forgotForm = byId('forgot-form') || document.createElement('form');
var forgotBtn = byId('forgot-submit') || document.createElement('button');
forgotForm.addEventListener('submit', function (e) {
e.preventDefault();
clearBanners();
var who = byId('forgot-username').value.trim();
if (!who) { showError('Enter your username or email.'); return; }
forgotBtn.disabled = true;
forgotBtn.textContent = 'Sending…';
postJson('/api/auth/forgot-password', { username: who })
.then(function (res) {
if (res.status === 503) {
showError(detail(res, "Password reset by email isn't available. Ask an administrator."));
} else if (res.ok) {
// Deliberately the same message whether or not the account exists.
showOk('If that account exists, a reset link is on its way. The link expires in an hour.');
hide(forgotForm);
} else {
showError(detail(res, 'Could not send the reset email (HTTP ' + res.status + ').'));
}
forgotBtn.disabled = false;
forgotBtn.textContent = 'Email me a reset link';
})
.catch(function () {
showError('Could not reach the server. Check your connection and try again.');
forgotBtn.disabled = false;
forgotBtn.textContent = 'Email me a reset link';
});
});
// ── set a new password (from the emailed link) ──────────────────────────────
(byId('reset-to-login') || {addEventListener: function(){}}).addEventListener('click', function (e) {
e.preventDefault();
view('login');
});
var resetForm = byId('reset-form') || document.createElement('form');
var resetBtn = byId('reset-submit') || document.createElement('button');
resetForm.addEventListener('submit', function (e) {
e.preventDefault();
clearBanners();
var token = resetToken();
var pw = byId('new-password').value;
var pw2 = byId('new-password2').value;
if (!token) { showError('This reset link is incomplete. Request a new one.'); return; }
if (pw !== pw2) { showError('The two passwords do not match.'); return; }
if (pw.length < 12) { showError('Password must be at least 12 characters.'); return; }
resetBtn.disabled = true;
resetBtn.textContent = 'Saving…';
postJson('/api/auth/reset-password', { token: token, new_password: pw })
.then(function (res) {
if (res.ok) {
// Take the token out of the URL before anything else — it's spent.
try { history.replaceState(null, '', 'login.html'); } catch (err) {}
view('login');
showOk('Password updated. Sign in with your new password.');
byId('username').focus();
return;
}
showError(detail(res, 'Could not set your password (HTTP ' + res.status + ').'));
resetBtn.disabled = false;
resetBtn.textContent = 'Set password & sign in';
})
.catch(function () {
showError('Could not reach the server. Check your connection and try again.');
resetBtn.disabled = false;
resetBtn.textContent = 'Set password & sign in';
});
});
// Arriving from the reset email opens straight into the new-password view.
if (resetToken()) view('reset');
})();

View File

@@ -26,8 +26,9 @@
room for "Assistant Project Manager" without pushing Actions off screen. */
#users-table table td:nth-child(3){ max-width:230px; overflow:hidden; text-overflow:ellipsis; }
#users-banner:not(:empty), #scope-banner:not(:empty){ margin-bottom:var(--s3); }
/* The create form is a lot of fields; let them wrap and
/* The create form is a lot of fields; give the password one room to breathe and
let the project picker take a full row of its own. */
#nu-password{ flex:1 1 200px; }
#nu-projects{ margin-top:var(--s2); }
#nu-projects .pickrow{ padding:var(--s1) var(--s1); }
/* A manager with one project doesn't need a scrolling picker; a manager with
@@ -87,6 +88,7 @@
<input id="nu-email" placeholder="Email" autocomplete="off">
<select id="nu-role" title="Permissions — what this account may do"></select>
<select id="nu-project-role" title="Job function on the project"></select>
<input id="nu-password" type="password" placeholder="Password (min 12)" autocomplete="new-password">
</div>
<div id="nu-projects">
<div class="note" id="nu-projects-label" style="margin-bottom:var(--s1)"></div>

View File

@@ -183,12 +183,11 @@ function managerRow(u){
: projRoleReadonly(u, can, why);
const actions = [];
if(can && !me) actions.push('<button class="mini" onclick="resetPw(\''+uid+'\',\''+uname+'\')">Reset password</button>');
if(can && !me) actions.push('<button class="mini" onclick="toggleActive(\''+uid+'\','+(!u.is_active)+')">'+
(u.is_active?'Disable':'Enable')+'</button>');
if(can && !me) actions.push('<button class="mini danger" onclick="deleteUser(\''+uid+'\',\''+uname+'\')">Delete</button>');
// D13: no self-service action left on your own row — the domain owns the password
// and role changes are never self-applied.
if(me) actions.push('<span class="note" style="margin:0" title="Your own account">you</span>');
if(me) actions.push('<button class="mini" disabled title="Use the Password link in the top bar to change your own">—</button>');
if(!can && !me) actions.push('<span class="note" style="margin:0" title="'+uesc(why)+'">read-only</span>');
return '<tr'+(can||me ? '' : ' class="is-locked"')+'>'+
@@ -255,6 +254,19 @@ function projAccessCell(u){
// ── row actions ───────────────────────────────────────────────────────────────
// Each one reloads on failure so a control can never sit there showing a value the
// server refused.
async function resetPw(id, username){
// The min-12 rule was stated in the prompt label and enforced only by the
// server round-trip; the kit's validate() answers AT the input instead.
const pw = await wpPromptDialog({title:'Reset password',
message:'Set a new password for "'+username+'". Their existing sessions are signed out.',
label:'New password (min 12 characters)',
validate:v => (v && v.length >= 12) ? '' : 'At least 12 characters.'});
if(pw === null) return;
const { status, json } = await api('POST','/api/auth/users/'+id+'/password',{new_password:pw});
if(status === 200) toast('Password reset for '+username+'. Their existing sessions are signed out.');
else wpAlertDialog({title:'Reset failed', message:'Could not reset the password: '+apiError(status, json)});
}
async function toggleActive(id, makeActive){
const { status, json } = await api('POST','/api/auth/users/'+id+'/active',{is_active:makeActive});
if(status === 200) loadUsers();
@@ -333,22 +345,24 @@ async function createUser(){
const msg = document.getElementById('users-create-msg');
const val = id => (document.getElementById(id)||{}).value || '';
const username = val('nu-username').trim();
const password = val('nu-password');
const project_ids = [...document.querySelectorAll('#nu-project-list input[type=checkbox]:checked')]
.map(c => c.value);
const say = (color, text) => { msg.style.color = color; msg.textContent = text; };
if(!username){ say('var(--red)','Username is required.'); return; }
if(password.length < 12){ say('var(--red)','Password must be at least 12 characters.'); return; }
if(_scope.scope !== 'all' && !project_ids.length){
say('var(--red)','Pick at least one project — you administer users per project.'); return;
}
say('var(--muted)','Creating…');
const { status, json } = await api('POST','/api/auth/users',{
username, project_ids,
username, password, project_ids,
full_name: val('nu-fullname').trim(), email: val('nu-email').trim(),
role: val('nu-role'), project_role: val('nu-project-role'),
});
if(status === 200){
say('var(--green)','✓ Created '+username+'.');
['nu-username','nu-fullname','nu-email'].forEach(id => document.getElementById(id).value = '');
['nu-username','nu-fullname','nu-email','nu-password'].forEach(id => document.getElementById(id).value = '');
loadUsers();
} else {
say('var(--red)','✕ '+apiError(status, json, 'Could not create the account'));

View File

@@ -53,6 +53,7 @@
{ section: 'Account' },
{ action: 'wpPreferences', icon: '◷', label: 'Language & time',
sub: 'Dates, numbers and time zone' },
{ action: 'wpChangePassword', icon: '⚿', label: 'Password', sub: 'Change your password' },
];
function esc(v) {

View File

@@ -21,40 +21,6 @@ AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
# How long a login lasts before re-authentication (hours). Default 12.
# AUTH_SESSION_HOURS=12
# ── Domain authentication, D13 (REQUIRED — there is no fallback) ───────────────
# The suite stores no passwords. Sign-in is an LDAPS simple bind against the
# domain, so if this is misconfigured NOBODY CAN SIGN IN, admins included. There
# is deliberately no local break-glass account (decided Aug 21 2026 — see
# docs/waves/decisions-2026-08-21.md). Check `docker compose logs api` on startup:
# the API logs one line saying whether LDAP is configured and reachable.
#
# Connect to the DOMAIN NAME, never a DC hostname and never 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. 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 not happen — domain passwords cross this link.
# LDAP_DOMAIN=prime.local
# LDAP_HOST=prime.local
# LDAP_PORT=636
# Trust anchor: PRIME CONTROLS ROOT CA + PRIME CONTROLS ISSUING CA 1 as a PEM
# bundle. These are PUBLIC certificates — no private key, nothing issued to this
# app, nothing to request from IT. The repo ships a verified copy and the default
# points at it, so you only set this to override with a mounted file.
# LDAP_CA_FILE=/app/server/certs/prime-ca-chain.pem
# An AD group required to sign in. Empty means every domain account may sign in.
# This is the INITIAL value and the fallback; the live value is set in the Admin
# console, which refuses to save a group that does not resolve or that the saving
# admin is not a member of. Nested groups count.
# LDAP_REQUIRED_GROUP=WP-Suite-Users
# Bind/connect timeout, and how many extra CONNECT attempts to make. Retries never
# apply to a rejected password — each failed bind counts against the domain lockout
# policy, so guessing would lock real accounts out of Windows.
# LDAP_TIMEOUT_SECONDS=8
# LDAP_CONNECT_RETRIES=2
# ── Email notifications (optional) ─────────────────────────────────────────────
# WP-assignment emails are OFF by default and are turned on from the Admin
# console (Notifications & email card), where the SMTP host/port/from-address

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}`) — binds against the domain, sets the session cookie |
| POST | `/api/auth/login` | sign in (`{username, password}`) — 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` | 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**) |
| POST | `/api/auth/users` | create an account (**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,14 +40,9 @@ fields (name, number, status, …) are promoted to columns for listing/filtering
---
## Sign-in (domain authentication, D13)
## Login portal (user accounts)
**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
The suite is gated by a username/password login. 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
@@ -58,43 +53,8 @@ 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.
**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)
```
Passwords are stored only as **bcrypt** hashes (`server/auth.py`). Roles are
`admin` (may manage users) and `user`.
### Set the signing secret
@@ -105,70 +65,30 @@ without it the API uses a random per-process key, so logins reset on restart.
python -c "import secrets; print(secrets.token_urlsafe(48))"
```
### Bootstrap the first admin
### Create the first admin
Two steps, in this order. There is no `create-admin` any more — there is no password
to set and no account to create.
The `/api/auth/users` endpoint needs an existing admin, so bootstrap one from a
shell (run from the **project root**, like uvicorn):
```bash
# 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
python -m server.manage_users create-admin alice --name "Alice Smith"
# prompts for a password (min 8 chars)
```
It prompts for **your** domain username and password, binds to confirm who you are,
and prints `alice: project_user -> admin`.
In Docker:
Other commands: `list`, `promote <user> [--role …]`, `demote <user>`,
`disable <user>`, `enable <user>`. After that, admins manage accounts from the Admin
console.
```bash
docker compose exec api python -m server.manage_users create-admin alice --name "Alice Smith"
```
**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.
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).
---
## Local dev
> ### A SQLite database created before D13 will reject new sign-ins
>
> `Base.metadata.create_all()` creates missing tables; it never alters existing ones.
> So a `wpsuite.db` built before D13 still has `users.password_hash` declared
> `NOT NULL` with no default, while the current model has no such column — and an
> INSERT that omits it is rejected:
>
> ```
> IntegrityError: NOT NULL constraint failed: users.password_hash
> ```
>
> Accounts already in the file keep working, so **you** can sign in and nothing looks
> wrong. It breaks the moment a *new* person signs in, because provisioning them is an
> INSERT — and it surfaces as an HTTP **500**, not a 401, so it reads as a server fault
> rather than anything to do with the schema.
>
> Such a database also has no `alembic_version` table, so `alembic upgrade head` would
> try to replay the baseline against tables that already exist. Stamp it first:
>
> ```bash
> python -m alembic -c server/alembic.ini stamp a1b8c6d4e2f9 # the revision before the drop
> python -m alembic -c server/alembic.ini upgrade head # runs only the drop
> ```
>
> That keeps whatever is in the file. Deleting the database also works and
> `create_all()` rebuilds a correct schema, but it throws away your test data.
>
> Production is unaffected: it runs Postgres and the container applies migrations at
> start, so the column is dropped properly there.
```bash
cd server
python -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\activate

View File

@@ -32,11 +32,10 @@ def upgrade() -> None:
sa.Column('code', sa.String(length=80), nullable=False, server_default=''),
sa.Column('description', sa.String(length=300), nullable=False, server_default=''),
sa.Column('unit', sa.String(length=20), nullable=False, server_default=''),
# sa.true(), NOT sa.text('1'). sa.text() emits raw SQL, and Postgres refuses
# an integer default on a boolean column: "column active is of type boolean
# but default expression is of type integer". SQLite accepts 1 happily, so
# this passed every local test and failed only on the real engine. The
# sibling migration e2a4c7d91b30 does the identical column correctly.
# sa.true(), not sa.text('1'): SQLite coerces integer 1 to boolean,
# Postgres refuses it (DatatypeMismatch) - found when this migration
# took down the wp.controls.dev api container on 2026-08-21. The
# location-taxonomy migration next door had it right all along.
sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column('sort', sa.Integer(), nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),

View File

@@ -1,56 +0,0 @@
"""drop users.password_hash — D13 / T10.3
Authentication moved to an LDAPS simple bind against the domain (see
server/ldap_auth.py), so the suite no longer holds a credential of any kind.
THIS MIGRATION DESTROYS DATA AND CANNOT BE UNDONE IN ANY MEANINGFUL SENSE.
`downgrade()` recreates the column, but every hash in it is gone — and even a
restored hash would be useless, because nothing reads the column any more. The
downgrade exists so the revision is well-formed and so an operator can step back
past it, not because stepping back restores the old login. To actually revert to
local passwords you have to revert the application code and reset every password
by hand.
The column is recreated NULLABLE on downgrade, deliberately. The baseline schema
declared it NOT NULL, but there are no values to put back, so a NOT NULL column
with no server default would refuse to add itself on any table that has rows.
DO NOT WRAP THIS IN batch_alter_table. An earlier version of this migration did,
and it silently deleted every row of `project_members` on SQLite.
Why: alembic's batch mode emulates ALTER on SQLite by rebuilding the table —
create a new one, copy the rows, DROP the original, rename. `server/alembic/env.py`
imports the engine from `server/db.py`, which registers a `connect` listener setting
`PRAGMA foreign_keys=ON`, so that DROP TABLE cascades through
`project_members.user_id`, which is declared `ondelete="CASCADE"`. Every project
membership in the database goes with it, with no error and nothing in the log.
Turning the pragma off around the batch is not a fix either: `PRAGMA foreign_keys`
is a no-op inside a transaction, and alembic runs migrations in one.
The real answer is that the rebuild is unnecessary. SQLite gained native
ALTER TABLE ... DROP COLUMN in 3.35 (2021); this runtime has 3.42 and Postgres has
always had it. A plain drop_column touches one table and cascades nowhere.
Revision ID: b7e4f1a20c93
Revises: a1b8c6d4e2f9
Create Date: 2026-08-21
"""
import sqlalchemy as sa
from alembic import op
revision = 'b7e4f1a20c93'
down_revision = 'a1b8c6d4e2f9'
branch_labels = None
depends_on = None
def upgrade():
# Plain, un-batched, on both engines. See the docstring: batching this destroys
# project_members on SQLite.
op.drop_column('users', 'password_hash')
def downgrade():
op.add_column('users', sa.Column('password_hash', sa.String(length=200),
nullable=True))

View File

@@ -9,8 +9,6 @@ Run (prod): gunicorn -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 serve
Interactive docs: http://<host>/api/docs
"""
import base64
import logging
from contextlib import asynccontextmanager
import os
import re
import uuid
@@ -28,11 +26,7 @@ from sqlalchemy import select, delete, func
from sqlalchemy.orm import Session
from .db import Base, engine, get_db
from . import models, auth, notify, ldap_auth, assets_db
# Same naming as the other modules' loggers (wpsuite.auth / .ldap / .notify), so a
# deployment can raise the level on one subsystem without raising it on all of them.
log = logging.getLogger("wpsuite.api")
from . import models, auth, notify, assets_db
# Schema management:
# • Local dev (SQLite) auto-creates tables for a zero-config run.
@@ -45,30 +39,7 @@ if engine.dialect.name == "sqlite":
# Interactive docs are handy in dev but hand an attacker the full API map in prod,
# so enable them only on the SQLite dev fallback (production runs on Postgres).
_docs_enabled = engine.dialect.name == "sqlite"
@asynccontextmanager
async def _lifespan(_app):
"""Say, once, whether anyone can sign in at all.
D13 removed the local password path and left no break-glass, so a broken LDAP
configuration and a forgotten password look identical at the login box. This
line is what tells an operator which one they have, and DEPLOYMENT.md,
DEPLOY-login-portal.md and server/README.md all send people here first:
docker compose logs api | grep -i "LDAP auth"
Configuration only — it opens no connection and binds nothing, so startup stays
fast and cannot be made to hang by an unreachable domain controller. Use
`ldap_auth.selftest()` for a reachability check; it validates the certificate
without binding, so it cannot contribute to a lockout either.
"""
log.info("%s", ldap_auth.describe())
yield
app = FastAPI(
lifespan=_lifespan,
title="Work Package Suite API",
docs_url="/api/docs" if _docs_enabled else None,
redoc_url=None,
@@ -640,10 +611,8 @@ class LoginIn(BaseModel):
class NewUserIn(BaseModel):
# No password: D13 authenticates against the domain, so an administrator
# pre-creating an account only supplies identity and authorization. The person
# signs in with their Windows password, or is provisioned on first sign-in.
username: str
password: str
full_name: str = ""
email: str = ""
role: str = auth.ROLE_PROJECT_USER # permissions role — see auth.ROLES
@@ -665,6 +634,24 @@ class PreferencesIn(BaseModel):
timezone: Optional[str] = None
class ForgotPasswordIn(BaseModel):
username: str = "" # username or email
class ResetPasswordIn(BaseModel):
token: str
new_password: str
class PasswordChangeIn(BaseModel):
current_password: str
new_password: str
class AdminPasswordIn(BaseModel):
new_password: str
class ActiveIn(BaseModel):
is_active: bool
@@ -687,214 +674,42 @@ class AutoAddIn(BaseModel):
role: str = ""
# D13 / T10.2 — THIS NUMBER IS NOT A UX PREFERENCE, IT IS A SAFETY LIMIT.
#
# Failures are now LDAP binds, so every one counts against the DOMAIN account
# lockout policy. This estate's AD threshold is 5. The throttle below is
# per-process and the API runs 2 gunicorn workers, so a local limit of N lets up
# to 2N binds reach a domain controller: 2 x 2 = 4, one under the threshold.
#
# It defaulted to 5 before this task, which would have allowed up to 10 binds and
# locked the account out of WINDOWS — twice over — before the local lockout ever
# engaged. If you raise this, or add a worker, redo the arithmetic first.
LOGIN_MAX_ATTEMPTS = int(os.getenv("AUTH_MAX_ATTEMPTS", "2"))
LOGIN_MAX_ATTEMPTS = int(os.getenv("AUTH_MAX_ATTEMPTS", "5"))
LOGIN_LOCKOUT_MINUTES = int(os.getenv("AUTH_LOCKOUT_MINUTES", "15"))
# Pre-account throttle, keyed by USERNAME (not by client IP — an attacker rotating
# IPs would sail past an IP-keyed limit, and it is the domain account we are
# protecting, not this endpoint's capacity).
#
# The DB counter on `users` is shared across workers and persists, but it can only
# work for an account that already has a local row. Under D13 accounts are created
# on first successful login, so a REAL domain account can be hammered before any
# row exists — this dict covers exactly that window. Per-worker and lost on
# restart, which is why LOGIN_MAX_ATTEMPTS is halved rather than trusted.
_bind_attempts: dict[str, list[float]] = {}
_BIND_WINDOW_SECONDS = LOGIN_LOCKOUT_MINUTES * 60
def _bind_throttled(sam: str) -> bool:
"""True if this username has already used its bind budget in the window.
Checked BEFORE any call to the directory."""
now = monotonic()
key = (sam or "").strip().lower()
hits = [t for t in _bind_attempts.get(key, []) if now - t < _BIND_WINDOW_SECONDS]
_bind_attempts[key] = hits
if len(_bind_attempts) > 5000: # bound the dict on a long-lived worker
for k in [k for k, v in _bind_attempts.items()
if not v or now - max(v) > _BIND_WINDOW_SECONDS]:
_bind_attempts.pop(k, None)
return len(hits) >= LOGIN_MAX_ATTEMPTS
def _record_bind_failure(sam: str) -> None:
_bind_attempts.setdefault((sam or "").strip().lower(), []).append(monotonic())
def _clear_bind_failures(sam: str) -> None:
_bind_attempts.pop((sam or "").strip().lower(), None)
def _match_directory_account(db: Session, result) -> Optional[models.User]:
"""Find the local row for a directory identity — D13 criterion 4.
Matched on `sAMAccountName` OR the directory's `mail`, because existing accounts
were created by hand with `manage_users.py` and some were typed as short logon
names while others were typed as email addresses. Matching on both is what keeps
an existing admin's role instead of handing them a second, default-role account.
`auth.find_user` already compares case-insensitively against username AND email,
so each call covers two columns; the second call is for the case where the local
username is the person's address and the directory only told us their sAMAccountName.
"""
user = auth.find_user(db, result.sam)
if user is None and result.mail:
user = auth.find_user(db, result.mail)
if user is not None:
log.info("matched directory identity %r to existing local account %r by mail",
result.sam, user.username)
return user
def _provision_from_directory(db: Session, result) -> models.User:
"""Create a local account for someone who just authenticated and has no row.
Lands at `project_user` with NO project memberships. That is least privilege and
it is deliberate, but it means the person signs in successfully into an empty
app until an admin grants access — so it is written to the audit log rather than
happening silently. `auto_add_projects` cannot help here: it is evaluated when a
PROJECT is created, to mark who joins every new job, and cannot retroactively add
a new account to jobs that already exist.
"""
u = models.User(
id=gen_id("user"),
username=result.sam,
email=result.mail or "",
full_name=result.full_name or "",
role=auth.ROLE_PROJECT_USER,
)
db.add(u)
db.flush() # see the flush-order note in models.py's docstring
log_event(db, u.username, "user_provisioned", "user", u.id, summary=u.username,
detail={"source": "directory", "role": u.role, "upn": result.upn,
"projects": 0, "note": "created on first successful sign-in"})
log.info("provisioned local account %r from the directory at role %r with no "
"project access — an admin must grant access before they see anything",
u.username, u.role)
return u
@app.post("/api/auth/login")
def login(body: LoginIn, request: Request, response: Response, db: Session = Depends(get_db)):
"""Authenticate against the domain (D13 / T10.2) and set the session cookie.
The suite stores no passwords: this is an LDAPS simple bind as
`<sAMAccountName>@prime.local`, and a successful bind IS the authentication.
See server/ldap_auth.py for the transport and its two hard guards.
ORDER MATTERS HERE. Every throttle check runs BEFORE the directory is touched,
because a failed bind counts against the DOMAIN lockout policy — so this
endpoint must not be usable to lock a colleague out of Windows. Nothing below
reaches `ldap_auth.verify` until the local budget has been checked twice: once
against the persistent per-account counter, once against the pre-account
window that covers usernames with no local row yet.
Three outcomes, deliberately distinguished:
401 the credential was rejected, or the account is not in the required
group. One generic message for every case — the response must never
reveal whether an account exists (see `_ERR49` in ldap_auth: the useful
detail goes to the log).
403 the local account exists and is disabled. Independent of the directory.
503 OUR fault — LDAP unconfigured, unreachable, untrusted, or the required
group does not resolve. D13 left no password fallback, so this must not
masquerade as 401: "your password is wrong" sends people hunting for a
password they no longer have, while the real problem is a broken deploy.
"""
"""Verify credentials and, on success, set the HttpOnly session cookie.
Throttles online password guessing: after LOGIN_MAX_ATTEMPTS consecutive
failures an account is locked for LOGIN_LOCKOUT_MINUTES."""
user = auth.find_user(db, body.username)
now = models.utcnow()
sam = ldap_auth.normalize_username(body.username)
if not sam or not (body.password or "").strip():
# No directory call for empty input. ldap_auth.verify guards this too; the
# duplication is intentional, since an empty password would otherwise be an
# anonymous bind and anonymous binds SUCCEED.
raise HTTPException(status_code=401, detail="Invalid username or password")
user = auth.find_user(db, sam)
# ── throttle 1: the persistent per-account lockout ────────────────────────
# Always run the hash comparison first — even for missing or locked accounts —
# so response timing doesn't leak which usernames exist. verify_password
# tolerates an empty hash.
valid = auth.verify_password(body.password, user.password_hash if user else "")
locked = user.locked_until if user else None
if locked is not None and locked.tzinfo is None:
locked = locked.replace(tzinfo=timezone.utc) # SQLite returns naive datetimes
locked = locked.replace(tzinfo=timezone.utc) # SQLite returns naive datetimes; normalize to UTC
if locked is not None and locked > now:
raise HTTPException(status_code=429, detail="Too many failed attempts. Try again later.")
# ── throttle 2: the pre-account window ────────────────────────────────────
if _bind_throttled(sam):
log.warning("refusing to bind for %r — local attempt budget (%d) spent; "
"protecting the domain account from lockout", sam, LOGIN_MAX_ATTEMPTS)
raise HTTPException(status_code=429, detail="Too many failed attempts. Try again later.")
# ── the directory ─────────────────────────────────────────────────────────
settings = notify.get_settings(db)
result = ldap_auth.verify(sam, body.password,
required_group=settings.get("ldap_required_group"))
if result.is_config_problem:
# Ours, not theirs. Never counted against the user, never a 401.
log.error("sign-in unavailable — %s: %s", result.reason, result.detail)
raise HTTPException(status_code=503,
detail="Sign-in is temporarily unavailable. Contact IT.")
if not result.ok:
# WARNING, not INFO: this is the line an operator needs when someone
# cannot sign in, and nothing configures the root logger — under plain
# uvicorn an INFO record from wpsuite.* goes nowhere, so the reason was
# invisible in exactly the situation it exists for.
log.warning("sign-in refused for %r (%s: %s)", sam, result.reason, result.detail)
_record_bind_failure(sam)
if not user or not valid:
if user:
user.failed_attempts = (user.failed_attempts or 0) + 1
if user.failed_attempts >= LOGIN_MAX_ATTEMPTS:
user.locked_until = now + timedelta(minutes=LOGIN_LOCKOUT_MINUTES)
user.failed_attempts = 0
log_event(db, user.username, "login_locked", "user", user.id,
summary=user.username,
detail={"minutes": LOGIN_LOCKOUT_MINUTES, "reason": result.reason})
log_event(db, user.username, "login_locked", "user", user.id, summary=user.username,
detail={"minutes": LOGIN_LOCKOUT_MINUTES})
db.commit()
raise HTTPException(status_code=401, detail="Invalid username or password")
# ── authenticated ─────────────────────────────────────────────────────────
_clear_bind_failures(sam)
# The pre-bind lookup used the typed name only. Now that the directory has told
# us the account's real sAMAccountName and mail, re-match on both (T10.4).
if user is None:
user = _match_directory_account(db, result)
provisioned = user is None
if provisioned:
user = _provision_from_directory(db, result)
else:
# NEVER touch `role` here. An existing admin stays an admin — that is D13
# criterion 4, and it is the whole reason this branch is separate from the
# one above. Fill in identity fields only where they are empty locally, so a
# name deliberately set in the console is not overwritten by the directory.
if not user.full_name and result.full_name:
user.full_name = result.full_name
if not user.email and result.mail:
user.email = result.mail
if not user.is_active:
# Checked after provisioning so a brand-new account (is_active defaults True)
# is not caught by it, and after the role branch so a disabled admin is still
# refused. Local state overrides the directory: disabling here is how you
# revoke access to THIS app without touching the domain account.
raise HTTPException(status_code=403, detail="Account is disabled")
user.failed_attempts = 0
user.locked_until = None
user.last_login_at = now
db.commit()
db.refresh(user)
token = auth.create_token(user)
auth.set_session_cookie(response, request, token)
return {"user": user.to_dict()}
@@ -906,6 +721,115 @@ def logout(response: Response):
return {"ok": True}
# ── Self-service password reset (needs email switched on) ──────────────────────
RESET_COOLDOWN_SECONDS = int(os.getenv("AUTH_RESET_COOLDOWN_SECONDS", "120"))
# In-process throttle: one reset mail per (account, client) per cooldown. Enough to
# stop someone using the form to spam a colleague's inbox. Per-worker and lost on
# restart — deliberately simple; the token expiry is the real control.
_reset_last: dict[str, float] = {}
def _reset_throttled(request: Request, username: str) -> bool:
now = monotonic()
key = f"{(username or '').strip().lower()}|{request.client.host if request.client else ''}"
prev = _reset_last.get(key)
if prev is not None and (now - prev) < RESET_COOLDOWN_SECONDS:
return True
_reset_last[key] = now
if len(_reset_last) > 5000: # bound the dict on a long-lived worker
cutoff = now - RESET_COOLDOWN_SECONDS
for k in [k for k, t in _reset_last.items() if t < cutoff]:
_reset_last.pop(k, None)
return False
def reset_body(user: "models.User", link: str, minutes: int) -> str:
# No account detail beyond the username, and no customer data — same rule as
# the assignment mail. The link is the only sensitive thing in here.
who = user.full_name or user.username
return (
f"Hi {who},\n\n"
f"A password reset was requested for your Work Package Suite account "
f"({user.username}).\n\n"
f"Set a new password:\n{link}\n\n"
f"The link expires in {minutes} minutes and can only be used once. "
f"If you didn't request this, you can ignore this email — your current "
f"password still works.\n"
)
@app.get("/api/auth/reset-available")
def reset_available(db: Session = Depends(get_db)):
"""Whether the login page should offer 'Forgot password'. Self-service reset
depends entirely on outbound email, so it's off unless email is enabled AND
SMTP is configured — otherwise the only route is an admin reset."""
s = notify.get_settings(db)
return {"enabled": bool(s.get("email_enabled")) and notify.smtp_ready(s)}
@app.post("/api/auth/forgot-password")
def forgot_password(body: ForgotPasswordIn, request: Request, db: Session = Depends(get_db)):
"""Email a reset link. Always returns the same 200 response whether or not the
account exists — this endpoint is unauthenticated, so it must not become a
username/email oracle. Failures are recorded in the audit log instead."""
s = notify.get_settings(db)
if not (s.get("email_enabled") and notify.smtp_ready(s)):
raise HTTPException(
status_code=503,
detail="Password reset by email isn't available. Ask an administrator to reset it for you.",
)
if _reset_throttled(request, body.username):
# Same shape as the success response — no oracle, no mail bomb.
return {"ok": True, "message": "If that account exists, a reset link is on its way."}
user = auth.find_user(db, body.username)
if user and user.is_active and user.email:
base = (s.get("app_base_url") or "").rstrip("/")
token = auth.create_reset_token(user)
link = f"{base}/login.html?reset={token}" if base else f"/login.html?reset={token}"
sent = notify.send_now(
db, user.email,
"Work Package Suite — reset your password",
reset_body(user, link, auth.RESET_MINUTES),
)
log_event(db, user.username, "password_reset_requested", "user", user.id,
summary=user.username, detail={"emailed": bool(sent)})
db.commit()
else:
# Log the miss for the admin's benefit; the caller can't tell the difference.
log_event(db, "(anonymous)", "password_reset_miss", "user", "",
summary=(body.username or "")[:200],
detail={"reason": "no account, inactive, or no email on file"})
db.commit()
return {"ok": True, "message": "If that account exists, a reset link is on its way."}
@app.post("/api/auth/reset-password")
def reset_password(body: ResetPasswordIn, db: Session = Depends(get_db)):
"""Complete a reset using the emailed token. The token carries the user's
token_version, and finishing a reset bumps it — so the link is single-use and
every existing session for that account is signed out."""
claims = auth.decode_reset_token(body.token or "")
if not claims:
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired. Request a new one.")
user = db.get(models.User, claims.get("sub"))
if not user or not user.is_active:
raise HTTPException(status_code=400, detail="This reset link is no longer valid.")
if (claims.get("ver", 0) or 0) != (user.token_version or 0):
raise HTTPException(status_code=400, detail="This reset link has already been used. Request a new one.")
problem = auth.password_problem(body.new_password, user.username, user.email)
if problem:
raise HTTPException(status_code=400, detail=problem)
user.password_hash = auth.hash_password(body.new_password)
user.token_version = (user.token_version or 0) + 1 # burns the link + all sessions
# A completed reset also clears any login lockout — the person has proven
# control of the mailbox, so there's nothing left to throttle.
user.failed_attempts = 0
user.locked_until = None
log_event(db, user.username, "password_reset", "user", user.id, summary=user.username)
db.commit()
return {"ok": True}
@app.get("/api/auth/me")
def whoami(user: models.User = Depends(auth.get_current_user)):
"""Who is logged in. The frontend guard calls this on every page load.
@@ -959,6 +883,22 @@ def set_preferences(body: PreferencesIn, user: models.User = Depends(auth.get_cu
return {"user": user.to_dict()}
@app.post("/api/auth/password")
def change_password(body: PasswordChangeIn, request: Request, response: Response, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
if not auth.verify_password(body.current_password, user.password_hash):
raise HTTPException(status_code=400, detail="Current password is incorrect")
problem = auth.password_problem(body.new_password, user.username, user.email)
if problem:
raise HTTPException(status_code=400, detail=problem)
user.password_hash = auth.hash_password(body.new_password)
user.token_version = (user.token_version or 0) + 1 # invalidate all OTHER existing sessions
db.commit()
db.refresh(user)
# Keep this session logged in by re-issuing a cookie carrying the new version.
auth.set_session_cookie(response, request, auth.create_token(user))
return {"ok": True}
# ── User administration ─────────────────────────────────────────────────────────
# Two kinds of caller reach these routes: an app admin, who manages every account,
# and a Project Super User, who manages the accounts on the projects they administer.
@@ -1046,6 +986,9 @@ def user_scope(user: models.User = Depends(auth.get_current_user), db: Session =
@app.post("/api/auth/users")
def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
problem = auth.password_problem(body.password, body.username, body.email)
if problem:
raise HTTPException(status_code=400, detail=problem)
allowed = grantable_roles(actor)
if body.role not in allowed:
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(allowed)}")
@@ -1077,6 +1020,7 @@ def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manag
username=body.username.strip(),
email=body.email.strip(),
full_name=body.full_name.strip(),
password_hash=auth.hash_password(body.password),
role=body.role,
project_role=body.project_role.strip()[:120],
)
@@ -1103,6 +1047,24 @@ def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manag
return directory_entry(db, u, actor)
@app.post("/api/auth/users/{user_id}/password")
def admin_reset_password(user_id: str, body: AdminPasswordIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
u = load_target_user(db, user_id)
require_see_user(db, actor, u)
require_manage_user(db, actor, u)
problem = auth.password_problem(body.new_password, u.username, u.email)
if problem:
raise HTTPException(status_code=400, detail=problem)
u.password_hash = auth.hash_password(body.new_password)
u.token_version = (u.token_version or 0) + 1 # revoke the user's existing sessions
# An administrative password reset was the one user-account change that left no
# trace; it is the most impersonation-adjacent thing on this page, so it logs.
log_event(db, actor, "password_reset", "user", u.id, summary=u.username,
detail={"by": "administrator"})
db.commit()
return {"ok": True}
@app.post("/api/auth/users/{user_id}/active")
def set_user_active(user_id: str, body: ActiveIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
u = load_target_user(db, user_id)

View File

@@ -1,8 +1,8 @@
"""Authentication for the Work Package Suite.
Authentication is an LDAPS simple bind against the domain (D13); this module owns
everything *after* that. A successful sign-in issues a signed JWT that rides in an
HttpOnly cookie (`wp_session`). Because the token is signed and self-validating, there is no
A self-contained username/password login. Passwords are stored only as bcrypt
hashes; a successful login issues a signed JWT that rides in an HttpOnly cookie
(`wp_session`). Because the token is signed and self-validating, there is no
server-side session store — every request is checked by verifying the cookie's
signature and expiry (see `auth_gate` and `get_current_user`).
@@ -12,8 +12,6 @@ Security model:
• The cookie is HttpOnly (JS can't read it → XSS can't steal the session),
SameSite=Lax (blunts CSRF), and Secure whenever the request arrives over
HTTPS (detected via X-Forwarded-Proto behind NGINX).
• Roles are LOCAL. The directory supplies identity; this app decides what that
identity may do, which is why an existing admin keeps admin (D13 criterion 4).
• The signing secret comes from AUTH_SECRET_KEY. In production this MUST be
set; if it is missing we fall back to a random per-process key (which logs a
warning and invalidates every session on restart) so dev still works.
@@ -37,11 +35,10 @@ The user-administration SCOPE of a super user is worked out in server/app.py
(`managed_project_ids`, `manage_user_problem`), because it depends on project
membership rows — this module only decides which roles carry the power at all.
There is no password and no password reset: D13 replaced local credentials with an
LDAPS bind (server/ldap_auth.py). People change their password with the domain, and
the login page points them at Okta. `token_version` survives as the
session-revocation mechanism — a role change or a deactivation must take effect on
sessions that have already been issued.
Password reset: a short-lived signed token (see `create_reset_token`) is emailed
to the account's address. It is single-use by construction — it embeds the user's
`token_version`, which is bumped when the password changes, so a used or
superseded link stops validating.
"""
import os
import secrets
@@ -49,6 +46,7 @@ import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
import bcrypt
import jwt
from fastapi import Depends, HTTPException, Request, Response, status
from sqlalchemy import select, func
@@ -63,6 +61,8 @@ COOKIE_NAME = "wp_session"
JWT_ALG = "HS256"
# How long a login lasts before the user must sign in again.
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12"))
# How long an emailed password-reset link stays valid.
RESET_MINUTES = int(os.getenv("AUTH_RESET_MINUTES", "60"))
# ── permissions roles ─────────────────────────────────────────────────────────
ROLE_ADMIN = "admin"
@@ -121,6 +121,29 @@ def is_project_admin(user: "models.User") -> bool:
# same question used to exist here and silently disagreed with the scoped one, which
# locked per-project super users out of the routes they were entitled to.
# Password policy (shared by the API and the CLI).
MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12"))
_COMMON_PASSWORDS = {
"password", "password1", "password123", "passw0rd", "12345678", "123456789",
"1234567890", "qwerty123", "letmein123", "changeme", "admin123", "welcome123",
"iloveyou1", "abc12345", "qwertyuiop",
}
def password_problem(pw: str, username: str = "", email: str = "") -> Optional[str]:
"""Return a human-readable reason the password is unacceptable, or None if OK.
Shared by the API endpoints and the CLI so the policy is enforced everywhere."""
if len(pw) < MIN_PASSWORD_LEN:
return f"Password must be at least {MIN_PASSWORD_LEN} characters."
low = pw.lower()
if username and low == username.strip().lower():
return "Password must not be the same as the username."
if email and low == email.strip().lower():
return "Password must not be the same as the email."
if low in _COMMON_PASSWORDS:
return "That password is too common — choose something less guessable."
return None
# Paths under /api that do NOT require a session (login itself, health, docs).
_EXEMPT_PREFIXES = ("/api/auth/",)
_EXEMPT_EXACT = {
@@ -161,6 +184,22 @@ def _load_secret() -> str:
SECRET_KEY = _load_secret()
# ── password hashing ──────────────────────────────────────────────────────────
def hash_password(plain: str) -> str:
# bcrypt operates on at most 72 bytes; longer inputs are truncated by the
# algorithm. Encode explicitly so non-ASCII passwords hash consistently.
return bcrypt.hashpw(plain.encode("utf-8")[:72], bcrypt.gensalt()).decode("ascii")
def verify_password(plain: str, hashed: str) -> bool:
if not hashed:
return False
try:
return bcrypt.checkpw(plain.encode("utf-8")[:72], hashed.encode("ascii"))
except (ValueError, TypeError):
return False
# ── tokens ──────────────────────────────────────────────────────────────────
def create_token(user: "models.User") -> str:
now = datetime.now(timezone.utc)
@@ -188,6 +227,33 @@ def decode_token(token: str) -> Optional[dict]:
return claims
def create_reset_token(user: "models.User") -> str:
"""Short-lived, single-use token for an emailed password-reset link.
Single-use falls out of `ver`: completing a reset bumps the user's
token_version, so the link (and any older link) no longer validates."""
now = datetime.now(timezone.utc)
payload = {
"typ": "pwreset",
"sub": user.id,
"ver": user.token_version or 0,
"iat": now,
"exp": now + timedelta(minutes=RESET_MINUTES),
}
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
def decode_reset_token(token: str) -> Optional[dict]:
"""Claims for a valid, unexpired reset token, else None."""
try:
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
except jwt.PyJWTError:
return None
if claims.get("typ") != "pwreset":
return None
return claims
# ── cookie helpers ────────────────────────────────────────────────────────────
def _is_https(request: Request) -> bool:
# Behind NGINX, TLS is terminated at the proxy and forwarded as plain HTTP,

View File

@@ -1,66 +0,0 @@
# CN=PRIME CONTROLS ROOT CA
-----BEGIN CERTIFICATE-----
MIIFHzCCAwegAwIBAgIQOnMIcdMiP6pELiXfh2ZmLzANBgkqhkiG9w0BAQsFADAhMR8wHQYDVQQD
ExZQUklNRSBDT05UUk9MUyBST09UIENBMCAXDTIxMDkwOTEzMjM1OFoYDzIwNTEwOTA5MTMzMzU2
WjAhMR8wHQYDVQQDExZQUklNRSBDT05UUk9MUyBST09UIENBMIICIjANBgkqhkiG9w0BAQEFAAOC
Ag8AMIICCgKCAgEAri9Vkf+l3bnjGXWMEX15BYRnQTSKUStctG2NBppJ/lwj2LbHAvHf6HdkJAxx
2lqDiG0R+D9NcGEi427XOQ78Nc9aJe4jOwip5u5Md6Szwmu4QDRjUy11dDBvRoftD550052O1WOV
0OY5hxcZIo7bOfqDLesHG/Y74GJvrYai/4xq440uN6iTaMmsfzbIahIXP39NhuW4i4dgkQkRSfqX
y8i3AGS8WhpViVvIlMgGXRrCcBW8MnVp32OvKE2MqQnjZI2i5f8wMT4L0J0DXlSPbV6FPLdpBXl5
+OX+9qcEH6hI+Mv8sC/xGLAt/wwBP6E2kM2lovGGIUhBsay0UM03PJsh4r6q3HV5gpH6uYZlOgOY
UW59pNT/VyESdZb4kfEGHNHrHy3uNb8Q71UzQxrq/UVKXnBUMN/1PszV4YA08ZaYf2EGItZoNM3v
uTOJIifgtAo1DxaTygmglgk8CHlaN4IU8jotrzdksLYQ9MQaD5xnYADCnIf1eZ5VCF+fhpgQOCCV
4TfJ20rR14+jy6L4yKYGgtvg8H+hvhucOOnktpV/zLv4H9pItIzbQPuxNdrEmco2i7zrsMuaNYD/
tmbGf4mhy20zFOkdOjXGeb4cTF4HIBh3aue2T7o08vi1pkVN+8RXa7lZNbLJnpgtfTr9DKBpW2Fd
LqAb1zN2Azorg3kCAwEAAaNRME8wCwYDVR0PBAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0O
BBYEFMoWYtRcmyQQmB3AD4DPiau0r9XRMBAGCSsGAQQBgjcVAQQDAgEAMA0GCSqGSIb3DQEBCwUA
A4ICAQB0XhWlrK81ZKW9bBlbQe/i6js7ciDhwObgKB0FGZJH8rqK5HFwM/wZbh9h0t+YPk/ev6oG
BXU0rJUr1YVMERBb37c/rHBU4Bnv9r6cLhsfiTTcVGqZTM7JE9JfdmJQ10V1awSFoxU1cYMSzot9
TyrspkZJ6JFJH2wdhZirIySQHH5WuqEOdG6g9/bDW2X27B0S62s8WNIfP+gugkTDkiHQvoUDi8GY
/DA29lZTKPI8PhmKYZvw+fi1weqIYsFLk9pvHW21nIv6qAT7nfuGvuUR5siB7sFbw80XEX6Em8O0
0lnP6u+vecBqLK7D34X+aupDlkZlZdPoa0EaTwvTO8pkecZCdMLcTkB2quc/fcyCmVdj4CGn3yND
cUUR5wZbHtuCU27Rc4d3rY0gxPNpK3EXkTSOQL7BgR6EkwwNwUqeYmFb/SyXYeSqpdChvsWKgRrP
+8n27SeJ01ezk8GMDC0YcOJAAHxXqqOAJo1IwxVvpRkoljKIAprQwHAGUNTmKy+SJXT73/LpPTX+
RnbNTG27UfYONh5/DdkGc1wwmIp1X1a1tAb3MNRasiBGlYJ3NGDHVUwgEtmcVeVXWsj1ZhaXq2YX
TFsDl9m1IMhFD1n8pLJTLd7AT8Exxga+OzFjMzDu0uzKw3KoAVIX/IgfybdKexVT4Z+nBCY9N+n9
/vpujg==
-----END CERTIFICATE-----
# CN=PRIME CONTROLS ISSUING CA 1, DC=prime, DC=local
-----BEGIN CERTIFICATE-----
MIIH/jCCBeagAwIBAgITdQAAAAI3j8Wt9kAshQAAAAAAAjANBgkqhkiG9w0BAQsFADAhMR8wHQYD
VQQDExZQUklNRSBDT05UUk9MUyBST09UIENBMB4XDTIxMDkwOTE0NDUxM1oXDTM2MDkwOTE0NTUx
M1owVDEVMBMGCgmSJomT8ixkARkWBWxvY2FsMRUwEwYKCZImiZPyLGQBGRYFcHJpbWUxJDAiBgNV
BAMTG1BSSU1FIENPTlRST0xTIElTU1VJTkcgQ0EgMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC
AgoCggIBANvhsMj7RooZ+FzAExgWLex++F1QsdDqcGkwqjaUI5AziI4yb/FMqnO93Sus60hiqql+
hfxW/a55huBfo54kOyp5oWAjd0akfJQvFY8sXsWIGz07wiy0msvZDtHdBXy561zb0SSNpJS1Vceb
ihyDvM5Tyc6QAzO1u76QJPkegn+v6lpWmodba0kWVvRg/P3SLCnq1LdU8oA1VhIiCkCbdry0Syop
bVX0+evzINGmv81VbHct8ptZTBLctM11C16IwPEmntvOqVKpT166/aUO8FMqyHQQZ32ZHBp6ORNu
WlCN/EDdgT2s7Vy9/7Hr6gy1IPjEGNYq1yYxcpth+6/RgvxFnt9SWr7B9qKtYe1rN8r+6qYVw4ne
c0L7vRvw8HTjOJ9EQnPfID9+34Y8OQkqIHnnF80yjMGCdvh/tR/tXsUlz+Byt9m5MFdhPE22MX1x
jyCJFug1Ufso8toAVZcavsRfX0ygeUkv+zZDZKHmF910rB8Cdb980cpKtFvx/v0BNWnzgAhgGkqO
ttUf0PKUeGDIW6DMUKhs5JM7ED5rkepvNG5vePDVy/YidhbM4x1ph+HFVkHgT6rd1M4NeCm818C0
MVsofXdOOdbjmkQyxPGukl9EG1ukYMfUIQpFozTbbHKNUDfqcdHu8Qm39K7opwxMOiLprXGRSgx2
vMHI82mtAgMBAAGjggL6MIIC9jAQBgkrBgEEAYI3FQEEAwIBADAdBgNVHQ4EFgQU94Bw+7t8JXHx
VZoWN/pFMo005ZwwGQYJKwYBBAGCNxQCBAweCgBTAHUAYgBDAEEwCwYDVR0PBAQDAgGGMA8GA1Ud
EwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUyhZi1FybJBCYHcAPgM+Jq7Sv1dEwggEvBgNVHR8EggEm
MIIBIjCCAR6gggEaoIIBFoaByGxkYXA6Ly8vQ049UFJJTUUlMjBDT05UUk9MUyUyMFJPT1QlMjBD
QSxDTj1QUklNRS1ST09UQ0EsQ049Q0RQLENOPVB1YmxpYyUyMEtleSUyMFNlcnZpY2VzLENOPVNl
cnZpY2VzLENOPUNvbmZpZ3VyYXRpb24sREM9cHJpbWUsREM9bG9jYWw/Y2VydGlmaWNhdGVSZXZv
Y2F0aW9uTGlzdD9iYXNlP29iamVjdENsYXNzPWNSTERpc3RyaWJ1dGlvblBvaW50hklodHRwOi8v
Y3JsLnByaW1lLWNvbnRyb2xzLmNvbS9DZXJ0RW5yb2xsL1BSSU1FJTIwQ09OVFJPTFMlMjBST09U
JTIwQ0EuY3JsMIIBNAYIKwYBBQUHAQEEggEmMIIBIjCBuwYIKwYBBQUHMAKGga5sZGFwOi8vL0NO
PVBSSU1FJTIwQ09OVFJPTFMlMjBST09UJTIwQ0EsQ049QUlBLENOPVB1YmxpYyUyMEtleSUyMFNl
cnZpY2VzLENOPVNlcnZpY2VzLENOPUNvbmZpZ3VyYXRpb24sREM9cHJpbWUsREM9bG9jYWw/Y0FD
ZXJ0aWZpY2F0ZT9iYXNlP29iamVjdENsYXNzPWNlcnRpZmljYXRpb25BdXRob3JpdHkwYgYIKwYB
BQUHMAKGVmh0dHA6Ly9jcmwucHJpbWUtY29udHJvbHMuY29tL0NlcnRFbnJvbGwvUFJJTUUtUk9P
VENBX1BSSU1FJTIwQ09OVFJPTFMlMjBST09UJTIwQ0EuY3J0MA0GCSqGSIb3DQEBCwUAA4ICAQAw
2SIuBMB8JWC/YGbh3LJDt9T/z1BwEniLwEKu4SyBMfW3qJoLR0Zps8xHlCIlUjisZBSilHDNW4uW
4891yqg104OR0dx94dQV2Y6Aw9V4tvlw+GGnWpHbUNwP3/JizlndR6ZdhdnJlIt6g7xCy4LwyXrw
f22CibC1EWUVnsYJ8ez+LxSprWiUaEME8+bEaVuajXRitYyoG0asvtESeBhR3lwaxuzBqyAuGEfc
P8pO/fgjigripPScXnp6opQRzZ12VSNsN8BUmtCfsqX8DK7iRsn/QLZtOKVf6kapPkorovetKwai
wasE2mpKJweKYhGwWXdHrXZfCp6biTzG0oX4DGke7tHxdA92ArgmPR4SsNnpekUGcbUgkdpOc0sC
mt+LXzRvFKJNd205u/FvpZlBzRZl+NQ++9OGAywyzOb4Lxli49G0yFQ5Fpq0UElqDKiLzUqUR2Ye
qDT9nMJNTZEyP6hEjQQ+tSD9XFIlFWD83AyBHhRNKKZfPiu4mJxNYtW1ltVu5Z1EvBfbcRwc0lQa
VKGQS23sSIx6heq2q5FqhnYZ15zSXqaAH680Up5mOkeGIfm45rMIFl/aMAJ2Fntl+Taebosf2rv5
M/QOJs494ePbXxCJ1kzCLaqgucDXtadJ7ZV2AKhu3B9OiDrS8sv990Yn95ItlpthpVetiXxLHg==
-----END CERTIFICATE-----

View File

@@ -1,462 +0,0 @@
"""LDAPS authentication against the Windows domain — D13, built in T10.1.
The suite stores no passwords. A sign-in is a **simple bind** to
`ldaps://prime.local:636` as `<sAMAccountName>@prime.local` using the password the
person typed; a successful bind IS the authentication. This module owns that
conversation and nothing else — it does not touch the database, does not issue
sessions, and does not decide what anyone is allowed to do. `server/auth.py` and
the `login` route in `server/app.py` do that.
WHY `prime.local` AND NOT A DC NAME OR AN IP (this is load-bearing, do not "fix" it):
every domain controller's certificate carries `prime.local` in its SAN alongside its
own hostname, and the domain name round-robins in DNS across all six DCs published
in `_ldap._tcp.prime.local`. So connecting to the domain name both passes hostname
validation and gives failover in one move. Connecting to `192.168.3.37` instead
fails with `hostname mismatch` — the DC certificates carry no IP SAN — and the only
way to "fix" that is to disable the check, which is the one thing that must not
happen here (see below).
THE CLIENT PRESENTS NO CERTIFICATE. This module is the TLS *client*; clients verify,
they do not present. What it needs is a trust anchor: `PRIME CONTROLS ROOT CA` plus
`PRIME CONTROLS ISSUING CA 1`, shipped as a PEM bundle at `server/certs/`. Those are
public certificates — no private key, nothing secret, nothing issued to this app.
TWO WAYS THIS GOES CATASTROPHICALLY WRONG, both guarded here:
1. An EMPTY PASSWORD. In LDAP a simple bind with an empty password is an
*anonymous* bind, and it SUCCEEDS. Without an explicit guard a blank password
authenticates as whatever username was submitted. `verify()` therefore rejects
an empty or whitespace-only password BEFORE `bind()` is ever called. If you are
refactoring this file and that check looks redundant, it is not.
2. `validate=ssl.CERT_NONE`. It still encrypts, so it fails silently — what it
loses is the ability to tell the real DC from someone who terminates the TLS
session, harvests the domain password and relays the bind onward. Domain
credentials cross this channel, so that turns an app compromise into a Windows
compromise. `CERT_REQUIRED` with an explicit CA file is the only mode here, and
the system trust store is deliberately NOT used: it currently trusts five other
self-signed CAs on this estate, any of which could issue a DC-shaped cert.
FAILED BINDS COUNT AGAINST THE DOMAIN LOCKOUT POLICY. That is why nothing in here
retries a rejected credential — only genuine network failures are retried, and a
`bind()` that returns False is final. The caller throttles before it gets this far
(T10.2); this module's job is not to make the problem worse.
Unconfigured is a first-class state, as it is for `MICRON_DB_URL` in `assets_db.py`:
with no CA bundle or no `ldap3` installed, `is_configured()` is False and `verify()`
returns `UNCONFIGURED` rather than raising. Since D13 leaves no local password
fallback, the caller must surface that state loudly — an unconfigured deploy and a
mistyped password look identical at the login box otherwise.
"""
import logging
import os
import re
import ssl
from dataclasses import dataclass
from typing import Optional
log = logging.getLogger("wpsuite.ldap")
# ldap3 is pinned in requirements.txt. The import is guarded rather than assumed so
# that this module — and the test suite that imports it — still loads on a checkout
# where the dependency has not been installed. `is_configured()` reports the truth,
# and the startup line from `describe()` says so out loud.
try:
from ldap3 import Server, Connection, Tls, SIMPLE, SUBTREE, NONE
from ldap3.core.exceptions import (
LDAPException,
LDAPSocketOpenError,
LDAPSessionTerminatedByServerError,
LDAPCertificateError,
)
from ldap3.utils.conv import escape_filter_chars
HAVE_LDAP3 = True
except ImportError: # pragma: no cover
HAVE_LDAP3 = False
LDAPException = LDAPSocketOpenError = Exception
LDAPSessionTerminatedByServerError = LDAPCertificateError = Exception
def escape_filter_chars(x, encoding=None): # type: ignore[misc]
raise RuntimeError("ldap3 is not installed")
# ── configuration ─────────────────────────────────────────────────────────────
# Module level, matching how server/auth.py reads AUTH_SESSION_HOURS. Tests patch
# these attributes directly rather than re-importing.
DOMAIN = os.getenv("LDAP_DOMAIN", "prime.local")
# Defaults to the domain name on purpose — see the module docstring. Override only
# if you have a reason, and never with an IP address.
HOST = os.getenv("LDAP_HOST", "") or DOMAIN
PORT = int(os.getenv("LDAP_PORT", "636"))
CA_FILE = os.getenv("LDAP_CA_FILE", "") or os.path.join(
os.path.dirname(os.path.abspath(__file__)), "certs", "prime-ca-chain.pem"
)
TIMEOUT_SECONDS = int(os.getenv("LDAP_TIMEOUT_SECONDS", "8"))
# Retries apply to CONNECT failures only, never to a rejected credential. Two extra
# attempts covers the realistic case: DNS round-robin handed out a DC that is
# rebooting for patching.
CONNECT_RETRIES = int(os.getenv("LDAP_CONNECT_RETRIES", "2"))
# The initial value and the fallback for the admin-console setting added in T10.5.
REQUIRED_GROUP = os.getenv("LDAP_REQUIRED_GROUP", "")
# AD's "member of, transitively" extensible match. Plain `memberOf` is DIRECT
# membership only and would wrongly refuse anyone who is in a nested child group,
# which is how most estates actually organise access.
NESTED_MEMBER_RULE = "1.2.840.113556.1.4.1941"
_USER_ATTRS = ["sAMAccountName", "mail", "displayName", "userPrincipalName"]
# Reason codes. Machine-readable, for logs and for the caller's branching — never
# for a response body, because several of them would leak whether an account exists.
OK = "ok"
EMPTY_INPUT = "empty_input"
UNCONFIGURED = "unconfigured"
UNREACHABLE = "unreachable"
UNTRUSTED = "untrusted"
BAD_CREDENTIALS = "bad_credentials"
NOT_IN_GROUP = "not_in_group"
NO_DIRECTORY_ENTRY = "no_directory_entry"
GROUP_NOT_FOUND = "group_not_found"
GROUP_CHECK_FAILED = "group_check_failed"
# AD returns these as `data <code>` inside an error-49 message. Kept for the log
# only: telling an unauthenticated caller "your password expired" confirms the
# account exists, which `login()` goes out of its way not to do.
_ERR49 = {
"525": "no such user",
"52e": "bad password",
"530": "not permitted at this time",
"531": "not permitted at this workstation",
"532": "password expired",
"533": "account disabled",
"701": "account expired",
"773": "must change password",
"775": "account locked out",
}
_ERR49_RE = re.compile(r"data\s+([0-9a-fA-F]{3})")
@dataclass(frozen=True)
class LdapResult:
"""Outcome of a bind attempt.
`ok` is the only field the caller should branch on for allow/deny. `reason` and
`detail` are for logs. `sam`/`mail`/`full_name` are populated only on success and
are what T10.4 uses to match or provision the local account.
"""
ok: bool
reason: str = ""
detail: str = ""
sam: str = ""
mail: str = ""
full_name: str = ""
upn: str = ""
@property
def is_config_problem(self) -> bool:
"""True when the failure is ours, not the user's. With no local password
fallback (D13) these must be logged as errors and surfaced to an operator —
otherwise a broken deploy is indistinguishable from a forgotten password."""
return self.reason in (UNCONFIGURED, UNREACHABLE, UNTRUSTED, GROUP_NOT_FOUND,
GROUP_CHECK_FAILED)
def base_dn(domain: Optional[str] = None) -> str:
"""`prime.local` -> `DC=prime,DC=local`."""
d = (domain or DOMAIN or "").strip().strip(".")
return ",".join(f"DC={part}" for part in d.split(".") if part)
def is_configured() -> bool:
"""Whether a bind could even be attempted. Deliberately does not touch the
network — `selftest()` does that."""
return bool(HAVE_LDAP3 and HOST and CA_FILE and os.path.isfile(CA_FILE))
def describe() -> str:
"""One line for the startup log. D13 removed the local password path, so an
operator needs to see this in `docker compose logs api` rather than discovering
it when the first person cannot sign in."""
from . import ldap_fake
if ldap_fake.is_active():
return ("*** FAKE DIRECTORY ACTIVE — passwords come from "
f"{ldap_fake.ENV_VAR}, NOT from the domain. Tests only. ***")
if not HAVE_LDAP3:
return "LDAP auth DISABLED — ldap3 is not installed. No one can sign in."
if not CA_FILE or not os.path.isfile(CA_FILE):
return (f"LDAP auth DISABLED — CA bundle not found at {CA_FILE!r}. "
f"No one can sign in. Set LDAP_CA_FILE.")
group = REQUIRED_GROUP or "(none configured — every domain account may sign in)"
return (f"LDAP auth enabled — ldaps://{HOST}:{PORT}, domain {DOMAIN}, "
f"CA {CA_FILE}, required group: {group}")
def _tls() -> "Tls":
"""The only TLS configuration in this module.
`CERT_REQUIRED` plus an explicit `ca_certs_file`. ldap3 performs the hostname
check against the certificate's SAN itself whenever validate is not CERT_NONE,
which is what makes connecting by IP fail instead of quietly succeeding.
"""
# No `version=` pin: ldap3's default negotiates the highest version both ends
# support, which against these DCs is TLS 1.3. Pinning PROTOCOL_TLSv1_2 here
# would silently DOWNGRADE every connection to 1.2.
return Tls(
ca_certs_file=CA_FILE,
validate=ssl.CERT_REQUIRED,
)
def _server() -> "Server":
return Server(
HOST, port=PORT, use_ssl=True, tls=_tls(),
connect_timeout=TIMEOUT_SECONDS, get_info=NONE,
)
def _err49(result: Optional[dict]) -> str:
"""Human-readable AD sub-code from a bind failure, for the log only."""
msg = ((result or {}).get("message") or "")
m = _ERR49_RE.search(msg)
if not m:
return ((result or {}).get("description") or "invalid credentials")
code = m.group(1).lower()
return f"{code} ({_ERR49.get(code, 'unrecognised sub-code')})"
def normalize_username(raw: str) -> str:
"""Reduce whatever was typed in the login box to a `sAMAccountName`.
People type their email address. The mail domain here (`prime-controls.com`) is
not the AD domain (`prime.local`), so an address is never a valid bind string —
the local part is used instead. This assumes the mail local part equals the
sAMAccountName, which is the norm but not guaranteed; where it differs the person
must type their short logon name, and we log it so that is diagnosable.
ONE candidate is produced, never a list to try in turn: every rejected bind
counts against the domain lockout policy, so guessing would let a handful of
login attempts lock a real account out of Windows.
"""
name = (raw or "").strip()
if not name:
return ""
# DOMAIN\user, as typed by anyone used to a Windows logon prompt.
if "\\" in name:
name = name.rsplit("\\", 1)[1].strip()
if "@" in name:
local, _, dom = name.partition("@")
log.info("login input %r looks like an address; binding as sAMAccountName %r "
"(mail domain %r is not the AD domain)", name, local.strip(), dom)
name = local.strip()
return name
def _resolve_group_dn(conn, group: str) -> Optional[str]:
"""Accept either a distinguished name or a plain group name, return a DN."""
g = (group or "").strip()
if not g:
return None
if "," in g and "=" in g:
return g # already a DN
esc = escape_filter_chars(g)
conn.search(base_dn(), f"(&(objectClass=group)(|(cn={esc})(sAMAccountName={esc})))",
search_scope=SUBTREE, attributes=["cn"], size_limit=2)
if not conn.entries:
return None
if len(conn.entries) > 1:
log.warning("group %r is ambiguous in the directory (%d matches); using %s",
g, len(conn.entries), conn.entries[0].entry_dn)
return conn.entries[0].entry_dn
def member_of(conn, sam: str, group: str) -> bool:
"""Is `sam` in `group`, counting nested membership?
THE RETURN VALUE OF conn.search() IS NOT OPTIONAL READING. The connection is
built with raise_exceptions=False, so a search that FAILS returns False and
leaves conn.entries empty — which is byte-for-byte indistinguishable from "no
match" if you only look at conn.entries. An earlier version of this function did
exactly that, and every failure of the extensible-match filter presented to the
user as "you are not in the group" while they plainly were.
Two searches, in order, and the second one exists to catch the first being wrong:
1. AD's transitive matching rule (LDAP_MATCHING_RULE_IN_CHAIN). This is the
correct query — it walks nested groups, which plain memberOf does not.
2. If that matches nothing, a plain memberOf equality check for DIRECT
membership.
If (2) matches after (1) did not, the person IS a member and is let in — refusing
a real member is the worse error — but it is logged as a WARNING, because it means
the transitive rule is returning nothing and NESTED membership is silently not
working on this connection. That needs a human; it must not pass unnoticed.
"""
dn = _resolve_group_dn(conn, group)
if not dn:
log.error("required group %r does not resolve in %s — refusing the sign-in. "
"This is a configuration fault, not a bad password.", group, base_dn())
raise LookupError(GROUP_NOT_FOUND)
esc_sam, esc_dn = escape_filter_chars(sam), escape_filter_chars(dn)
def _search(filt: str, label: str):
ok = conn.search(base_dn(), filt, search_scope=SUBTREE,
attributes=["sAMAccountName"], size_limit=1)
if not ok:
log.error("the %s membership search FAILED (not 'no match') for %r: %s | "
"filter=%s", label, sam, conn.result, filt)
raise LookupError(GROUP_CHECK_FAILED)
return bool(conn.entries)
if _search(f"(&(sAMAccountName={esc_sam})(memberOf:{NESTED_MEMBER_RULE}:={esc_dn}))",
"nested"):
return True
if _search(f"(&(sAMAccountName={esc_sam})(memberOf={esc_dn}))", "direct"):
log.warning(
"%r IS a direct member of %r, but AD's transitive matching rule "
"(%s) returned nothing for them. Allowing the sign-in — refusing a real "
"member is worse — but NESTED group membership is not working on this "
"connection and needs investigating.", sam, dn, NESTED_MEMBER_RULE)
return True
return False
def verify(username: str, password: str, required_group: Optional[str] = None) -> LdapResult:
"""Authenticate against the domain. The only entry point the app should call.
`required_group` overrides the `LDAP_REQUIRED_GROUP` default so the admin-console
setting (T10.5) wins. Pass an empty string to mean "no group gate"; pass None to
use the configured default.
"""
sam = normalize_username(username)
# ── guard 1: no bind on empty input ──────────────────────────────────────
# An empty password makes the bind below an ANONYMOUS bind, which SUCCEEDS and
# would authenticate `sam` without proving anything at all. Must stay before
# every return path that reaches bind().
if not sam or not (password or "").strip():
return LdapResult(False, EMPTY_INPUT, "empty username or password")
group = REQUIRED_GROUP if required_group is None else required_group
# Test seam (T10.7). Deliberately placed AFTER the empty-input guard above, so
# the anonymous-bind guard covers the fake path too — a fake that re-implemented
# it would let the real one rot without any test noticing. `is_active()` refuses
# whenever a non-SQLite DATABASE_URL is configured; see server/ldap_fake.py.
from . import ldap_fake
if ldap_fake.is_active():
ok, reason, attrs = ldap_fake.lookup(sam, password, group)
if not ok:
log.info("fake directory refused %r: %s", sam, reason)
return LdapResult(False, reason, "fake directory")
return LdapResult(True, OK, "fake directory", **attrs)
if not is_configured():
return LdapResult(False, UNCONFIGURED, describe())
bind_user = f"{sam}@{DOMAIN}"
last_network_error = ""
# Retries cover CONNECT failures only: DNS round-robin across six DCs will
# eventually hand out one that is rebooting. A rejected credential returns
# immediately and is never retried — each attempt counts against AD lockout.
for attempt in range(1, max(1, CONNECT_RETRIES + 1) + 1):
conn = None
try:
conn = Connection(
_server(), user=bind_user, password=password,
authentication=SIMPLE, read_only=True,
receive_timeout=TIMEOUT_SECONDS, raise_exceptions=False,
)
if not conn.bind():
detail = _err49(conn.result)
log.warning("bind refused for %r: %s", sam, detail)
return LdapResult(False, BAD_CREDENTIALS, detail)
# Bound as the user. AD lets an account read its own object, so no
# service account is needed for either of the next two steps.
conn.search(base_dn(),
f"(&(objectClass=user)(sAMAccountName={escape_filter_chars(sam)}))",
search_scope=SUBTREE, attributes=_USER_ATTRS, size_limit=1)
if not conn.entries:
log.error("bind succeeded for %r but the account has no readable "
"directory entry under %s", sam, base_dn())
return LdapResult(False, NO_DIRECTORY_ENTRY, "no readable directory entry")
e = conn.entries[0]
def one(attr: str) -> str:
v = getattr(e, attr, None)
return str(v.value) if v is not None and v.value else ""
if group:
try:
if not member_of(conn, sam, group):
log.warning("bind succeeded for %r but the account is NOT in %r",
sam, group)
return LdapResult(False, NOT_IN_GROUP, f"not in {group}")
except LookupError as exc:
reason = str(exc) or GROUP_NOT_FOUND
return LdapResult(False, reason, f"group {group!r}: {reason}")
return LdapResult(
True, OK,
sam=one("sAMAccountName") or sam,
mail=one("mail"),
full_name=one("displayName"),
upn=one("userPrincipalName"),
)
except LDAPCertificateError as exc:
# NOT retried and NOT downgraded. Either the CA bundle is wrong or
# something is impersonating a DC; both need a human, and retrying with
# relaxed validation is exactly the wrong instinct.
log.error("LDAPS certificate validation FAILED against %s: %s. Refusing "
"to continue — check LDAP_CA_FILE, and never set CERT_NONE.",
HOST, exc)
return LdapResult(False, UNTRUSTED, str(exc))
except (LDAPSocketOpenError, LDAPSessionTerminatedByServerError) as exc:
last_network_error = str(exc)
log.warning("LDAPS connect to %s:%s failed (attempt %d): %s",
HOST, PORT, attempt, exc)
continue
except LDAPException as exc:
log.error("LDAP error for %r: %s", sam, exc)
return LdapResult(False, UNREACHABLE, str(exc))
finally:
if conn is not None:
try:
conn.unbind()
except Exception:
pass
return LdapResult(False, UNREACHABLE,
last_network_error or f"no domain controller answered on {HOST}:{PORT}")
def selftest() -> LdapResult:
"""Open a TLS session to the domain and validate the certificate, WITHOUT binding.
Used by the startup check and by the admin console's diagnostics. Touches no
account, so it cannot contribute to a lockout. Proves the three things that
actually break a deploy: DNS resolves, a DC answers on 636, and the presented
certificate validates against our CA bundle.
"""
if not is_configured():
return LdapResult(False, UNCONFIGURED, describe())
conn = None
try:
conn = Connection(_server(), receive_timeout=TIMEOUT_SECONDS, raise_exceptions=True)
conn.open()
return LdapResult(True, OK, f"ldaps://{HOST}:{PORT} certificate validates")
except LDAPCertificateError as exc:
return LdapResult(False, UNTRUSTED, str(exc))
except LDAPException as exc:
return LdapResult(False, UNREACHABLE, str(exc))
finally:
if conn is not None:
try:
conn.unbind()
except Exception:
pass

View File

@@ -1,95 +0,0 @@
"""A fake directory, for tests only — D13 / T10.7.
`server/ldap_auth.py` normally opens an LDAPS connection to a domain controller.
Tests cannot: CI has no domain, and the browser checks launch the app as a
SUBPROCESS (`start_server` in tests/browser_check.py), so a monkeypatch in the
test process would never reach the code doing the authenticating. The seam has to
be configurable from the ENVIRONMENT, which is what this module is.
Set `WP_LDAP_FAKE_DIRECTORY` to a JSON object and `ldap_auth.verify()` answers
from it instead of touching the network:
{"root": {"password": "", "mail": "root@example.test",
"full_name": "Root", "groups": ["WP-Suite-Users"]}}
`groups` is a flat list of names the account is "in". Nested groups do not exist
here — the real `member_of` resolves a DN and uses AD's LDAP_MATCHING_RULE_IN_CHAIN,
and faking that faithfully would mean reimplementing AD. A test that cares about
nesting has to run against a real directory; this one is honest about being a
string comparison.
THE PRODUCTION GUARD IS THE POINT OF THIS FILE.
An environment variable that makes any password work is exactly the kind of thing
that escapes into production, and D13 removed every other way in — there is no
local password to fall back on and no break-glass, so a fake directory silently
active in production would be a total authentication bypass with nothing behind it.
So `is_active()` refuses whenever a real database is configured, using the same
test `auth._load_secret` uses to refuse an ephemeral signing key: a non-SQLite
`DATABASE_URL` means production, full stop. `ldap_auth.describe()` also shouts
when the fake is live, so the startup line can never be mistaken for a real one.
"""
import json
import logging
import os
from typing import Optional
log = logging.getLogger("wpsuite.ldap.fake")
ENV_VAR = "WP_LDAP_FAKE_DIRECTORY"
def _raw() -> str:
return os.getenv(ENV_VAR, "").strip()
def is_active() -> bool:
"""Whether the fake should answer. False in anything resembling production."""
if not _raw():
return False
# Imported lazily: server.db reads DATABASE_URL at import, and this module is
# imported from ldap_auth, which must stay importable on its own.
from .db import DATABASE_URL
if not str(DATABASE_URL).startswith("sqlite"):
log.error(
"%s is set but a non-SQLite DATABASE_URL is configured. REFUSING to use "
"the fake directory — this looks like production, and D13 leaves no "
"other way in, so honouring it would be an authentication bypass. "
"Unset %s.", ENV_VAR, ENV_VAR)
return False
return True
def directory() -> dict:
try:
data = json.loads(_raw())
if not isinstance(data, dict):
raise ValueError("top level must be an object")
return data
except Exception as exc: # noqa: BLE001 — a malformed fake must not look like a bad password
log.error("%s is not valid JSON (%s); the fake directory is empty", ENV_VAR, exc)
return {}
def lookup(username: str, password: str, required_group: Optional[str]) -> tuple:
"""Return (ok, reason, attrs). Mirrors what ldap_auth.verify() needs.
Deliberately does NOT re-check for an empty password: `verify()` guards that
before it ever gets here, and duplicating the check in the fake would let the
real guard rot without any test noticing.
"""
people = directory()
who = people.get(username) or people.get(username.lower())
if not isinstance(who, dict) or password != who.get("password"):
return (False, "bad_credentials", {})
if required_group:
groups = who.get("groups") or []
if required_group not in groups:
return (False, "not_in_group", {})
return (True, "ok", {
"sam": who.get("sam") or username,
"mail": who.get("mail", ""),
"full_name": who.get("full_name", ""),
"upn": who.get("upn", ""),
})

View File

@@ -1,42 +1,21 @@
"""Command-line user management for the Work Package Suite.
Accounts are not created here any more. D13 provisions them on first successful
sign-in, so this tool exists to do the one thing the directory cannot decide:
assign the app's PERMISSIONS role. The directory supplies identity; this supplies
authorization.
Use this to create the FIRST admin account (the /api/auth/users endpoint needs an
existing admin, so you have to bootstrap one here), and for occasional account
maintenance from a shell on the server.
Run from the PROJECT ROOT (same place you run uvicorn), so the package imports
and .env resolve the same way the API does:
python -m server.manage_users create-admin alice --name "Alice Smith"
python -m server.manage_users create bob --role user --name "Bob Jones"
python -m server.manage_users list
python -m server.manage_users promote alice # -> admin
python -m server.manage_users promote bob --role project_admin
python -m server.manage_users demote alice # -> project_user
python -m server.manage_users reset-password alice
python -m server.manage_users disable bob
python -m server.manage_users enable bob
Run from the PROJECT ROOT (same place you run uvicorn) so the package imports and
.env resolve the way the API does.
`create-admin` and `create` are GONE (D14). They were redundant once accounts
provision themselves, and removing them closes a whole class of problem: every
row now originates from a successful bind, so a username can no longer be typed
in wrong and end up orphaned from the directory identity it was meant to match.
BOOTSTRAPPING THE FIRST ADMIN is therefore two steps, in this order:
1. Sign in to the app once. That provisions your account at project_user.
2. Run `promote <your-sAMAccountName>` here.
EVERY COMMAND THAT CHANGES ANYTHING REQUIRES A DOMAIN BIND (D14). Shell access
alone is no longer enough to mint an admin. Be clear about what that is and is
not worth: anyone with a shell on this container can still write to the `users`
table directly with psql or sqlite3, so this is defence in depth and — mostly —
ACCOUNTABILITY. Before D14 every role change made from a shell was invisible in
the audit trail while the same change through the console was recorded. Now both
are recorded, and both name a person.
The bind here deliberately does NOT apply the login group gate. If a mistyped
required group locks everyone out of the console, this tool has to still work —
otherwise the only way to fix the lockout is the only thing the lockout prevents.
`list` needs no credential, so an outage stays diagnosable.
If --password is omitted you'll be prompted (input is hidden). Passwords must be
at least 8 characters.
"""
import argparse
import getpass
@@ -44,194 +23,124 @@ import sys
import uuid
from .db import SessionLocal, Base, engine
from . import models, auth, ldap_auth
from . import models, auth
def _gen_id(prefix: str = "user") -> str:
return f"{prefix}_{uuid.uuid4().hex[:12]}"
def _gen_id() -> str:
return f"user_{uuid.uuid4().hex[:12]}"
def _audit(db, actor: str, action: str, user: "models.User", detail: dict) -> None:
"""Append an audit row in the caller's transaction.
Written by hand rather than via app.py's log_event: importing that would drag
FastAPI and the entire application into a CLI startup for one INSERT.
"""
db.add(models.AuditLog(
id=_gen_id("ev"), actor=actor, action=action, entity_type="user",
entity_id=user.id, summary=user.username, detail=detail,
))
def authenticate_operator() -> str:
"""Prompt for a domain credential, bind, and return the operator's sAMAccountName.
Exits on failure — a command that changes a role must not proceed unauthenticated.
The password is only ever read from a hidden prompt: there is no --password flag,
because that would put a live domain password into shell history and into the
output of `ps` for every other user on the box.
"""
if not ldap_auth.is_configured():
sys.exit(f"Cannot authenticate: {ldap_auth.describe()}\n"
f"This command needs a domain bind. Fix the LDAP configuration first.")
who = input("Your domain username: ").strip()
if not who:
sys.exit("Cancelled.")
pw = getpass.getpass("Your domain password: ")
def _prompt_password(provided: str | None, username: str = "") -> str:
pw = provided
if not pw:
sys.exit("Cancelled.")
# required_group="" on purpose: see the module docstring. The login group must
# not be able to lock an operator out of the tool that fixes the login group.
result = ldap_auth.verify(who, pw, required_group="")
if not result.ok:
# SAY WHY. The /api/auth/login endpoint deliberately returns one generic
# message so an unauthenticated caller cannot enumerate accounts; that
# reasoning does NOT transfer here. This is a local tool, the operator is
# the account holder, and there is nobody to leak to — so withholding the
# AD sub-code only makes a failure undiagnosable. An earlier version of
# this function printed "Authentication failed." and nothing else.
print(f"Authentication failed: {result.reason}{result.detail}", file=sys.stderr)
print(f" bind attempted as : {ldap_auth.normalize_username(who)}@{ldap_auth.DOMAIN}",
file=sys.stderr)
print(f" server : ldaps://{ldap_auth.HOST}:{ldap_auth.PORT}", file=sys.stderr)
print(f" password length : {len(pw)} characters", file=sys.stderr)
if result.reason == ldap_auth.BAD_CREDENTIALS:
print(" The sub-code above is AD's own reason: 52e = wrong password, "
"775 = account locked out, 532 = password expired, "
"533 = account disabled, 525 = no such user.", file=sys.stderr)
print(" A 525 with a password you know is correct means the BIND NAME is "
"wrong, not the password. This binds as <sAMAccountName>@LDAP_DOMAIN, "
"which only works where that matches your real UPN suffix — set "
"LDAP_DOMAIN to the UPN suffix if yours differs from the AD DNS name.",
file=sys.stderr)
sys.exit(1)
print(f"Authenticated as {result.sam}.")
return result.sam
pw = getpass.getpass("New password: ")
confirm = getpass.getpass("Confirm password: ")
if pw != confirm:
sys.exit("Passwords do not match.")
problem = auth.password_problem(pw, username)
if problem:
sys.exit(problem)
return pw
def _load(db, username: str) -> "models.User":
u = auth.find_user(db, username)
if not u:
sys.exit(f"No account named '{username}'. Accounts are created on first "
f"sign-in — has this person signed in yet? `list` shows who exists.")
return u
def cmd_create(args, role: str | None = None) -> None:
role = role or args.role
# 'user' is the pre-roles spelling of 'project_user' and is still accepted so the
# documented one-liners keep working; anything else has to be a current role.
if role == "user":
role = auth.ROLE_PROJECT_USER
if role not in auth.ROLES:
sys.exit(f"role must be one of {', '.join(auth.ROLES)}")
pw = _prompt_password(getattr(args, "password", None), args.username)
with SessionLocal() as db:
if auth.find_user(db, args.username):
sys.exit(f"A user named '{args.username}' already exists.")
u = models.User(
id=_gen_id(),
username=args.username.strip(),
full_name=(args.name or "").strip(),
email=(args.email or "").strip(),
password_hash=auth.hash_password(pw),
role=role,
)
db.add(u)
db.commit()
print(f"Created {role}: {u.username} (id={u.id})")
def cmd_list(args) -> None:
"""Read-only, and deliberately needs no credential: during an outage this is
how you find out what the app thinks the world looks like."""
with SessionLocal() as db:
rows = db.query(models.User).order_by(models.User.username).all()
if not rows:
print("No accounts yet. They are created on first successful sign-in.")
print("No users yet. Create one with: create-admin <username>")
return
print(f"{'USERNAME':<24}{'ROLE':<20}{'ACTIVE':<8}{'LAST LOGIN':<22}{'NAME'}")
print(f"{'USERNAME':<24}{'ROLE':<20}{'ACTIVE':<8}{'NAME'}")
for u in rows:
last = u.last_login_at.strftime("%Y-%m-%d %H:%M") if u.last_login_at else "never"
print(f"{u.username:<24}{auth.normalize_role(u.role):<20}"
f"{('yes' if u.is_active else 'no'):<8}{last:<22}{u.full_name}")
f"{('yes' if u.is_active else 'no'):<8}{u.full_name}")
def _set_role(username: str, role: str) -> None:
if role not in auth.ROLES:
sys.exit(f"role must be one of {', '.join(auth.ROLES)}")
operator = authenticate_operator()
def cmd_reset_password(args) -> None:
pw = _prompt_password(getattr(args, "password", None), args.username)
with SessionLocal() as db:
u = _load(db, username)
old = auth.normalize_role(u.role)
if old == role:
print(f"{u.username} is already {role}. Nothing to do.")
return
# The last-admin guard, matching set_user_role in app.py: an app with no
# admin cannot be administered, and there is no password login left to
# recover through.
if old == auth.ROLE_ADMIN and role != auth.ROLE_ADMIN:
others = db.query(models.User).filter(
models.User.role == auth.ROLE_ADMIN,
models.User.id != u.id,
models.User.is_active.is_(True),
).count()
if not others:
sys.exit("Refusing: that is the last active admin account. Promote "
"someone else first.")
detail = {"from": old, "to": role, "via": "manage_users"}
u.role = role
# Mirrors set_user_role: an admin already reaches every project, so the
# default-member flag would sit there doing nothing and spring back to life
# on demotion.
if role == auth.ROLE_ADMIN and (u.auto_add_projects or u.auto_add_role):
u.auto_add_projects = False
u.auto_add_role = ""
detail["auto_add_cleared"] = True
# NOTE: unlike the API, this permits changing your OWN role. The endpoint
# forbids it to stop an admin locking themselves out of the console; here it
# is the entire bootstrap path — sign in, then promote yourself.
if u.username.lower() == operator.lower():
detail["self"] = True
_audit(db, operator, "role_changed", u, detail)
u = auth.find_user(db, args.username)
if not u:
sys.exit(f"No user named '{args.username}'.")
u.password_hash = auth.hash_password(pw)
db.commit()
print(f"{u.username}: {old} -> {role}")
def cmd_promote(args) -> None:
_set_role(args.username, args.role)
def cmd_demote(args) -> None:
_set_role(args.username, auth.ROLE_PROJECT_USER)
print(f"Password reset for {u.username}.")
def _set_active(username: str, active: bool) -> None:
operator = authenticate_operator()
with SessionLocal() as db:
u = _load(db, username)
if bool(u.is_active) == active:
print(f"{u.username} is already {'enabled' if active else 'disabled'}.")
return
u = auth.find_user(db, username)
if not u:
sys.exit(f"No user named '{username}'.")
u.is_active = active
# Disabling has to take effect on sessions already issued, and role reads go
# through the database on every request — but token_version is what get_current_user
# checks, so bump it to sign them out now rather than at session expiry.
u.token_version = (u.token_version or 0) + 1
_audit(db, operator, "user_active_changed", u,
{"is_active": active, "via": "manage_users"})
db.commit()
print(f"{u.username} is now {'enabled' if active else 'disabled'}.")
def main() -> None:
# Ensure tables exist on a fresh local database (SQLite dev). Production owns
# its schema through alembic.
# Ensure the users table exists even on a fresh database.
Base.metadata.create_all(bind=engine)
p = argparse.ArgumentParser(
prog="manage_users",
description="Work Package Suite user management. Accounts are created on "
"first sign-in (D13); this assigns roles.")
p = argparse.ArgumentParser(prog="manage_users", description="Work Package Suite user management")
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("list", help="list all accounts (no credential needed)")
def add_create(name, help_):
sp = sub.add_parser(name, help=help_)
sp.add_argument("username")
sp.add_argument("--password", help="set non-interactively (otherwise prompted)")
sp.add_argument("--name", default="", help="full name")
sp.add_argument("--email", default="")
return sp
pr = sub.add_parser("promote", help="raise an account's permissions role (needs a domain bind)")
pr.add_argument("username", help="the person's sAMAccountName")
pr.add_argument("--role", default=auth.ROLE_ADMIN, choices=list(auth.ROLES),
help="target role (default: admin)")
add_create("create-admin", "create an admin account")
c = add_create("create", "create an account")
c.add_argument("--role", choices=list(auth.ROLES) + ["user"], default=auth.ROLE_PROJECT_USER,
help="permissions role ('user' is the legacy name for project_user)")
dm = sub.add_parser("demote", help=f"set an account back to {auth.ROLE_PROJECT_USER}")
dm.add_argument("username", help="the person's sAMAccountName")
sub.add_parser("list", help="list all accounts")
dp = sub.add_parser("disable", help="disable an account (blocks sign-in)")
rp = sub.add_parser("reset-password", help="reset a user's password")
rp.add_argument("username")
rp.add_argument("--password", help="set non-interactively (otherwise prompted)")
dp = sub.add_parser("disable", help="disable an account (blocks login)")
dp.add_argument("username")
ep = sub.add_parser("enable", help="re-enable an account")
ep.add_argument("username")
args = p.parse_args()
if args.cmd == "list":
if args.cmd == "create-admin":
cmd_create(args, role="admin")
elif args.cmd == "create":
cmd_create(args)
elif args.cmd == "list":
cmd_list(args)
elif args.cmd == "promote":
cmd_promote(args)
elif args.cmd == "demote":
cmd_demote(args)
elif args.cmd == "reset-password":
cmd_reset_password(args)
elif args.cmd == "disable":
_set_active(args.username, False)
elif args.cmd == "enable":

View File

@@ -142,14 +142,8 @@ class WorkPackage(Base):
class User(Base):
"""A login account. NO PASSWORD IS STORED — D13 moved authentication to an
LDAPS bind against the domain (see server/ldap_auth.py), and the
`password_hash` column was dropped. `username` is the sAMAccountName people
sign in with; an account is created on first successful sign-in if it does not
already exist.
`is_active` is LOCAL and overrides the directory: clearing it revokes access to
this app without touching the domain account.
"""A login account. Passwords are never stored in the clear — only a bcrypt
hash (see server/auth.py). `username` is what people sign in with.
Two independent notions of "role", deliberately separate:
• role the PERMISSIONS role — what the account may do in the app.
@@ -165,6 +159,7 @@ class User(Base):
username: Mapped[str] = mapped_column(String(120), unique=True, index=True)
email: Mapped[str] = mapped_column(String(200), default="")
full_name: Mapped[str] = mapped_column(String(200), default="")
password_hash: Mapped[str] = mapped_column(String(200), default="")
role: Mapped[str] = mapped_column(String(20), default="project_user") # permissions role
# Job function on the project — free text, offered from a suggested list.
project_role: Mapped[str] = mapped_column(String(120), default="")
@@ -187,14 +182,12 @@ class User(Base):
# Online-guessing throttle (see login()): consecutive failures + a lockout window.
failed_attempts: Mapped[int] = mapped_column(Integer, default=0)
locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# Bumped to invalidate all existing sessions for this user. Password changes no
# longer exist (D13), but a role change or a deactivation still has to take
# effect on live sessions. The value is embedded in the JWT and re-checked on
# every request.
# Bumped to invalidate all existing sessions for this user (e.g. on a password
# change). The value is embedded in the JWT and re-checked on every request.
token_version: Mapped[int] = mapped_column(Integer, default=0)
def to_dict(self) -> dict:
"""Public view of a user."""
"""Public view of a user — NEVER includes the password hash."""
return {
"id": self.id, "username": self.username, "email": self.email,
"full_name": self.full_name, "role": self.role,

View File

@@ -49,8 +49,7 @@ DEFAULTS = {
# Settings the app needs before anyone is signed in, or that carry no secrets and
# are safe for any authenticated user to read (feature flags + localization
# defaults). Self-service password reset is gone with D13 — the login page links to
# Okta instead, so there is nothing left for the client to feature-detect.
# defaults + whether self-service password reset can work at all).
PUBLIC_KEYS = ("bim_enabled", "default_locale", "default_timezone")
@@ -84,9 +83,13 @@ def public_settings(db: Session) -> dict:
def app_flags(db: Session) -> dict:
"""Feature flags for any signed-in user (no secrets, no SMTP detail)."""
"""Feature flags for any signed-in user (no secrets, no SMTP detail).
`password_reset_enabled` tells the login page whether a self-service reset can
actually deliver mail — there's no point offering the link otherwise."""
s = get_settings(db)
return {k: s.get(k) for k in PUBLIC_KEYS}
out = {k: s.get(k) for k in PUBLIC_KEYS}
out["password_reset_enabled"] = bool(s.get("email_enabled")) and smtp_ready(s)
return out
def smtp_ready(s: dict) -> bool:
@@ -121,6 +124,22 @@ def send_email(s: dict, to_addr: str, subject: str, body: str) -> None:
srv.send_message(msg)
def send_now(db: Session, to_addr: str, subject: str, body: str) -> bool:
"""Send one email immediately, outside the outbox. Used for password resets —
a reset link must never sit in a queue, and it must not be persisted in the
notifications table where an admin could read it and take over the account.
Returns True if it went out."""
s = get_settings(db)
if not (s.get("email_enabled") and smtp_ready(s) and to_addr):
return False
try:
send_email(s, to_addr, subject, body)
return True
except Exception as e: # noqa: BLE001 — never surface SMTP detail to the caller
log.warning("password-reset email to %s failed: %s", to_addr, e)
return False
def enqueue(db: Session, *, user: "models.User", kind: str, subject: str, body: str,
link: str = "", wp_id: Optional[str] = None, project_id: Optional[str] = None) -> "models.Notification":
"""Record a notification. Marked 'pending' only if email is enabled + SMTP ready +

View File

@@ -19,8 +19,4 @@ pydantic==2.13.4
python-dotenv==1.2.2
bcrypt==5.0.0 # password hashing
PyJWT==2.13.0 # signed session tokens
ldap3==2.9.1 # D13: LDAPS simple bind against prime.local. Pure Python,
# so no system libldap/OpenLDAP headers in the image. The
# trust anchor is server/certs/prime-ca-chain.pem, NOT the
# system store — see server/ldap_auth.py.
starlette==1.3.1 # pinned transitive (cookie / CORS handling — security-relevant)

View File

@@ -26,7 +26,6 @@ browser found, or the server would not start). 2 is distinct on purpose: "I coul
not test this" is not the same answer as "this is broken".
"""
import argparse
import json
import os
import subprocess
import sys
@@ -87,7 +86,7 @@ def seed(db_path):
def mk(username, role):
db.add(models.User(id="user_" + username, username=username,
email=f"{username}@example.test", full_name=username.title(),
role=role))
password_hash=auth.hash_password(PW), role=role))
mk("root", auth.ROLE_ADMIN)
mk("sue", auth.ROLE_PROJECT_SUPER) # super user on Job A
@@ -151,33 +150,10 @@ def seed(db_path):
for u in db.query(models.User).all()}
# D13 / T10.7. The app authenticates by binding to a domain controller, which no
# test can reach, and start_server launches it as a SUBPROCESS — so a monkeypatch
# here would never reach the code doing the authenticating. server/ldap_fake.py
# reads this instead, and refuses to work against a non-SQLite database.
#
# Most checks never sign in (seed() mints tokens with auth.create_token and sets
# the cookie directly), so this matters only where the login FORM is driven —
# url_state_check's deep-link-through-login case. It is set for every server here
# anyway so that a test which starts signing in later does not fail mysteriously.
FAKE_DIRECTORY = json.dumps({
u: {"password": PW, "mail": f"{u}@example.test", "full_name": u.title(),
"groups": ["WP-Suite-Users"]}
for u in ("root", "sue", "pat", "mix", "bob", "sam", "legacy", "new")
})
def start_server(port, db_path):
env = dict(os.environ)
env["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/")
env.setdefault("AUTH_SECRET_KEY", "browser-check-secret-not-for-production")
env["WP_LDAP_FAKE_DIRECTORY"] = FAKE_DIRECTORY
# SET empty, never pop: server/db.py calls load_dotenv() at import and
# python-dotenv only skips keys already present in os.environ, so a popped
# variable comes back from the developer's .env inside the subprocess. An empty
# string is "present" and therefore wins. The fake grants "WP-Suite-Users" to
# everyone; a test asserting the group gate belongs in ldap_auth_check.
env["LDAP_REQUIRED_GROUP"] = ""
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "server.app:app", "--host", "127.0.0.1",
"--port", str(port), "--log-level", "warning"],

View File

@@ -91,6 +91,31 @@ def main():
chk("the console booted with a user table",
page.eval("!!document.querySelector('table')"))
page.eval("void resetPw('user_pat','pat')")
time.sleep(0.4)
chk("the reset prompt is the kit's modal, open, focused at the input",
page.eval("(() => { const o=document.getElementById('wp-dlg-overlay');"
" return !!o && o.classList.contains('open')"
" && document.activeElement.id==='wp-dlg-input'; })()"))
page.eval("document.getElementById('wp-dlg-input').value='short';"
"document.getElementById('wp-dlg-ok').click()")
chk("a short password is refused AT the input - dialog stays, error says why",
page.eval("(() => { const o=document.getElementById('wp-dlg-overlay');"
" return o.classList.contains('open')"
" && /12 characters/.test(document.getElementById('wp-dlg-err').textContent); })()"))
page.eval("document.getElementById('wp-dlg-input').value='CorrectHorseBattery10';"
"document.getElementById('wp-dlg-ok').click()")
time.sleep(1.2)
chk("a good answer closes the dialog and the server accepts it",
page.eval("!document.getElementById('wp-dlg-overlay').classList.contains('open')"))
chk("...announced through the kit's toast (role=status)",
page.eval("(() => { const t=document.getElementById('toast');"
" return !!t && t.getAttribute('role')==='status'"
" && /Password reset for pat/.test(t.textContent); })()"))
st, _ = api(base, "/api/auth/login", "x", "POST",
{"username": "pat", "password": "CorrectHorseBattery10"})
chk("...and the new password actually works", st == 200, st)
print("\n3. destroy needs a real yes")
page.eval("void deleteUser('user_bob','bob')")
time.sleep(0.4)

View File

@@ -55,7 +55,8 @@ def seed_empty(db_path):
Base.metadata.create_all(bind=engine)
with SessionLocal() as db:
db.add(models.User(id="user_new", username="new", email="new@example.test",
full_name="New Starter", role=auth.ROLE_ADMIN))
full_name="New Starter", password_hash=auth.hash_password(PW),
role=auth.ROLE_ADMIN))
db.commit()
return {u.username: auth.create_token(u) for u in db.query(models.User).all()}

View File

@@ -1,419 +0,0 @@
#!/usr/bin/env python3
"""Does domain authentication hold its guarantees? — D13 / T10.7.
Covers the four things `docs/waves/decisions-2026-08-21.md` calls non-negotiable,
plus the two provisioning rules that decide whether an existing admin survives the
switch. These are the failure modes where the app still *looks* fine:
1. An empty password must not reach bind(). In LDAP a simple bind with an empty
password is an ANONYMOUS bind and it SUCCEEDS — so without the guard, a blank
password authenticates as whatever username was submitted.
2. TLS must be CERT_REQUIRED with an explicit CA file. CERT_NONE still encrypts,
so it fails silently; what it loses is the ability to tell a real DC from
someone harvesting domain passwords.
3. Group membership must be evaluated through AD's nested-group matching rule.
Plain memberOf is direct membership only and wrongly refuses real people.
4. The fake directory must be impossible to select against a real database.
5. A refused sign-in must create no account.
6. An existing admin must still be an admin afterwards.
Self-contained: throwaway SQLite + its own uvicorn. No browser, no domain — the
fake directory (server/ldap_fake.py) stands in for the DC.
Exit 0 all passed, 1 a failure, 2 could not run.
"""
import json
import os
import ssl
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cdp import free_port # noqa: E402
_PASS, _FAIL = [], []
PW = "CorrectHorseBattery9"
GROUP = "WP-Suite-Users"
SECRET = "ldap-auth-check-not-for-production"
os.environ.setdefault("AUTH_SECRET_KEY", SECRET)
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def chk(label, ok, detail=""):
(_PASS if ok else _FAIL).append(label)
print(f" {'PASS' if ok else 'FAIL'} {label}" + ("" if ok else f" {detail}"))
def post(base, path, payload):
req = urllib.request.Request(base + path, method="POST",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=10) as r:
return r.status, json.loads(r.read().decode() or "{}")
except urllib.error.HTTPError as e:
try:
return e.code, json.loads(e.read().decode() or "{}")
except Exception:
return e.code, {}
def get(base, path, token):
req = urllib.request.Request(base + path, headers={"Cookie": f"wp_session={token}"})
try:
with urllib.request.urlopen(req, timeout=10) as r:
return r.status, json.loads(r.read().decode() or "{}")
except urllib.error.HTTPError as e:
return e.code, {}
def post_as(base, path, token, payload):
req = urllib.request.Request(base + path, method="POST",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json",
"Cookie": f"wp_session={token}"})
try:
with urllib.request.urlopen(req, timeout=10) as r:
return r.status, json.loads(r.read().decode() or "{}")
except urllib.error.HTTPError as e:
return e.code, {}
def start(port, db_path, fake, required_group=""):
env = dict(os.environ)
env["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/")
env["AUTH_SECRET_KEY"] = SECRET
env["WP_LDAP_FAKE_DIRECTORY"] = json.dumps(fake)
# SET it empty, never pop it. server/db.py calls load_dotenv() at import, and
# python-dotenv only skips a key that is already present in os.environ — so a
# popped variable is helpfully restored from the developer's .env inside the
# subprocess, and the test silently runs against the real required group.
# An empty string counts as present, so it wins.
env["LDAP_REQUIRED_GROUP"] = required_group or ""
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "server.app:app", "--host", "127.0.0.1",
"--port", str(port), "--log-level", "warning"],
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=ROOT)
for _ in range(160):
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout=1):
return proc
except Exception:
if proc.poll() is not None:
return None
time.sleep(0.25)
proc.kill()
return None
def users_in(db_path):
"""Read the users table straight out of the given file.
Deliberately NOT via server.db.SessionLocal: that engine is bound from
DATABASE_URL when the module is first imported, so setting the env var later
keeps reading whichever database was configured first. Two assertions in this
file passed against the wrong file before that was noticed.
"""
import sqlite3
con = sqlite3.connect(db_path)
try:
try:
return {r[0]: r[1] for r in con.execute("select username, role from users")}
except sqlite3.OperationalError:
return {} # no users table yet
finally:
con.close()
def main():
print("1. the guards that do not need a server")
os.environ["DATABASE_URL"] = "sqlite:///./_ldapcheck_unit.db"
os.environ["WP_LDAP_FAKE_DIRECTORY"] = json.dumps(
{"root": {"password": PW, "groups": [GROUP]}})
from server import ldap_auth
# 1 — the anonymous-bind guard. Connection is nulled so ANY call to bind()
# would raise: this proves the guard returns before the transport is touched,
# rather than merely that the result is a failure.
saved, ldap_auth.Connection = ldap_auth.Connection, None
try:
for label, u, p in [("an empty password", "root", ""),
("a whitespace-only password", "root", " "),
("an empty username", "", PW)]:
r = ldap_auth.verify(u, p, required_group="")
chk(f"{label} is refused without reaching bind()",
(not r.ok) and r.reason == ldap_auth.EMPTY_INPUT, r.reason)
finally:
ldap_auth.Connection = saved
# 2 — TLS configuration.
tls = ldap_auth._tls()
chk("TLS validate is CERT_REQUIRED", tls.validate == ssl.CERT_REQUIRED, tls.validate)
chk("...with an explicit CA file, not the system store",
bool(tls.ca_certs_file) and os.path.isfile(tls.ca_certs_file), tls.ca_certs_file)
src = open(os.path.join(ROOT, "server", "ldap_auth.py"), encoding="utf-8").read()
# Parse the module rather than grep it: the docstring names validate=ssl.CERT_NONE
# in order to explain why it must never be used, and a text search cannot tell
# that apart from an actual call. Walk every keyword argument called `validate`
# and check what it is really set to.
import ast as _ast
bad = []
for node in _ast.walk(_ast.parse(src)):
if isinstance(node, _ast.Call):
for kw in node.keywords:
if kw.arg == "validate":
name = (kw.value.attr if isinstance(kw.value, _ast.Attribute)
else getattr(kw.value, "id", ""))
if name != "CERT_REQUIRED":
bad.append(f"line {node.lineno}: validate={name or '?'}")
chk("every validate= in the module is CERT_REQUIRED (AST, not grep)",
not bad, "; ".join(bad))
# 3 — nested groups. The fake cannot model AD nesting (it is a string list), so
# what is asserted is that the REAL path builds AD's transitive matching rule
# into its filter. A test that truly exercises nesting needs a real directory.
chk("membership uses AD's nested matching rule, not plain memberOf",
ldap_auth.NESTED_MEMBER_RULE == "1.2.840.113556.1.4.1941"
and "memberOf:{NESTED_MEMBER_RULE}:=" in src,
"the transitive matching rule is not in the search filter")
# 4 — the production guard on the fake, in a subprocess because DATABASE_URL is
# read at import time.
out = subprocess.run(
[sys.executable, "-c",
"from server import ldap_fake; print(ldap_fake.is_active())"],
cwd=ROOT, capture_output=True, text=True,
env={**os.environ,
"DATABASE_URL": "postgresql+psycopg://u:p@localhost:5432/db",
"AUTH_SECRET_KEY": "x",
"WP_LDAP_FAKE_DIRECTORY": json.dumps({"root": {"password": PW}})})
chk("the fake directory REFUSES to work against a non-SQLite database",
out.stdout.strip() == "False", out.stdout.strip() or out.stderr[-200:])
print("\n2. sign-in, against a server")
db_fd, db_path = tempfile.mkstemp(suffix=".db"); os.close(db_fd)
port = free_port()
fake = {"root": {"password": PW, "mail": "root@example.test",
"full_name": "Root Person", "groups": [GROUP]},
"outsider": {"password": PW, "mail": "outsider@example.test",
"full_name": "Out Sider", "groups": ["SomeOtherGroup"]}}
server = start(port, db_path, fake, required_group=GROUP)
if server is None:
print("the test server would not start.")
return 2
base = f"http://127.0.0.1:{port}"
try:
st, _ = post(base, "/api/auth/login", {"username": "root", "password": PW})
chk("a correct password signs in", st == 200, st)
chk("...and provisioned the account at project_user",
users_in(db_path).get("root") == "project_user", users_in(db_path))
st, body = post(base, "/api/auth/login", {"username": "root", "password": "wrong-" + PW})
chk("a wrong password is refused", st == 401, st)
chk("...with a message that does not say why",
"password" in (body.get("detail") or "").lower()
and "expired" not in (body.get("detail") or "").lower(), body)
st, _ = post(base, "/api/auth/login", {"username": "root", "password": ""})
chk("a blank password is refused at the endpoint too", st == 401, st)
before = set(users_in(db_path))
st, _ = post(base, "/api/auth/login", {"username": "outsider", "password": PW})
chk("a correct password OUTSIDE the required group is refused", st == 401, st)
chk("...and no account was created for them",
set(users_in(db_path)) == before, set(users_in(db_path)) - before)
st, _ = post(base, "/api/auth/login", {"username": "nobody", "password": PW})
chk("an unknown account is refused and creates nothing",
st == 401 and "nobody" not in users_in(db_path), st)
finally:
server.kill()
try:
server.wait(timeout=10)
except subprocess.TimeoutExpired:
pass
print("")
print("4. local state overrides the directory, and the throttle protects it")
db_fd3, db3 = tempfile.mkstemp(suffix=".db"); os.close(db_fd3)
import sqlalchemy as _sa0
from server.db import Base as _B0
from server import models as _m0 # noqa: F401
eng0 = _sa0.create_engine("sqlite:///" + db3.replace("\\", "/"))
_B0.metadata.create_all(bind=eng0)
with eng0.begin() as con:
con.execute(_sa0.text(
"insert into users (id,username,email,full_name,role,is_active,"
"failed_attempts,token_version,project_role,locale,timezone,"
"auto_add_projects,auto_add_role,created_at,updated_at) values "
"('user_root','root','','Root','project_user',0,0,0,'','','',0,'',"
"datetime('now'),datetime('now'))")) # is_active = 0
eng0.dispose()
port3 = free_port()
server3 = start(port3, db3, fake, required_group=GROUP)
if server3 is None:
print("the third test server would not start.")
return 2
b3 = f"http://127.0.0.1:{port3}"
try:
st, _ = post(b3, "/api/auth/login", {"username": "root", "password": PW})
chk("a disabled local account is refused even though the bind succeeds",
st == 403, st)
# The throttle. AUTH_MAX_ATTEMPTS is 2, and its whole purpose is that failures
# are real domain binds counting against the AD lockout policy — so it has to
# stop CALLING the directory, not merely refuse. Proving that: burn the budget
# with wrong passwords, then present the CORRECT one. A 429 for a credential
# that would otherwise succeed is only possible if the throttle runs before the
# directory is consulted.
codes = [post(b3, "/api/auth/login",
{"username": "outsider", "password": "wrong"})[0] for _ in range(3)]
chk("the attempt budget is spent and the next try is throttled",
codes[-1] == 429, codes)
st, _ = post(b3, "/api/auth/login", {"username": "outsider", "password": PW})
chk("...and a CORRECT password is still refused while throttled, proving the "
"directory is never reached", st == 429, st)
chk("...while another account is unaffected (the budget is per-username)",
post(b3, "/api/auth/login", {"username": "root", "password": PW})[0] == 403, "")
finally:
server3.kill()
try:
server3.wait(timeout=10)
except subprocess.TimeoutExpired:
pass
print("\n3. an existing admin survives the switch")
db_fd2, db2 = tempfile.mkstemp(suffix=".db"); os.close(db_fd2)
# Build the schema with a NEW engine bound to this file — see users_in().
import sqlalchemy as _sa
from server.db import Base
from server import models # noqa: F401
eng2 = _sa.create_engine("sqlite:///" + db2.replace("\\", "/"))
Base.metadata.create_all(bind=eng2)
with eng2.begin() as con:
con.execute(_sa.text(
"insert into users (id,username,email,full_name,role,is_active,"
"failed_attempts,token_version,project_role,locale,timezone,"
"auto_add_projects,auto_add_role,created_at,updated_at) values "
"('user_root','root','','Set By Hand','admin',1,0,0,'','','',0,'',"
"datetime('now'),datetime('now'))"))
eng2.dispose()
port2 = free_port()
server2 = start(port2, db2, fake, required_group=GROUP)
if server2 is None:
print("the second test server would not start.")
return 2
try:
st, body = post(f"http://127.0.0.1:{port2}", "/api/auth/login",
{"username": "root", "password": PW})
chk("the pre-existing admin signs in", st == 200, st)
chk("...and is STILL an admin (D13 criterion 4)",
users_in(db2).get("root") == "admin", users_in(db2))
chk("...and their locally-set name was not overwritten by the directory",
(body.get("user") or {}).get("full_name") == "Set By Hand", body.get("user"))
chk("...and no duplicate account appeared",
len(users_in(db2)) == 1, users_in(db2))
finally:
server2.kill()
try:
server2.wait(timeout=10)
except subprocess.TimeoutExpired:
pass
print("")
print("5. the console's own routes, and what token_version actually does")
db_fd4, db4 = tempfile.mkstemp(suffix=".db"); os.close(db_fd4)
import sqlalchemy as _sa1
from server.db import Base as _B1
from server import models as _m1, auth as _auth # noqa: F401
url4 = "sqlite:///" + db4.replace("\\", "/")
eng1 = _sa1.create_engine(url4)
_B1.metadata.create_all(bind=eng1)
with eng1.begin() as con:
for uid, un, role in [("user_boss", "root", "admin"),
("user_pat", "pat", "project_user")]:
con.execute(_sa1.text(
"insert into users (id,username,email,full_name,role,is_active,"
"failed_attempts,token_version,project_role,locale,timezone,"
"auto_add_projects,auto_add_role,created_at,updated_at) values "
f"('{uid}','{un}','','{un}','{role}',1,0,0,'','','',0,'',"
"datetime('now'),datetime('now'))"))
class _U:
pass
def _tok(uid, un, role):
u = _U(); u.id = uid; u.username = un; u.role = role; u.token_version = 0
return _auth.create_token(u)
boss_tok = _tok("user_boss", "root", "admin")
pat_tok = _tok("user_pat", "pat", "project_user")
port4 = free_port()
server4 = start(port4, db4, fake, required_group="")
if server4 is None:
print("the fourth test server would not start.")
return 2
b4 = f"http://127.0.0.1:{port4}"
try:
st, body = get(b4, "/api/auth/users", boss_tok)
chk("an admin can list accounts", st == 200, st)
post(b4, "/api/auth/login", {"username": "outsider", "password": PW})
st, body = get(b4, "/api/auth/users", boss_tok)
rows = body if isinstance(body, list) else (body.get("users") or body.get("items") or [])
names = [u.get("username") for u in rows if isinstance(u, dict)]
chk("a just-provisioned account appears in the Admin console list",
"outsider" in names, names)
# Exactly the request users.html sends — D13 criterion 4.
st, _ = post_as(b4, "/api/auth/users/user_pat/role", boss_tok, {"role": "admin"})
chk("granting admin through the console route succeeds", st == 200, st)
chk("...and the role really changed", users_in(db4).get("pat") == "admin", users_in(db4))
# token_version. NOTHING in app.py bumps it any more: is_active and role are
# re-read from the database every request, so both take effect at once without
# it. What it still does is invalidate an ALREADY-ISSUED cookie, which is what
# manage_users does on disable. Bump it directly and prove the effect.
eng2 = _sa1.create_engine(url4)
with eng2.begin() as con:
con.execute(_sa1.text("update users set token_version = 1 where id='user_pat'"))
eng2.dispose()
st, _ = get(b4, "/api/auth/me", pat_tok)
chk("bumping token_version invalidates a cookie already issued", st == 401, st)
st, _ = get(b4, "/api/auth/me", boss_tok)
chk("...and leaves every other session alone", st == 200, st)
st, body = post(b4, "/api/auth/login", {"username": "root", "password": "wrong"})
blob = json.dumps(body).lower()
chk("no AD sub-code leaks into a response body",
not any(c in blob for c in ("52e", "525", "532", "533", "775", "data ")), body)
from server import ldap_auth as _la
chk("...though _err49 does parse one when AD sends it",
"52e" in _la._err49({"message": "80090308: LdapErr: DSID-0C0903A9, comment: "
"AcceptSecurityContext error, data 52e, v4563"}))
finally:
eng1.dispose()
server4.kill()
try:
server4.wait(timeout=10)
except subprocess.TimeoutExpired:
pass
print("\n" + "-" * 54)
print(f"{len(_PASS)}/{len(_PASS) + len(_FAIL)} checks passed.")
for f in _FAIL:
print(" - " + f)
return 1 if _FAIL else 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as exc: # noqa: BLE001
print(f"could not run: {type(exc).__name__}: {exc}")
sys.exit(2)

View File

@@ -54,6 +54,20 @@ def main():
chk("locations and materials are both instances of it - not a copy beside it",
"locList = WPListImport(" in suite and "matList = WPListImport(" in suite
and "function locImport(dryRun){ locList.importText" in suite)
# The 2026-08-21 outage, pinned: a Boolean server_default of sa.text('1')
# passes on SQLite (which coerces 1) and crash-loops Postgres at deploy
# (DatatypeMismatch). Every migration must say sa.true()/sa.false().
import re as _re
bad = []
vdir = os.path.join(ROOT, "server", "alembic", "versions")
for fn in sorted(os.listdir(vdir)):
if not fn.endswith(".py"):
continue
for ln in open(os.path.join(vdir, fn), encoding="utf-8"):
if "Boolean" in ln and "server_default" in ln and not _re.search(r"server_default=sa\.(true|false)\(\)", ln):
bad.append("%s: %s" % (fn, ln.strip()[:90]))
chk("no migration gives a Boolean a non-portable server_default "
"(sa.true()/sa.false() only)", not bad, ascii_(bad[:3]))
model = open(os.path.join(ROOT, "server", "models.py"), encoding="utf-8").read()
mat_block = model[model.find("class MaterialItem"):model.find("class WpFile")]
cols = re.findall(r"^\s+(\w+): Mapped", mat_block, re.M)

View File

@@ -187,13 +187,9 @@ def main():
break
time.sleep(0.3)
settle(page, 1.2)
# NOT `"wp-creation-index.html" in location.href` — that string is in the
# ?next= parameter too, so the check passed while still sitting on
# login.html with the sign-in rejected. Assert we actually LEFT the
# login page (D13/T10.7: it caught nothing when the bind started failing).
href = page.eval("location.href")
chk("signing in continues to the requested page, not the home page",
"login.html" not in href and "wp-creation-index.html" in href, href)
"wp-creation-index.html" in page.eval("location.href"),
page.eval("location.href"))
for _ in range(30):
if page.eval("!!window.wpCreatorReady"):
break