# 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) | | GET | `/api/auth/okta/login` | redirects the browser to Okta's authorize endpoint (`?next=` optional) | | GET | `/api/auth/okta/callback` | Okta redirects back here with the auth code; signs the person in | | 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 by username (**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 (Okta) There is no local password anywhere in this app (D15/D16) — Okta OIDC is the only way in. `login.html` is a single "Sign in with Okta" button; the actual exchange is `server/okta_auth.py` (the Okta client config) and the two routes in `server/app.py`: `okta_login()` sends the browser to Okta's authorize endpoint, `okta_callback()` exchanges the code, matches the ID token's identity claim against `users.username`, and signs the person in. Sign-in still ends the same way it always did: a signed JWT in an **HttpOnly, SameSite=Lax** cookie (`wp_session`), 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. Okta only confirms *who* someone is; this app still decides *what* they may do — roles, project membership, everything below stays local and unchanged by Okta. **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. **Access gating is Okta's job, not this app's.** Only accounts assigned to the app integration in Okta can complete the sign-in flow at all, so there is no required-group or claim check layered on top here. Once Okta lets someone through, this app decides their role — see below. Roles are `admin`, `project_super_user`, `project_admin`, `project_user` (`html/users.js`, enforced server-side). ### Set the signing secret and the Okta app integration Add `AUTH_SECRET_KEY` and the five `OKTA_*` variables to `.env` — see `.env.example` for what each one is and where it comes from. `AUTH_SECRET_KEY` is **required in production**: without it the API uses a random per-process key, so logins reset on restart. The `OKTA_*` variables are not a hard-fail the same way — the API starts without them, it just refuses every sign-in and says so in the startup log (`okta_auth.describe()`). ```bash python -c "import secrets; print(secrets.token_urlsafe(48))" ``` The Okta app integration itself (sign-in method OIDC, Application type Web Application) needs its **Sign-in redirect URI** set to exactly `OKTA_REDIRECT_URI`'s value, and the people who should have access assigned to it — that assignment IS the access control (see above). ### Create the first admin There's no `create-admin` command anymore — creating an account from scratch by hand-typed username risks a second, orphaned row if it doesn't exactly match what Okta actually sends (see `OKTA_IDENTITY_CLAIM` in `.env.example`). Instead, have the first admin **sign in through Okta once** — they land as an ordinary `project_user`, JIT-provisioned — then promote that existing row from a shell (run from the **project root**, like uvicorn): ```bash python -m server.manage_users promote alice --role admin ``` In Docker: ```bash docker compose exec api python -m server.manage_users promote alice --role admin ``` Other commands: `list`, `disable `, `enable `. After the first admin exists, they can promote others through the User Directory page (or keep using the CLI) — no shell access needed for anyone after the first. **No break-glass path.** If Okta is unreachable or misconfigured, the app is unreachable for everyone, including admins, until Okta is restored (D16) — this is a deliberate choice, the same one the abandoned LDAPS design made, not an oversight. --- ## 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; every other `/api/` route needs a session cookie: ```bash curl http://127.0.0.1:8000/api/health # {"ok":true} — no auth needed ``` Without a session 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 ``` There's no `curl`-able login anymore — Okta requires a real browser to complete, which is what `login.html`'s "Sign in with Okta" button is for. To exercise a protected route from a script instead, use `server/smoketest.py`'s own technique (mint a session with `auth.create_token()` and set it as the `wp_session` cookie, the same thing `okta_callback()` does after Okta hands back an identity) rather than reaching for curl by hand — see that script's own AUTHENTICATION section for the exact steps, and why it has to run somewhere that shares the target server's `AUTH_SECRET_KEY` and database.