Files
Project-SDE-WP-Suite/DEPLOYMENT.md
Cody Schaefer c99666796a DEPLOYMENT: a pre-deploy backup sequence for the D13 auth change
Asked whether there is an easy way to take a full backup before deploying,
since this touches auth. There is - the backup sidecar is already running and
scripts/db-backup.sh takes a one-shot dump - but three things about THIS deploy
were not written down anywhere.

Timing. The container starts with `alembic upgrade head && exec gunicorn`, so
the migration runs seconds after redeploy and there is no window afterwards. The
dump has to be taken before, not after.

Retention. The scheduled job prunes to the newest BACKUP_KEEP (14) files
matching wpsuite-*.sql.gz*, so on a daily cadence a pre-deploy dump is deleted
in a fortnight - exactly when a slow-burning problem would surface. Copying it
to a name outside the glob protects it.

Rollback is not just the database. The new code has no password_hash in its
model and the old code requires it, so restoring without also rolling the code
back leaves schema and application disagreeing. Recorded as both steps in
order, with a note to capture the current commit FIRST, since that is easy to
forget and impossible to reconstruct afterwards.

Also flagged what this particular dump is: the last copy of every password hash
that will ever exist. BACKUP_ENC_PASSPHRASE must be set before taking it - the
script warns and writes plaintext otherwise - and its retention deserves a
deliberate decision rather than the default fortnight, because bcrypt is not
plaintext but is crackable offline given a copy and time.

And a step to prove the dump is readable before deploying, because an untested
dump is not a backup.

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

