DEPLOY-login-portal.md was the most wrong and is rewritten. It described taking a username/password portal live - bcrypt, and a first admin created with `create-admin --password`. Every command in it now fails. It keeps its filename and carries a note saying what it replaced, because an admin holding the old copy needs to know why the steps stopped working rather than concluding the deploy is broken. New content leads with the warning that there is no break-glass, and puts verification BEFORE announcing the deploy - the log line, the certificate check that binds nothing, then a real sign-in. DEPLOYMENT.md: AUTH_RESET_* replaced with the LDAP variables; the users table row no longer claims a password_hash column; "Self-service password reset" replaced by a section saying there isn't one and pointing at Okta. New "Domain authentication" section covering the three things that are not obvious - why prime.local and never a DC or an IP, why the CA bundle is not a certificate issued to this app (with the thumbprints and a Get-ChildItem line to rebuild it), and why the outbound network stopped being optional - plus the lockout arithmetic written out so the next person to raise AUTH_MAX_ATTEMPTS sees the constraint rather than a magic 2. server/README.md: endpoint table drops /api/auth/password and gains the role route; the login-portal section becomes domain authentication; create-admin becomes the two-step bootstrap (sign in, then promote). CLAUDE.md: a new "authentication rules" section beside the token rule, for the same reason that one exists - four things that look like tidying-up if you do not know why. The empty-password guard that must run before bind(), CERT_REQUIRED with an explicit CA file, AUTH_MAX_ATTEMPTS being arithmetic rather than taste, and connecting to the domain name rather than a DC. Plus: no break-glass, and roles are local - never read a role from AD. Closed three done-when boxes that were open rather than ticked: T10.8 all of them T10.9 promote/demote verified against a real bind (Aug 24), not a stub T10.3 the Postgres round trip, on postgres:16-alpine Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
365 lines
13 KiB
Markdown
365 lines
13 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
|
|
|
|
```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
|
|
```
|