Files
Project-SDE-WP-Suite/server
Cody Schaefer 332b74e5de T10.2 - actually implement the startup line the docs send people to
A gap, not a refinement. T10.2's task text said the login change "must add
visibility: a startup log line stating whether LDAP is configured", and three
documents tell an operator to run

    docker compose logs api | grep -i "LDAP auth"

as the FIRST diagnostic when nobody can sign in. Nothing ever logged it. The
grep would have returned silence, during exactly the outage it was written for,
and silence reads as "the API never started" rather than "the API is fine and
the group is misconfigured".

Found while answering a question that the line exists to answer: a required
group had been added to .env, sign-in still worked, and there was no way to
tell whether the group had been checked or the value had simply not been picked
up. (It had not - the file was unsaved. Both readings were correct at the time
they were taken.)

Implemented as a FastAPI lifespan handler. It reports CONFIGURATION only and
opens no connection: startup must not be able to hang on an unreachable domain
controller, and a bind at boot would count against the AD lockout policy for
whatever account it used. ldap_auth.selftest() remains the reachability check -
it validates the certificate without binding, so it cannot contribute to a
lockout either.

Verified by booting the real app under uvicorn and reading the log:

  wpsuite.api: LDAP auth enabled - ldaps://prime.local:636, domain prime.local,
  CA .../prime-ca-chain.pem, required group: CN=Prime Employees,OU=Prime
  Distribution and Security Groups,DC=prime,DC=local

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 10:05:00 -05:00
..

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):

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.

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.

# 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

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.

# .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:

openssl rand -base64 32

2. Add the Dockerfile

Create Dockerfile in the project root:

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):

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:

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

# 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:

# 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

# 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:

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):

curl https://wp-suite.company.local/api/health