780 lines
40 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Deployment
Audience: the IT admin standing this up inside the firewall. This covers the
**SQL-backed deployment** — NGINX serving the static front end and a Python API
backed by **PostgreSQL**.
The repo already contains everything needed to run it as a Docker stack:
`Dockerfile`, `docker-compose.yml`, the `nginx/` config, the front end in
`html/`, and the API in `server/`. The detailed container reference (endpoints,
password rotation, day-to-day commands) lives in
[`server/README.md`](server/README.md) — this doc is the start-to-finish guide.
```
[ your TLS reverse proxy / traefik ] ← HTTPS terminates here
│ (external "proxy" network)
┌────▼────┐ internal network ┌──────────┐ ┌────────────┐
browser ───────────────────────│ nginx │ ───── /api/ ───────> │ api │ → │ postgres │
│ (html/) │ │ FastAPI │ │ (db) │
└─────────┘ └──────────┘ └────────────┘
```
Everything runs inside your firewall; the app makes **no outbound internet
calls** (logo and scripts are local).
> **Architecture note:** all static files live under **`html/`** and are *baked
> into the nginx image* at build time (not bind-mounted). So after any front-end
> change you rebuild the `webserver` image (see *Updating* below). The API image
> is built from the root `Dockerfile`.
---
## 1. Prerequisites
- A Linux host with **Docker** and **Docker Compose v2** (`docker compose …`).
- An external Docker network named `proxy` that your TLS-terminating reverse
proxy also sits on (the compose file marks it `external: true`):
```bash
docker network create proxy
```
If you don't run a separate reverse proxy, you can instead publish the nginx
container's port 80 directly (see the note in step 4) and terminate TLS there.
- The repository checked out on the host.
## 2. Create the database credentials (`.env`)
Create a file named `.env` in the **project root** (same folder as
`docker-compose.yml`). It is git-ignored and must never be committed.
```bash
# .env — project root
POSTGRES_DB=wpsuite
POSTGRES_USER=wpsuite
POSTGRES_PASSWORD=<strong-random-password>
# REQUIRED — signs login session cookies. If unset, `docker compose up` errors
# out and the API refuses to start. Generate once and keep it stable:
# openssl rand -base64 48
AUTH_SECRET_KEY=<strong-random-secret>
# Encrypts database backups at rest (AES-256). Set this BEFORE the DB holds
# customer IP. Keep the passphrase OFF this host — losing it makes dumps
# unrecoverable: openssl rand -base64 32
BACKUP_ENC_PASSPHRASE=<strong-random-passphrase>
# OPTIONAL — SMTP password for WP-assignment email + password-reset links. Email
# is OFF by default and enabled from the Admin console; the host/port/from-address
# are configured there, but the password is only ever read from this variable
# (never stored in the DB or shown in the UI). Leave unset until you have SMTP
# details.
# SMTP_PASSWORD=<smtp-app-password>
# OPTIONAL — the AD group required to sign in (D13). A group NAME or a full DN;
# nested groups count. Empty means any domain account may sign in. This is the
# initial value; the live one is set in the Admin console.
# LDAP_REQUIRED_GROUP=CN=Prime Employees,OU=Prime Distribution and Security Groups,DC=prime,DC=local
#
# OPTIONAL — the rest of the directory settings. The defaults are correct for this
# estate and you should not normally set them. NEVER point LDAP_HOST at a DC name
# or an IP: see § Domain authentication below.
# LDAP_DOMAIN=prime.local
# LDAP_HOST=prime.local
# LDAP_CA_FILE=/app/server/certs/prime-ca-chain.pem
```
The API builds its own DB connection string from the `POSTGRES_*`
values and **encodes the password automatically**, so a password with special
characters (`@ ! # : /` …) works without any manual escaping. `DATABASE_URL`
is **optional** and only needed if you want to point the API at some other
database; if you do set it, you must URL-encode the password yourself, and it's
ignored whenever the three `POSTGRES_*` values are present.
Generate a strong password with `openssl rand -base64 32`.
> **Portainer note:** for a Git-based stack the stack's **Environment variables**
> section is not merely an alternative to `.env` — it is the ONLY route, because
> Portainer does not read a local `.env` at all. Every value the compose file
> references as `${VAR}` has to be set there or it arrives empty.
>
> The full list, and what an empty one costs you:
>
> | Variable | Required? | If unset |
> |---|---|---|
> | `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` | **yes** | the stack will not start |
> | `AUTH_SECRET_KEY` | **yes** | compose fails fast; the API refuses to start |
> | `LDAP_REQUIRED_GROUP` | **effectively yes** | no group gate — **every account in the domain may sign in**. Silent: sign-in works, so nothing looks wrong. |
> | `BACKUP_ENC_PASSPHRASE` | before real data | dumps are written unencrypted |
> | `SMTP_PASSWORD` | only with email on | notifications are recorded and never sent |
> | `MICRON_DB_URL` | optional | the asset picker degrades to manual entry |
>
> Paste values raw — it is a form field, not a shell, so no surrounding quotes.
> Quotes are not stripped and become part of the value: a quoted
> `LDAP_REQUIRED_GROUP` will not resolve, and a quoted `MICRON_DB_URL` will not
> parse.
>
> **`MICRON_DB_URL` must be URL-encoded** (`@` → `%40`, `#` → `%23`, `/` → `%2F`)
> because it is a full connection URL. `LDAP_REQUIRED_GROUP` must NOT be encoded —
> it is an LDAP distinguished name, and its spaces and commas are legal as they are.
`POSTGRES_PASSWORD`, `AUTH_SECRET_KEY`, `BACKUP_ENC_PASSPHRASE`, `SMTP_PASSWORD` and
the password inside `MICRON_DB_URL` are the only credentials in the system, and none
of them appears in the compose file or in git.
## 3. Point your reverse proxy at the nginx container
The nginx container listens on port **80** on the `proxy` network and expects
TLS to be terminated upstream (by your reverse proxy / traefik). Route your
chosen hostname (e.g. `wp-suite.company.local`) to the `nginx_webserver`
container on that network. The container already proxies `/api/` to the `api`
service internally — no extra app config needed.
> **Serve it over HTTPS, and forward the scheme.** The bundled nginx sets the
> security response headers (CSP, HSTS, `X-Frame-Options`, `nosniff`) and passes
> `X-Forwarded-Proto: https` to the API, which is what makes the session cookie
> `Secure`. If you front the stack with your **own** proxy instead, make sure it
> terminates TLS and forwards `X-Forwarded-Proto: https` — otherwise the login
> cookie won't get the `Secure` flag. HSTS also assumes the site is only ever
> reached over HTTPS.
## 4. Bring it up
From the project root:
```bash
docker compose up -d --build # builds the api + nginx images, starts all three containers
docker compose ps # confirm nginx_webserver, wp_api, wp_db are running/healthy
docker compose logs -f api # watch the API start (Ctrl-C to stop following)
```
The database schema is **created automatically** on first API start — no manual
`CREATE TABLE`. The Postgres data lives in the named volume `pgdata` and
survives `docker compose down` (only `down -v` deletes it).
> No separate reverse proxy? Publish nginx directly by adding a `ports:` mapping
> to the `webserver` service (e.g. `"8080:80"`) and terminate TLS at whatever
> sits in front of it. The internal `api`/`db` containers should **never** be
> published.
## 5. Verify
```bash
# API liveness (from the host, through the proxy hostname)
curl https://wp-suite.company.local/api/health # → {"ok": true}
# Interactive API docs
# https://wp-suite.company.local/api/docs
```
Then load the site in a browser: the home page should prompt to **select or
create a project**. Create one, complete an SOP, and confirm a row appears:
```bash
docker compose exec db psql -U wpsuite -d wpsuite -c "select id, name from projects;"
```
### Automated smoke test
`server/smoketest.py` exercises the whole stack end-to-end (health → sign-in →
project → SOP → Work Package → the AWP issue gate → status → metrics → comments →
archive round trip → cascade cleanup → sign-out). Stdlib only — no pip/jq.
It **signs in first**, because every `/api/` route except `/api/health` requires a
session. Credentials come from the environment so a password stays out of shell
history, and the account must be an **admin**: the run creates a project and deletes
it again, and archiving or deleting one takes Project Admin on it. The script checks
the signed-in role up front and warns if it is too low rather than letting you find
out in the cleanup step.
```bash
export WP_SMOKE_USER=<admin-account>
export WP_SMOKE_PASSWORD='…'
# Through the proxy (use --insecure for a self-signed internal cert):
python3 server/smoketest.py https://wp-suite.company.local --insecure
# Or from inside the api container (hits FastAPI directly). Pass the vars through:
docker compose exec -e WP_SMOKE_USER -e WP_SMOKE_PASSWORD api \
python /app/server/smoketest.py http://localhost:8000
# Add --keep to leave a demo project in the DB so you can open it in the UI.
# --user / --password override the environment if you'd rather be explicit.
```
Exit codes: **0** all checks passed · **1** one or more checks failed · **2** the run
could not start (host unreachable, or credentials missing or rejected). The last is
kept separate on purpose — "I could not test this" is a different answer from "this is
broken", and automation should not treat them alike.
### Front-end browser check
`tests/browser_check.py` is the other half: the smoke test proves the API works, this
proves the **pages** work. It runs them in headless Edge (or Chrome) over the DevTools
Protocol and asserts what only a browser can settle — that each page boots without a
JavaScript error, that the role-dependent renderings are right, and that the layout
rules the console pages depend on are actually in effect.
Self-contained: it creates a throwaway SQLite database, seeds a fixture (two projects,
an admin, a Project Super User, a plain member, and accounts positioned to exercise
in-scope / out-of-scope / invisible), starts its own server on a free port, and tears
all of it down. **Your real database is never touched.** Stdlib only.
```bash
python tests/browser_check.py # everything, ~71 checks
python tests/browser_check.py --keep-server # leave it up to poke at by hand
WP_BROWSER=/path/to/chrome python tests/browser_check.py
```
Same exit codes as the smoke test, including **2** for "no browser found" — a missing
browser is not a failing app.
Run this after any change to `html/users.js`, `html/wp-sidenav.js`, `html/console.css`
or `html/admin.js`. It is the check that would have caught a rule lost while
`console.css` was being extracted out of `admin.html`, which is a silent, whole-page
regression that no server-side test can see.
Exit code 0 and "ALL PASS" means the API, the Python logic, and SQL are all
working. It cleans up after itself (the test project and its SOP/WPs are
deleted via cascade); a single tagged test comment remains (there's no comment
delete endpoint).
### Loadable demo project
`server/seed_demo.py` populates a realistic **DEMO** project (a complete SOP plus
a spread of Work Packages: issued, gated, a multi-discipline master with split
instances, an overdue one, an over-threshold draft) so there's data to look at.
```bash
python3 server/seed_demo.py https://wp-suite.company.local --insecure
python3 server/seed_demo.py https://wp-suite.company.local --clean # remove it later
```
> **What shows where:** the DEMO **project**, its **SOP**, and its **Work
> Packages** are all API/SQL-backed, so they appear in the home-page project
> picker and render in the Creator/Dashboard as soon as any user opens the
> project. Inspect them at the SQL layer with `smoketest.py` or:
> ```bash
> docker compose exec db psql -U wpsuite -d wpsuite \
> -c "select number, subject, status from work_packages order by number;"
> ```
---
## What is stored in SQL today
The API + Postgres are the system of record. Everything below is server-stored
and shared across every user who opens the project:
| Data | Stored in PostgreSQL today? |
|------|------------------------------|
| **Projects** | **Yes** — the front end is API-first (`/api/projects`), falling back to the browser only if the API is unreachable. |
| **Comments / feedback** | **Yes** — every feedback surface posts to `/api/feedback`. |
| **SOPs** | **Yes** — pulled from `/api/sops` on load and written through on every save. |
| **Work Packages** | **Yes** — same write-through to `/api/wps` (+ issue / status / archive / metrics), including the owner assignment (`assignee_id`). |
Saves go through a **durable client-side sync outbox**: edits are written to the
API immediately, and if the device is offline they queue and retry when it
reconnects (4xx rejections are dropped rather than retried forever). The browser
cache is only an offline fallback that reconciles through that outbox — so two
users on the same project see the same server-stored SOP and Work Packages.
## Data model (PostgreSQL)
| Table | Holds | Key columns |
|-------|-------|-------------|
| `projects` | top-level construction projects | `name`, `number`, `client`, `division`, `site`, `sample`, `archived_at`, `data` |
| `sops` | project SOP baselines | `project_id` → projects, `name`, `number`, `complete`, `data` (full SOP JSON) |
| `work_packages` | individual IWPs | `project_id` → projects, `sop_id` → sops, `parent_id` (split instances), `number`, `subject`, `type`, `status`, `assignee_id` (owner), `issued_at`, `archived_at`, `data` (full WP JSON) |
| `comments` | feedback from any page | `source`, `sop_id`, `wp_id`, `step`, `author`, `text`, `extra` |
| `users` | login accounts (no password — D13) | `username` (sAMAccountName), `role`, `full_name`, `email`, `is_active`, `auto_add_projects` + `auto_add_role` (default membership on new projects), login-lockout + `token_version` fields |
| `project_members` | per-project access control | `user_id` → users, `project_id` → projects |
| `audit_log` | append-only activity trail | `actor`, `action`, `entity_type`, `entity_id`, `project_id`, `summary`, `detail` |
| `notifications` | in-app record + email outbox | `user_id`, `kind`, `wp_id`, `subject`, `status` (pending / sent / failed / skipped) |
| `app_settings` | admin-configured settings (e.g. email) | `key`, `value` (JSON) |
The complete client document is stored verbatim in each row's `data` JSON
column; frequently-listed fields are promoted to real columns for filtering.
### Endpoints (summary)
Projects `GET/POST /api/projects`, `GET/DELETE /api/projects/{id}`,
`POST /api/projects/{id}/archive` ·
SOPs `GET/POST /api/sops`, `GET /api/sops/latest`, `GET/DELETE /api/sops/{id}` ·
Work Packages `GET/POST /api/wps`, `GET/DELETE /api/wps/{id}`,
`POST /api/wps/{id}/issue`, `POST /api/wps/{id}/status`, `POST /api/wps/{id}/archive`,
`GET /api/wps/metrics` ·
Comments `POST /api/comments` (and `/api/feedback`), `GET /api/comments` ·
Auth `POST /api/auth/login` / `logout`, `GET /api/auth/me`, admin user management
under `/api/auth/users` (including `POST /api/auth/users/{id}/auto-add`) ·
Admin-only `GET/PUT /api/settings`,
`POST /api/settings/test-email`, `GET /api/notifications`,
`GET /api/projects/{id}/members`.
List/latest/metrics accept a `project_id` (and `sop_id`) filter. `GET /api/projects`
and `GET /api/wps` both take `archived=exclude|only|all` and **default to
`exclude`** — anything that needs to see archived rows (the admin console, the demo
cleanup) must ask for them. Full reference and request shapes: `/api/docs` and
[`server/README.md`](server/README.md).
---
## Updating after a change
```bash
git pull
docker compose up -d --build webserver # front-end change (html/) — rebuild the baked image
docker compose up -d --build api # backend change (server/)
```
## Before deploying the D13 auth change — take a backup first
**Timing is the whole point.** The container's start command is
`alembic upgrade head && exec gunicorn`, so migrations run *seconds after you
redeploy*. Migration `b7e4f1a20c93` drops `users.password_hash`. There is no window
afterwards: back up **before** you redeploy, not after.
```bash
# 1. Record what you are rolling back TO. Do this first; it is easy to forget
# and impossible to reconstruct under pressure.
git -C /path/to/repo rev-parse --short HEAD
# 2. One-shot dump, using the sidecar that is already running.
docker compose exec backup sh /scripts/db-backup.sh
# -> ./backups/wpsuite-<UTC timestamp>.sql.gz[.enc] on the host
# 3. Take it OUT of the rotation. The scheduled job prunes to the newest
# BACKUP_KEEP (default 14) files matching wpsuite-*.sql.gz*, so on a daily
# cadence this dump is deleted in a fortnight. A prefix that does not match
# the glob is enough to protect it.
docker compose exec backup sh -c 'cd /backups && cp "$(ls -1t wpsuite-*.sql.gz* | head -1)" "pre-d13-$(ls -1t wpsuite-*.sql.gz* | head -1)"'
# 4. Prove it is readable BEFORE you deploy. An untested dump is not a backup.
docker compose exec backup sh -c 'openssl enc -d -aes-256-cbc -pbkdf2 -pass env:BACKUP_ENC_PASSPHRASE -in /backups/pre-d13-*.sql.gz.enc | gunzip -c | grep -c "INSERT INTO public.users"'
# (drop the openssl stage for an unencrypted .sql.gz)
```
### Two things about this particular dump
**It is the last copy of every password hash that will ever exist.** After the
migration the column is gone; this file is where those bcrypt hashes live from then
on. Make sure `BACKUP_ENC_PASSPHRASE` is set before step 2 — the script warns loudly
if it is not, and writes plaintext — and decide deliberately how long to keep the
file. bcrypt is not plaintext, but it is crackable offline given time and a copy.
**Restoring the database is not, by itself, a rollback.** The new code has no
`password_hash` in its model and the old code requires it, so a restore without a
matching code rollback leaves you with a schema and an application that disagree. A
real rollback is both, in this order:
```bash
# redeploy the commit from step 1 (Portainer: point the stack back and rebuild)
docker compose exec backup sh /scripts/db-restore.sh /backups/pre-d13-wpsuite-<ts>.sql.gz.enc
```
`db-restore.sh` dumps are taken with `--clean --if-exists`, so restoring **drops and
recreates** objects before loading. It overwrites whatever is currently there.
---
## Backups & retention
A **`backup` sidecar** (in `docker-compose.yml`) runs `pg_dump` on a schedule and
writes gzipped, timestamped dumps to `./backups/` on the host. It starts with the
stack — no cron to set up.
- **Cadence / retention:** daily, keeping the newest 14 dumps. Override in `.env`
with `BACKUP_INTERVAL_SECONDS` (seconds between dumps) and `BACKUP_KEEP` (how many
to keep).
- **Encryption at rest:** set `BACKUP_ENC_PASSPHRASE` in `.env` and dumps are
written AES-256-encrypted as `*.sql.gz.enc`. **Do this before any customer IP
goes in** — without it the dumps (and every offsite copy) are plaintext. Store
the passphrase somewhere other than this host; if you lose it the backups can't
be restored.
- **Ad-hoc backup now:** `docker compose exec backup sh /scripts/db-backup.sh`
- **Restore (destructive — overwrites current data):**
`docker compose exec backup sh /scripts/db-restore.sh /backups/wpsuite-YYYYMMDD-HHMMSSZ.sql.gz.enc`
- **Offsite — do this:** the dumps live in `./backups/` on the host; if the host/volume
dies, so do they. Sync that folder offsite from the **host** (e.g. a cron running
`rclone`/`aws s3 sync`). The `db`/`backup` containers are on an egress-less
`internal` network on purpose, so offsite must be pushed from the host.
- **Test restores quarterly:** load the latest dump into a throwaway database and
confirm it applies. An untested backup is not a backup.
## Field devices & data at rest
The field view (PWA) caches a project's Work Packages/SOP in the browser's
localStorage so it works offline — i.e. **customer IP sits on the device**.
localStorage is not encrypted and is not a security boundary. Signing out clears
the cached project data, but for any tablet/phone that opens customer-IP projects:
- **Require full-disk encryption** (BitLocker / FileVault / Android FBE / iOS is
encrypted by default) and a device passcode.
- **Enrol field devices in MDM** so a lost device can be remotely wiped, and keep
the browser profile per-user on shared devices.
- Users should **sign out** when handing off a shared device (clears the cache).
## Email notifications (optional)
Work-package **owner assignment** works out of the box (in-app only). Optional
**email** on assignment is **OFF by default** and is turned on from the **Admin
console → Notifications & email** card, where an admin sets the SMTP host / port /
TLS / From address and flips the master toggle.
- The **SMTP password is never stored in the database.** It is read only from the
`SMTP_PASSWORD` environment variable (see the `.env` block in step 2 and the
`api` service in `docker-compose.yml`). The UI shows only whether it is set.
- Email stays effectively off until **all** of: the toggle is on, SMTP host + From
are configured, and `SMTP_PASSWORD` is present. Until then, assignments are
still recorded in-app (status `skipped`); nothing is sent.
- Notification emails carry only a **WP number and a deep link** — never the work
package contents — so customer IP stays behind the login.
- Use the card's **Send test email** button to confirm SMTP before enabling.
### Password reset — there isn't one
D13 removed local passwords entirely. **Turning email on no longer affects sign-in.**
The login page's "Forgot password?" links to `https://primecontrols.okta.com/`, which
is the only self-service route; the app cannot reset a credential it does not hold.
Email still carries WP-assignment notifications and the critical-reopen mail.
---
## Domain authentication (D13)
Sign-in is an **LDAPS simple bind** as `<sAMAccountName>@prime.local`. There is no
password in the database and **no break-glass account**. If the domain is
unreachable, `LDAP_CA_FILE` is wrong, or the required group is misconfigured,
**nobody can sign in, including admins.**
**First thing to check on any sign-in problem** — the API logs one line at startup
saying whether LDAP is configured, and `/api/health` stays unauthenticated so the
stack is diagnosable while nobody can log in:
```bash
docker compose logs api | grep -i "LDAP auth"
# LDAP auth enabled — ldaps://prime.local:636, domain prime.local, …
# LDAP auth DISABLED — CA bundle not found at '…'. No one can sign in.
curl https://wp-suite.company.local/api/health # → {"ok": true}
```
Then prove the certificate path, without binding — this touches no account and so
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)
```
### Three things that are not obvious
**Connect to the domain name, never a DC or an IP.** Every DC's certificate carries
`prime.local` in its SAN, so the domain name both passes hostname validation and
round-robins across all six DCs published in `_ldap._tcp.prime.local`. An IP gives
`Verify return code: 62 (hostname mismatch)` because there is no IP SAN — and the
only way to force it through is to disable validation. Do not. Domain passwords
cross this link, and an unvalidated one can be terminated by anyone on the network
who then harvests them.
**The CA bundle is not a certificate issued to this app.** The API is the TLS
*client*; clients verify, they do not present. `server/certs/prime-ca-chain.pem`
contains `PRIME CONTROLS ROOT CA` (valid to 2051) and `PRIME CONTROLS ISSUING CA 1`
(2036) — public certificates with no private key. There is nothing to request from
IT, no CSR and no enrollment. Rebuild it from any domain-joined machine with:
```powershell
Get-ChildItem Cert:\LocalMachine\Root, Cert:\LocalMachine\CA |
Where-Object { $_.Thumbprint -in
'C371E91C430A12051029527C443B1EF683675CF3', # PRIME CONTROLS ROOT CA
'4F7506105228C73DF64181ACA20AD9783437EC8B' } # PRIME CONTROLS ISSUING CA 1
```
exporting each as Base-64 and concatenating them into one file.
**The `outbound` network is required.** `internal` has no default gateway, which
blocks the LAN and the VPN as well as the internet, so the `api` container cannot
reach `prime.local:636` without it. Its comment used to say it was optional if you
were not using the Micron asset picker; detaching it now breaks every sign-in.
### Accounts
Accounts are **created on first successful sign-in**, at `project_user` with **no
project access** — the person signs in and sees nothing until an admin grants it.
Roles are local and never read from AD, so an existing admin keeps admin.
The first admin is bootstrapped in two steps: sign in once, then
```bash
docker compose exec api python -m server.manage_users promote <sAMAccountName>
```
which prompts for *your* domain credential. `list`, `demote`, `disable` and `enable`
are the other commands; `create-admin` and `create` no longer exist.
### The lockout arithmetic
`AUTH_MAX_ATTEMPTS` defaults to **2**, and that is a safety limit rather than a
preference. Failures are now domain binds, so they count against the **AD account
lockout policy** (5 on this estate). The throttle is per-process and the API runs 2
gunicorn workers, so a local limit of N allows up to 2N binds to reach a DC: 2 × 2 = 4,
one under the threshold. **Raising this, or adding a worker, means redoing that
arithmetic** — otherwise `/api/auth/login` becomes a way for anyone, unauthenticated,
to lock a colleague out of Windows.
## Permissions roles
`User.role` is the **permissions** role; `User.project_role` is the person's **job
function** on the project (Project Manager, Superintendent, …) and grants nothing.
Both are set on the **User Directory** page (`users.html`) — not the Admin Console,
which no longer manages accounts.
| Role | May do |
|---|---|
| `admin` | User administration everywhere, app settings, and every project |
| `project_super_user` | Everything `project_admin` may do, **plus user administration on the projects they hold the role on**: create accounts, reset passwords, set permissions, grant project access |
| `project_admin` | On assigned projects: delete work packages, change a **completed** SOP, delete the project |
| `project_user` | Create/edit work packages, author a SOP up to completion; may archive a WP but not delete one |
Enforced server-side by `require_project_admin` in `server/app.py`; the front end
only hides controls to avoid dead-end clicks. Accounts created before roles existed
carried the role `user`, which the migration rewrites to `project_user`.
### Project Super User — what bounds it
The role exists so a project admin can staff their own job without an app admin.
Its limits are what make it safe to hand out, and all of them are server-side
(`managed_project_ids`, `manage_user_problem`, `grantable_roles` in `server/app.py`):
* **Scope comes from projects, not the job title.** A super user administers the users
of the projects they hold the role on — via their account role, or via
`ProjectMember.role` for a super user on one job only. No projects, no authority.
* **Account changes need EXCLUSIVE scope.** Resetting a password, disabling, renaming,
changing permissions or deleting are global acts, so they are refused when the
target is also on a project the caller does not administer. The directory shows
those rows read-only with the reason. An app admin has to make the change.
* **No admin or super-user targets, and none granted.** A super user may hand out
`project_admin` / `project_user` only, and may not touch an admin's or another
super user's account — so the role cannot become a route to app-wide control.
* **Saving project access never reaches outside scope.** `PUT
/api/auth/users/{id}/projects` rebuilds only the caller's own slice; memberships on
projects they don't administer are left untouched.
* **App settings, feature flags and the default-member rule stay admin-only.**
No migration is needed for the new role — `users.role` is already `String(20)` and
`project_super_user` fits. Grant it from the User Directory (Permissions column), or
per project from **Project access → Project Super User here**.
## Feature flags
**Admin console → Features.** `bim_enabled` is **OFF by default**: the SOP creator
hides the BIM/VDC section and every project is install-only (IWP). A SOP that
already has BIM enabled keeps its data — it just stops being offered — so turning
the flag off never deletes BIM types, gates, or sequence steps.
## Release gates (constraints + predecessors)
A work package reaches **Issued** only when both gates are met:
1. every constraint is **Cleared** or **N/A** — a hard gate, no override;
2. every **predecessor work package** (`data.predecessors`, a list of WP ids) is
**Closed**.
Enforced by `enforce_release_gates()` on **every** path that can set a status —
`/api/wps` (the browser and the offline outbox both save through it),
`/api/wps/{id}/issue`, and `/api/wps/{id}/status`. Also:
- **Overridable, deliberately.** Planners legitimately release ahead of upstream
close-out, so the predecessor gate accepts `data.gateOverride = {reason, by, at}`.
A blank reason is not an override. The server writes a `gate_overridden` audit
event naming the reason and what was skipped, and the reason prints on the
package. Changing the predecessor set clears the override.
- **Cycles are refused** (`check_predecessor_cycle`) — direct and through a chain,
with a 400 explaining which package already waits on this one.
- **A deleted predecessor does not block.** It would otherwise freeze everything
downstream of a package someone removed.
- The Creator's picker hides itself and any package that already waits on it, so a
cycle is hard to build in the first place; the dashboard refuses to issue a
blocked package and points at the form for the logged override.
`data.seq` (the SOP sequence phase) is still stored and shown, but it is
descriptive — it gates nothing.
## Critical constraints reopened after release
A constraint marked **Critical** on the SOP that reopens **after** the package was
released emails the **owner, PM, CM and everyone on the package's distribution
list** (minus whoever reopened it), and writes a `constraint_reopened` audit event.
Detected by comparing incoming constraints against the stored ones inside the
normal upsert — *not* a separate endpoint, because the browser saves through the
sync outbox, which only replays `POST /api/wps`; anything hung off another route
would be lost offline. It fires only on a real transition (cleared/N-A → open), so
re-saving an already-open constraint doesn't re-announce, and never for a package
that was never released or a non-critical constraint. Bodies carry the constraint
name, WP number and a link — never the package contents.
## Localization (dates, times, numbers)
Three levels, most specific first — resolved in `html/wp-format.js`:
1. **the user's own preference** — *Language & time* in the top-right menu
(`users.locale` / `users.timezone`, via `POST /api/auth/preferences`)
2. **the app default** — Admin console → Features → *Localization defaults*
(`default_locale` / `default_timezone`)
3. **the browser**, as before
Timezone names are validated against the server's own `zoneinfo` database, and the
picker is fed from `GET /api/timezones` so it can only offer what will be accepted.
Calendar dates (a due date, a kitting date) are formatted from their parts and are
**never** shifted by a timezone — only real instants (MIMO windows, history,
notifications) are converted. Use the shared helpers (`wpFormatDate`,
`wpFormatDateTime`, `wpFormatTime`, `wpFormatNumber`) rather than
`toLocaleString()`, or a page will quietly ignore the preference.
## Top-bar chrome (project switcher + search)
`html/wp-chrome.js` + `wp-chrome.css` inject a project switcher and a centered
global search into whichever top bar a page has — the dark `.wp-appbar` or the
older `.header`. It is skipped inside an iframe, so the embedded WP creator does
not get a second bar.
- Switching project reloads the current page with `?project=<id>`; every page
already resolves its project from that parameter.
- Search calls `GET /api/search?q=`, which is **scoped to the caller's projects**
(`scope_to_access`) and hides archived work packages, archived projects, and
anything belonging to an archived project. LIKE wildcards in the query are escaped,
so searching `100%` matches a literal `100%`. Two-character minimum.
- Ctrl/Cmd-K focuses the field from anywhere.
## Schema migrations (Alembic)
Schema is managed by **Alembic** (`server/alembic/`). The API container runs
`alembic upgrade head` on startup (see the `Dockerfile` CMD), so **deploys apply
pending migrations automatically**.
- The **baseline** migration is idempotent: on a fresh database it creates every
table; on a database whose tables already exist (made by the old `create_all`)
it adopts the schema as-is — no manual `alembic stamp` needed.
- Local dev on SQLite still auto-creates tables for a zero-config run; Postgres is
migrations-only.
- **To change the schema:** edit `server/models.py`, then generate and review a
migration before committing:
```bash
# from the project root (against your dev SQLite or a staging DB)
python -m alembic -c server/alembic.ini revision --autogenerate -m "describe the change"
python -m alembic -c server/alembic.ini upgrade head # apply locally to test
```
The next `docker compose up -d --build api` applies it in production on startup.
## Local trial without Postgres
For a quick local look, the API falls back to a SQLite file when `DATABASE_URL`
is unset (`sqlite:///./wpsuite.db`) — see [`server/README.md`](server/README.md)
§ *Local dev*. The front end alone can also be served statically from `html/`
(it falls back to browser storage when the API isn't reachable).
## Per-project permissions
`users.role` is the account's **default** permissions role. A membership row can
override it **per project** (`project_members.role`), so someone can be Project
Admin on one job and a plain Project User on another. Empty means "inherit the
account's role", which is how every pre-existing membership behaves.
Resolved by `effective_role()` in `server/app.py`; `require_project_admin()` uses it,
so deleting a work package, changing a completed SOP and deleting a project are all
judged **on that project**. An app `admin` is admin everywhere and bypasses
membership entirely.
Set it in **Admin console → User administration → Project access** (its own column,
showing how many projects each account can reach). The dialog ticks project access
and picks the role on each; `/api/auth/users/{id}/projects` takes
`{project_ids: [...], roles: {project_id: role}}` and only accepts the two
project-scoped roles. Changes are audit-logged as `project_access_changed`.
**Who appears in the SOP's people pickers** is `GET /api/projects/{id}/members` —
the project's members plus app admins, each with their effective role on that
project. A project with nobody assigned shows only the admins, which is why
assigning people is the first step on a new job.
### Default members on new projects
Memberships are also created automatically. **Admin console → Default members on
new projects** flags accounts (`users.auto_add_projects`) that belong on every job —
the PM who runs them all, the QC lead — with the role they should hold there
(`users.auto_add_role`, sharing `project_members.role`'s value space, `''` =
inherit the account's own).
- It applies **only to projects created after the flag is set**. Nothing is
back-filled onto existing jobs; use **Project access** for those.
- App admins are skipped (they already reach every project) and the flag is cleared
if an account is promoted to admin. Inactive accounts are skipped.
- Runs in `add_default_members()` on the `is_new` branch of `upsert_project`, so it
covers every route into project creation — the home page, the sample project, the
demo seeder. An update never re-runs it.
- If the creator is themselves a flagged member, the membership created for them as
creator carries their `auto_add_role`, so they aren't silently downgraded on the
one job they started.
- Audit-logged once per project as `project_access_granted` with
`detail.reason = "auto_add_projects"`.
## Archiving a project
A finished job is archived rather than deleted: `projects.archived_at`, set from
**Admin console → Projects** (or `POST /api/projects/{id}/archive`, which needs
Project Admin **on that project**, same bar as deleting it).
An archived project is **hidden and frozen**:
- It leaves the home picker, the app-bar switcher and global search, because
`GET /api/projects` defaults to `archived=exclude`.
- It is still readable by id, so a deep link renders it — with a read-only banner
from `wp-chrome.js` — and the admin console still lists it under
`?archived=all`.
- Every write that lands on it is refused with **409** by
`require_project_writable()`: saving a project, SOP or work package, deleting
either, issuing, status changes, WP archiving, and comments on its WPs/SOPs.
Moving a work package *into* or *out of* an archived project is refused too.
409 rather than 403 is deliberate — nobody lacks a permission, the project's state
is the objection, and the browser outbox (`html/project-data.js`) retires 4xx ops
instead of retrying them forever.
- Unarchiving and **deleting** stay allowed: unarchive is the one write an archived
project must accept, and archive-then-delete is a normal sequence.
Nothing is removed, and unarchiving restores all of it. `server/smoketest.py`
asserts the whole round trip.
## Asset freshness (why the app can't run half-updated)
A page must never run against a stylesheet or script from a previous deploy. Three
things enforce that, and all three are needed:
1. **`Cache-Control: no-cache` on HTML/CSS/JS** — set by NGINX
(`nginx/conf.d/wp-suite.conf`) and by the dev server (`_NoCacheCode` in
`server/app.py`). With no header at all the browser applies *heuristic* freshness,
roughly 10% of each file's age, so the least recently changed file gets the longest
lifetime — which is exactly how HTML and CSS drift apart. ETag/Last-Modified still
make each revalidation a cheap 304.
2. **The service worker fetches code with `cache: 'no-cache'`** (`html/sw.js`) and
precaches with `cache: 'reload'`. A plain `fetch(req)` inherits the request's
default cache mode and consults the browser HTTP cache, so "network-first" alone
was not enough. Non-`ok` responses fall back to the cache rather than replacing a
page the cache could still serve, and cache keys drop the query string so in-app
links (`?project=…&tab=…`) still resolve offline.
3. **Components whose CSS-missing state is *broken* carry their own critical layout.**
The embedded creator's iframe keeps its sizing inline (and `sizeWPFrame()` re-applies
it), and the work-package panel injects a floor of positioning rules from
`wp-creation-app.js`. Both had failure modes — a 300×150 iframe, and panel controls
dumped loose into the form — that a missing rule turned into a broken page rather
than a plain one.
If you change the shell file list in `sw.js`, bump `CACHE`.
> **NGINX note:** the `Cache-Control` value comes from a `map $uri $wp_cache_control`
> at http level, applied with a single server-level `add_header`. Do **not** move it
> into a `location` block: nginx does not inherit `add_header` into a block that
> declares its own, so a `location ~* \.(html|css|js)$` setting only `Cache-Control`
> silently drops the CSP / HSTS / X-Frame-Options / nosniff headers for exactly those
> files. After deploying, confirm both are present on one response:
>
> ```bash
> curl -sI https://wp-suite.company.local/work-package-suite.html > | grep -Ei 'cache-control|content-security-policy'
> ```