Found while diagnosing a login failure that turned out to be an account
lockout. The local wpsuite.db has no alembic_version table - it was built by
create_all() before D13 - so users.password_hash is still NOT NULL with no
default while the current model has no such column. Verified on a COPY of the
database rather than reasoned about: provisioning a new account raises
IntegrityError: NOT NULL constraint failed: users.password_hash
The shape of it is what makes it worth documenting. Accounts already in the file
keep working, so the developer can sign in and nothing looks wrong; it breaks
only when a NEW person signs in, and it surfaces as HTTP 500, which reads as a
server fault rather than a schema one. Nothing anywhere told a developer their
existing database needed migrating.
The note gives the non-destructive fix - stamp a1b8c6d4e2f9 then upgrade head,
which runs only the drop and keeps the data - and says why a plain
`alembic upgrade head` would fail on such a file. Earlier in the session I
suggested deleting the database; stamping is strictly better, since deleting
discards test data for no benefit.
Production is unaffected: Postgres, migrations applied at container start.
Also updated the nested-group done-when to reflect what is now actually known.
The transitive matching rule WAS exercised against the live directory today for
a direct member of a 2,003-member group and returned a match, so the rule and
the filter are right on this estate. A genuinely nested case remains untested
for want of such an account, and the box is marked partial rather than done.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
14 KiB
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
A SQLite database created before D13 will reject new sign-ins
Base.metadata.create_all()creates missing tables; it never alters existing ones. So awpsuite.dbbuilt before D13 still hasusers.password_hashdeclaredNOT NULLwith 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_hashAccounts 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_versiontable, soalembic upgrade headwould try to replay the baseline against tables that already exist. Stamp it first: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 dropThat 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.
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