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>
396 lines
14 KiB
Markdown
396 lines
14 KiB
Markdown
# 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):
|
|
|
|
```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)
|
|
```
|
|
|
|
### 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.
|
|
|
|
```bash
|
|
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.
|
|
|
|
```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
|
|
```
|
|
|
|
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 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
|
|
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 is `server.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`.
|
|
|
|
```bash
|
|
# .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:
|
|
```bash
|
|
openssl rand -base64 32
|
|
```
|
|
|
|
### 2. Add the Dockerfile
|
|
|
|
Create `Dockerfile` in the **project root**:
|
|
|
|
```dockerfile
|
|
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`):
|
|
|
|
```nginx
|
|
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:
|
|
|
|
```yaml
|
|
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
|
|
|
|
```bash
|
|
# 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:
|
|
```bash
|
|
# 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
|
|
|
|
```bash
|
|
# 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:
|
|
|
|
```bash
|
|
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):
|
|
|
|
```bash
|
|
curl https://wp-suite.company.local/api/health
|
|
```
|