D13 never shipped, so DEPLOYMENT.md, server/.env.example and server/README.md
still described the original local-password system as of this task starting -
POST /api/auth/login, bcrypt password_hash, create-admin with a prompted
password, self-service reset-password email flow, AUTH_RESET_MINUTES /
AUTH_RESET_COOLDOWN_SECONDS. All of that is gone as of T10.4; these three files
now describe what actually runs.
server/.env.example and DEPLOYMENT.md's env block both gain the five OKTA_*
variables (ISSUER, CLIENT_ID, CLIENT_SECRET, REDIRECT_URI, IDENTITY_CLAIM),
explained the same way AUTH_SECRET_KEY already was - what it does, where to
get it, what happens if it's missing.
Also updated, not originally named in T10.6's bullet but required for the
documented vars to actually reach a running container: docker-compose.yml's
api service sets environment: as an explicit allowlist, not env_file, so the
four new OKTA_* entries had to be added there too or .env would document
something that silently does nothing. OKTA_IDENTITY_CLAIM specifically is NOT
${OKTA_IDENTITY_CLAIM:-} - compose setting an env var to an empty string is
not the same as leaving it unset, and server/okta_auth.py's own default
(preferred_username) only kicks in when the var is truly unset. Mirrored the
same default in the compose file instead, or every deployment that leaves the
optional line commented out in .env would 503 on every sign-in looking for a
claim literally named "".
server/README.md: replaced the login-portal section with the Okta flow
(access gating is Okta's job, not this app's - roles/authorization stay
local), replaced "create the first admin" with the promote-not-create
bootstrap path (D16) and its no-break-glass posture, replaced the curl-based
login example in Quick Test with a pointer to smoketest.py's own
session-minting technique (there is nothing left to curl - Okta requires a
real browser).
DEPLOYMENT.md: same treatment for its own copies of the env block, the
Portainer var list, the users table's password_hash column, the auth
endpoints summary, the smoke-test walkthrough (WP_SMOKE_USER only, must run
inside the api container or local dev sharing AUTH_SECRET_KEY/DATABASE_URL -
no longer targetable from an arbitrary remote workstation), the entire
"Self-service password reset" section (replaced with "Sign-in and admin
bootstrap (Okta)"), and the project_super_user role description / exclusive-
scope bullet, both of which named "reset passwords" as something that no
longer exists.
Left alone, logged rather than fixed here per CLAUDE.md scope discipline:
- users.failed_attempts / locked_until columns are still in the schema and
still reset to 0/None on every Okta sign-in, but nothing increments them
anymore since local login() is gone - vestigial, not documented as active
lockout behavior in either doc now, but not migrated away either.
- server/README.md's "Production - Docker Compose" section (### 1-5) is a
self-contained alternate quickstart that already duplicated and diverged
from the real root docker-compose.yml before this task; it uses env_file
rather than an explicit allowlist so it isn't broken by this change, but
it's still a second source of truth nobody asked this task to reconcile.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
347 lines
12 KiB
Markdown
347 lines
12 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) |
|
|
| 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 <user>`, `enable <user>`. 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=<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; 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.
|