Asked whether MICRON_DB_URL could live in Portainer rather than .env. It can -
for a Git-based stack it is the only route, since Portainer does not read a
local .env and every ${VAR} in the compose file resolves from the stack's
Environment variables.
But the note listing what to set there named POSTGRES_*, AUTH_SECRET_KEY,
BACKUP_ENC_PASSPHRASE and SMTP_PASSWORD only. MICRON_DB_URL was missing and has
been since it was added, and LDAP_REQUIRED_GROUP was missing because I added the
variable and never updated this list. That list is what someone follows when
standing the stack up.
Replaced with a table of every variable the compose file references, and what an
empty one actually costs. LDAP_REQUIRED_GROUP is the one worth reading twice:
unset means no group gate, so every account in the domain may sign in, and it is
SILENT - sign-in works, nothing looks wrong. That was observed first-hand today,
where a group had been configured, sign-in succeeded, and the group check had
never run.
Two encoding rules that pull in opposite directions, now stated together because
getting them the wrong way round is easy: MICRON_DB_URL is a connection URL and
must be percent-encoded; LDAP_REQUIRED_GROUP is a distinguished name and must NOT
be - its spaces and commas are legal as they stand. Neither takes quotes; a form
field is not a shell, and quotes become part of the value.
Also corrected the closing line, which claimed those were "the only credentials
in the system" while omitting the password embedded in MICRON_DB_URL.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
730 lines
38 KiB
Markdown
730 lines
38 KiB
Markdown
# 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/)
|
||
```
|
||
|
||
## 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'
|
||
> ```
|