Files
Project-SDE-WP-Suite/server
Cody Schaefer 25bc5bcd3f member_of could not tell a failed search from a non-member
Reported by a user who is a direct member of the configured group and was being
refused with "Invalid username or password". Get-ADUser confirmed the
membership, so the fault was here.

The connection is built with raise_exceptions=False. A search that FAILS
therefore returns False and leaves conn.entries empty - which is
indistinguishable from "no match" if you only inspect conn.entries, which is
all member_of did. Every possible failure of the extensible-match filter
presented to the user as "you are not in the group" while they plainly were,
and produced no log line saying otherwise.

Now:

- conn.search()'s return value is checked. A failed search raises
  LookupError(GROUP_CHECK_FAILED) and logs conn.result together with the filter
  that produced it. GROUP_CHECK_FAILED counts as a config problem, so login()
  answers 503 rather than 401 - our fault, not the user's, and reported as such.

- If the transitive query matches nothing, a plain memberOf equality check runs
  for DIRECT membership. If THAT matches, the person is a member and is let in:
  refusing a real member is the worse error. It logs a WARNING naming the
  matching rule, because that outcome means nested membership is silently not
  working on this connection and needs a human.

The failure mode this replaces is the one that hurts most: correct
configuration, correct credential, real membership, and a refusal that blames
the password. Same shape as the two logging faults fixed just before it - the
information existed and could not be read.

ldap_auth_check still 32/32; the fake directory exercises both branches.

A step-by-step diagnostic (bind, account lookup, memberOf dump, the nested
query, the direct query, group resolution) is in the session scratchpad rather
than the repo - it is a one-off aid, not a deliverable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 11:37:59 -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