# 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 `@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 &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 [--role …]`, `demote `, `disable `, `enable `. 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 ```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= # Must match POSTGRES_* above; hostname is the compose service name "db" DATABASE_URL=postgresql+psycopg://wpsuite:@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":""}' # 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 ```