Far smaller than estimated, because the premise was wrong. I had said four checks sign in and would each need a fake directory. They do not: seed() mints a session token with auth.create_token() and sets the cookie directly - browser_check's own docstring says so - and the only breakage was a leftover password_hash= kwarg on a model that no longer has the column. Deleting that one line in browser_check.seed() unblocked 39 files that import seed/start_server from it. launcher_check needed the same. console_dialogs_check's password-reset half is deleted rather than ported. Its docstring now records what went and where the prompt kit is still covered (wpPromptDialog has five callers left in wp-creation-app.js; creator_dialogs_check exercises them, validation included - verified, 20/20). Nothing was left skipped in place of the removed section. Exactly one check genuinely needed a seam: url_state_check drives the real login form to prove a deep link's ?next= survives authentication. That cannot be faked by minting a cookie, because the login round trip is the thing under test. The seam is env-driven because it has to be: start_server launches the app as a SUBPROCESS, so a monkeypatch in the test process would never reach the code doing the authenticating. server/ldap_fake.py reads WP_LDAP_FAKE_DIRECTORY and ldap_auth dispatches to it AFTER the empty-input guard, so the anonymous-bind guard covers the fake path too - a fake that reimplemented it would let the real one rot unnoticed. The production guard is the point of that module. An env var that makes any password work is exactly the kind of thing that escapes into production, and D13 left no other way in. is_active() refuses whenever a non-SQLite DATABASE_URL is configured - the same test auth._load_secret uses - and describe() shouts in capitals so a fake run can never be mistaken for a real one in the startup log. Two things found on the way, neither of them the app's fault: - url_state_check's "signing in continues to the requested page" asserted `"wp-creation-index.html" in location.href`. That string is in the ?next= parameter too, so it passed while sitting on login.html with the sign-in rejected. It would have passed with login entirely broken. Tightened to assert we actually left the login page. - Two assertions in my own new ldap_auth_check read the WRONG database: server/db.py binds its engine from DATABASE_URL at import, so setting the env var afterwards keeps reading whichever file was configured first. users_in() now opens the file it is asked about with sqlite3. The CERT_NONE check also had to become an AST walk - the module docstring names validate=ssl.CERT_NONE in order to explain why it is banned, and a text search cannot tell that apart from a real call. tests/ldap_auth_check.py is new coverage rather than repair: the anonymous-bind guard, CERT_REQUIRED by AST, the nested matching rule in the filter, the production refusal, a refused sign-in creating no account, and an existing admin still being an admin with their locally-set name intact. 20/20. Run so far, all green: browser_check 71/71, launcher_check 58/58, console_dialogs 12/12, url_state 23/23, qa_gate 41/41, critical_reopen 11/11, creator_dialogs 20/20, a11y 22/22, kitting_notify 17/17, ldap_auth 20/20. A full sweep of the remaining ~30 is running; its box stays unticked until it reports. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Work Package Suite API
A small Python (FastAPI) service that stores project SOPs, Work Packages,
and comments in PostgreSQL. NGINX serves the static site and proxies /api/
to this service.
browser → NGINX ──serves──> static site (index.html, …)
└─proxy /api/─> api container (:8000) → db container (postgres)
Endpoints
| 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/logout |
clear the session cookie |
| GET | /api/auth/me |
the logged-in user |
| 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) |
| 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 |
| GET | /api/sops/latest?complete=true |
most recent (complete) SOP |
| GET | /api/sops/{id} |
full SOP document |
| DELETE | /api/sops/{id} |
delete a SOP |
| POST | /api/wps |
create/update a Work Package (upsert by id) |
| GET | /api/wps?sop_id=… |
list WPs (optionally for one SOP) |
| GET | /api/wps/{id} |
full WP document |
| DELETE | /api/wps/{id} |
delete a WP |
| POST | /api/comments (and /api/feedback) |
add a comment |
| GET | /api/comments?source=&sop_id=&wp_id=&step= |
list comments |
Interactive docs once running: /api/docs.
The full client document is stored in each row's data (JSON) column; common
fields (name, number, status, …) are promoted to columns for listing/filtering.
Sign-in (domain authentication, D13)
The suite stores no passwords. Signing in performs an LDAPS simple bind to
ldaps://prime.local:636 as <sAMAccountName>@prime.local using the password the
person typed — their Windows password. A successful bind is the authentication.
See server/ldap_auth.py; the schema has no password_hash column.
Sign-in issues a signed JWT
that rides in an HttpOnly, SameSite=Lax cookie (wp_session); the cookie is
marked Secure automatically whenever the request arrives over HTTPS (via
NGINX's X-Forwarded-Proto). There is no server-side session store — each
request is validated by checking the cookie's signature and expiry.
The real security boundary is the API: every /api/ data route is refused
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):
docker compose exec api openssl s_client -connect prime.local:636 -CAfile /app/server/certs/prime-ca-chain.pem </dev/null 2>&1 | grep "Verify return"
# want: Verify return code: 0 (ok)
Set the signing secret
Add AUTH_SECRET_KEY to .env (see .env.example). Required in production —
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
Two steps, in this order. There is no create-admin any more — there is no password
to set and no account to create.
# 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
It prompts for your domain username and password, binds to confirm who you are,
and prints alice: project_user -> admin.
Other commands: list, promote <user> [--role …], demote <user>,
disable <user>, enable <user>. After that, admins manage accounts from the Admin
console.
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.
Local dev
cd server
python -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
# No DATABASE_URL → uses a local sqlite file, so you can start immediately:
uvicorn server.app:app --reload --port 8000 # run from the PROJECT ROOT
Then open http://localhost:8000/api/docs.
Run uvicorn/gunicorn from the project root (the folder that contains the
server/directory), because the import path isserver.app:app.
Production — Docker Compose
This is the recommended production setup. Three containers run in an isolated
internal network; only NGINX is exposed to the outside via the external proxy
network.
[external proxy network]
│
┌────▼────┐ internal network ┌──────────┐ ┌────────┐
│ nginx │ ───────────────────> │ api │ → │ db │
└─────────┘ └──────────┘ └────────┘
1. Create the credentials file
Create .env in the project root (same directory as docker-compose.yml).
This file is never committed — add it to .gitignore.
# .env — project root
POSTGRES_DB=wpsuite
POSTGRES_USER=wpsuite
POSTGRES_PASSWORD=<strong-random-password>
# Must match POSTGRES_* above; hostname is the compose service name "db"
DATABASE_URL=postgresql+psycopg://wpsuite:<strong-random-password>@db:5432/wpsuite
Generate a strong password:
openssl rand -base64 32
2. Add the Dockerfile
Create Dockerfile in the project root:
FROM python:3.12-slim
WORKDIR /app
COPY server/requirements.txt ./server/
RUN pip install --no-cache-dir -r server/requirements.txt
COPY server/ ./server/
EXPOSE 8000
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", \
"-b", "0.0.0.0:8000", "--workers", "2", "server.app:app"]
3. Update the NGINX site config
The API is no longer at 127.0.0.1:8000 — it is the api container.
Update the /api/ proxy block in your nginx conf (e.g. nginx/conf.d/wp-suite.conf):
location /api/ {
proxy_pass http://api:8000; # ← service name, not localhost
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 5m;
}
4. docker-compose.yml
Replace your existing docker-compose.yml with:
services:
webserver:
image: nginx:alpine
container_name: nginx_webserver
volumes:
- ./html:/usr/share/nginx/html:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./logs:/var/log/nginx
restart: unless-stopped
depends_on:
api:
condition: service_started
networks:
- proxy # external — reachable by your reverse proxy / traefik
- internal # needs a path to the api container
api:
build: .
container_name: wp_api
env_file: .env # loads DATABASE_URL
restart: unless-stopped
depends_on:
db:
condition: service_healthy # waits for postgres to accept connections
networks:
- internal
db:
image: postgres:16-alpine
container_name: wp_db
env_file: .env # loads POSTGRES_DB / USER / PASSWORD
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
volumes:
pgdata:
networks:
proxy:
name: proxy
external: true
internal:
internal: true # no outbound internet access from api/db
5. First-time startup
# Build the api image and start all containers
docker compose up -d --build
# Confirm all three containers are running
docker compose ps
# Tail logs (Ctrl-C to stop following)
docker compose logs -f api
Tables are created automatically on first API startup — no manual CREATE TABLE
needed.
Authentication notes
Postgres → API authentication is handled entirely through DATABASE_URL in
.env. The db container uses POSTGRES_USER / POSTGRES_PASSWORD to
initialise the database on first run; the api container uses the matching
credentials in DATABASE_URL to connect. Neither credential ever appears in the
compose file itself.
Network isolation: the db container is on the internal network only —
it has no port exposed to the host and is unreachable from outside the compose
stack. Only the api container can open a connection to it.
Changing the password: update both POSTGRES_PASSWORD and the password
in DATABASE_URL in .env, then:
# Stop api first (db must keep running to accept the ALTER USER command)
docker compose stop api
docker compose exec db psql -U wpsuite -c "ALTER USER wpsuite PASSWORD 'new-password';"
docker compose start api
Day-to-day operations
# Rebuild api after a code change
docker compose up -d --build api
# View postgres data directly
docker compose exec db psql -U wpsuite -d wpsuite
# Take a database backup
docker compose exec db pg_dump -U wpsuite wpsuite > backup-$(date +%F).sql
# Restore from backup
docker compose exec -T db psql -U wpsuite -d wpsuite < backup-2025-01-01.sql
# Stop everything (data volume is preserved)
docker compose down
# Stop everything AND delete all data
docker compose down -v
Quick test
/api/health is open; data routes now require a session, so log in first and
reuse the cookie jar:
curl http://127.0.0.1:8000/api/health # {"ok":true} — no auth needed
# Sign in, saving the session cookie to a jar
curl -c jar.txt -X POST http://127.0.0.1:8000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"alice","password":"<password>"}'
# Reuse the cookie on protected routes
curl -b jar.txt http://127.0.0.1:8000/api/comments
Without the cookie, protected routes return 401 {"detail":"Not authenticated"}.
Or via the nginx proxy (replace with your hostname):
curl https://wp-suite.company.local/api/health