T10.6 - deployment docs and env var reference describe Okta, not the never-shipped LDAP config

D13 never shipped, so DEPLOYMENT.md, server/.env.example and server/README.md
still described the original local-password system as of this task starting -
POST /api/auth/login, bcrypt password_hash, create-admin with a prompted
password, self-service reset-password email flow, AUTH_RESET_MINUTES /
AUTH_RESET_COOLDOWN_SECONDS. All of that is gone as of T10.4; these three files
now describe what actually runs.

server/.env.example and DEPLOYMENT.md's env block both gain the five OKTA_*
variables (ISSUER, CLIENT_ID, CLIENT_SECRET, REDIRECT_URI, IDENTITY_CLAIM),
explained the same way AUTH_SECRET_KEY already was - what it does, where to
get it, what happens if it's missing.

Also updated, not originally named in T10.6's bullet but required for the
documented vars to actually reach a running container: docker-compose.yml's
api service sets environment: as an explicit allowlist, not env_file, so the
four new OKTA_* entries had to be added there too or .env would document
something that silently does nothing. OKTA_IDENTITY_CLAIM specifically is NOT
${OKTA_IDENTITY_CLAIM:-} - compose setting an env var to an empty string is
not the same as leaving it unset, and server/okta_auth.py's own default
(preferred_username) only kicks in when the var is truly unset. Mirrored the
same default in the compose file instead, or every deployment that leaves the
optional line commented out in .env would 503 on every sign-in looking for a
claim literally named "".

server/README.md: replaced the login-portal section with the Okta flow
(access gating is Okta's job, not this app's - roles/authorization stay
local), replaced "create the first admin" with the promote-not-create
bootstrap path (D16) and its no-break-glass posture, replaced the curl-based
login example in Quick Test with a pointer to smoketest.py's own
session-minting technique (there is nothing left to curl - Okta requires a
real browser).

DEPLOYMENT.md: same treatment for its own copies of the env block, the
Portainer var list, the users table's password_hash column, the auth
endpoints summary, the smoke-test walkthrough (WP_SMOKE_USER only, must run
inside the api container or local dev sharing AUTH_SECRET_KEY/DATABASE_URL -
no longer targetable from an arbitrary remote workstation), the entire
"Self-service password reset" section (replaced with "Sign-in and admin
bootstrap (Okta)"), and the project_super_user role description / exclusive-
scope bullet, both of which named "reset passwords" as something that no
longer exists.

Left alone, logged rather than fixed here per CLAUDE.md scope discipline:
- users.failed_attempts / locked_until columns are still in the schema and
  still reset to 0/None on every Okta sign-in, but nothing increments them
  anymore since local login() is gone - vestigial, not documented as active
  lockout behavior in either doc now, but not migrated away either.
- server/README.md's "Production - Docker Compose" section (### 1-5) is a
  self-contained alternate quickstart that already duplicated and diverged
  from the real root docker-compose.yml before this task; it uses env_file
  rather than an explicit allowlist so it isn't broken by this change, but
  it's still a second source of truth nobody asked this task to reconcile.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 13:47:48 -07:00
parent 78d1942a0e
commit f023192b74
4 changed files with 205 additions and 90 deletions

View File

@@ -52,27 +52,44 @@ POSTGRES_DB=wpsuite
POSTGRES_USER=wpsuite POSTGRES_USER=wpsuite
POSTGRES_PASSWORD=<strong-random-password> POSTGRES_PASSWORD=<strong-random-password>
# REQUIRED — signs login session cookies. If unset, `docker compose up` errors # REQUIRED — signs login session cookies, AFTER Okta has confirmed who someone
# out and the API refuses to start. Generate once and keep it stable: # is. If unset, `docker compose up` errors out and the API refuses to start.
# Generate once and keep it stable:
# openssl rand -base64 48 # openssl rand -base64 48
AUTH_SECRET_KEY=<strong-random-secret> AUTH_SECRET_KEY=<strong-random-secret>
# REQUIRED (in spirit — see the note below) — Okta OIDC is the only sign-in
# path (D15/D16). There is no local password anywhere in this app to fall back
# to, so without these nobody can sign in at all. Get them from the Okta app
# integration (sign-in method OIDC - Authorization Code, Web Application):
#
# OKTA_ISSUER the authorization server, e.g.
# https://your-org.okta.com/oauth2/default
# OKTA_CLIENT_ID \_ from the app integration
# OKTA_CLIENT_SECRET /
# OKTA_REDIRECT_URI must exactly match a "Sign-in redirect URI"
# registered on the app integration, e.g.
# https://wp-suite.company.local/api/auth/okta/callback
#
# Full explanation, and the optional OKTA_IDENTITY_CLAIM override, in
# server/.env.example. Not enforced at startup the way AUTH_SECRET_KEY is —
# the API starts without these, it just refuses every sign-in and says so in
# `docker compose logs api` (server/okta_auth.py's describe()).
OKTA_ISSUER=<https://your-org.okta.com/oauth2/default>
OKTA_CLIENT_ID=<from the Okta app integration>
OKTA_CLIENT_SECRET=<from the Okta app integration>
OKTA_REDIRECT_URI=<https://your-hostname/api/auth/okta/callback>
# Encrypts database backups at rest (AES-256). Set this BEFORE the DB holds # 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 # customer IP. Keep the passphrase OFF this host — losing it makes dumps
# unrecoverable: openssl rand -base64 32 # unrecoverable: openssl rand -base64 32
BACKUP_ENC_PASSPHRASE=<strong-random-passphrase> BACKUP_ENC_PASSPHRASE=<strong-random-passphrase>
# OPTIONAL — SMTP password for WP-assignment email + password-reset links. Email # OPTIONAL — SMTP password for WP-assignment email. Email is OFF by default and
# is OFF by default and enabled from the Admin console; the host/port/from-address # enabled from the Admin console; the host/port/from-address are configured
# are configured there, but the password is only ever read from this variable # there, but the password is only ever read from this variable (never stored
# (never stored in the DB or shown in the UI). Leave unset until you have SMTP # in the DB or shown in the UI). Leave unset until you have SMTP details.
# details.
# SMTP_PASSWORD=<smtp-app-password> # SMTP_PASSWORD=<smtp-app-password>
# OPTIONAL — password-reset link lifetime (minutes) and the per-account send
# cooldown (seconds). Defaults shown; both only matter once email is enabled.
# AUTH_RESET_MINUTES=60
# AUTH_RESET_COOLDOWN_SECONDS=120
``` ```
The API builds its own DB connection string from the `POSTGRES_*` The API builds its own DB connection string from the `POSTGRES_*`
@@ -87,6 +104,7 @@ Generate a strong password with `openssl rand -base64 32`.
> **Portainer note:** for a Git-based stack these go in the stack's > **Portainer note:** for a Git-based stack these go in the stack's
> **Environment variables** section (Portainer doesn't read a local `.env`). > **Environment variables** section (Portainer doesn't read a local `.env`).
> Set `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` / `AUTH_SECRET_KEY` / > Set `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` / `AUTH_SECRET_KEY` /
> `OKTA_ISSUER` / `OKTA_CLIENT_ID` / `OKTA_CLIENT_SECRET` / `OKTA_REDIRECT_URI` /
> `BACKUP_ENC_PASSPHRASE` (and `SMTP_PASSWORD`, if you enable email) there. > `BACKUP_ENC_PASSPHRASE` (and `SMTP_PASSWORD`, if you enable email) there.
These are the only credentials in the system, and they never appear in the These are the only credentials in the system, and they never appear in the
@@ -151,25 +169,34 @@ project → SOP → Work Package → the AWP issue gate → status → metrics
archive round trip → cascade cleanup → sign-out). Stdlib only — no pip/jq. 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 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 session — but there is no local password to sign in with (D15/D16), and Okta
history, and the account must be an **admin**: the run creates a project and deletes requires a real browser to complete, which this script cannot do. So instead
it again, and archiving or deleting one takes Project Admin on it. The script checks of an HTTP login, it mints a session directly the same way `okta_callback()`
the signed-in role up front and warns if it is too low rather than letting you find does after Okta hands back an identity, which means **it has to run somewhere
out in the cleanup step. that can read the same `AUTH_SECRET_KEY` and reach the same database as the
server under test** — inside the `api` container, or local dev against your
own DB. It can no longer sign in to an arbitrary remote URL from an unrelated
workstation the way the old password-based version could.
The account named by `WP_SMOKE_USER` must **already exist** — sign it in
through Okta once first, or pre-create it from the User Directory — and must
be an **admin**: the run creates a project and deletes it again, and 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 ```bash
export WP_SMOKE_USER=<admin-account> # From inside the api container — has AUTH_SECRET_KEY and DATABASE_URL, and
export WP_SMOKE_PASSWORD='…' # hits FastAPI directly. This is the normal way to run it in production:
docker compose exec -e WP_SMOKE_USER api \
# 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 python /app/server/smoketest.py http://localhost:8000
# Local dev, against the app you're running yourself:
export AUTH_SECRET_KEY=... DATABASE_URL=... WP_SMOKE_USER=<admin-account>
python3 server/smoketest.py http://localhost:8000
# Add --keep to leave a demo project in the DB so you can open it in the UI. # 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. # --user overrides $WP_SMOKE_USER if you'd rather be explicit.
``` ```
Exit codes: **0** all checks passed · **1** one or more checks failed · **2** the run Exit codes: **0** all checks passed · **1** one or more checks failed · **2** the run
@@ -257,7 +284,7 @@ users on the same project see the same server-stored SOP and Work Packages.
| `sops` | project SOP baselines | `project_id` → projects, `name`, `number`, `complete`, `data` (full SOP JSON) | | `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) | | `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` | | `comments` | feedback from any page | `source`, `sop_id`, `wp_id`, `step`, `author`, `text`, `extra` |
| `users` | login accounts | `username`, `password_hash` (bcrypt), `role`, `full_name`, `email`, `is_active`, `auto_add_projects` + `auto_add_role` (default membership on new projects), login-lockout + `token_version` fields | | `users` | login accounts | `username` (matched against Okta's identity claim — no password column; D15/D16), `role`, `full_name`, `email`, `is_active`, `auto_add_projects` + `auto_add_role` (default membership on new projects), `token_version` |
| `project_members` | per-project access control | `user_id` → users, `project_id` → projects | | `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` | | `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) | | `notifications` | in-app record + email outbox | `user_id`, `kind`, `wp_id`, `subject`, `status` (pending / sent / failed / skipped) |
@@ -275,8 +302,9 @@ 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`, `POST /api/wps/{id}/issue`, `POST /api/wps/{id}/status`, `POST /api/wps/{id}/archive`,
`GET /api/wps/metrics` · `GET /api/wps/metrics` ·
Comments `POST /api/comments` (and `/api/feedback`), `GET /api/comments` · Comments `POST /api/comments` (and `/api/feedback`), `GET /api/comments` ·
Auth `POST /api/auth/login` / `logout`, `GET /api/auth/me`, admin user management Auth `GET /api/auth/okta/login` / `okta/callback` (the Okta sign-in round trip),
under `/api/auth/users` (including `POST /api/auth/users/{id}/auto-add`) · `POST /api/auth/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`, Admin-only `GET/PUT /api/settings`,
`POST /api/settings/test-email`, `GET /api/notifications`, `POST /api/settings/test-email`, `GET /api/notifications`,
`GET /api/projects/{id}/members`. `GET /api/projects/{id}/members`.
@@ -350,27 +378,31 @@ TLS / From address and flips the master toggle.
package contents — so customer IP stays behind the login. package contents — so customer IP stays behind the login.
- Use the card's **Send test email** button to confirm SMTP before enabling. - Use the card's **Send test email** button to confirm SMTP before enabling.
### Self-service password reset ### Sign-in and admin bootstrap (Okta)
Turning email on also enables **Forgot password** on the login page. Until then the There is no local password anywhere in this app — no "Forgot password," no
link explains that an admin must reset it (`server/manage_users.py`, or the Admin reset link, nothing email-related to sign-in (D15/D16). `SMTP_PASSWORD` above
console's **Reset password** button). is purely for WP-assignment notification emails.
- The emailed link carries a short-lived signed token — `AUTH_RESET_MINUTES` Sign-in is entirely Okta's job: `login.html` redirects to Okta, and access
(default 60). It is **single-use**: completing a reset bumps the account's control is **who is assigned to the app integration in Okta** — see step 2's
`token_version`, which both burns the link and signs out that user's other `OKTA_*` variables and [`server/README.md`](server/README.md#sign-in-okta) for
sessions. A completed reset also clears any login lockout. the full flow. The first admin has to sign in through Okta once (landing as an
- `/api/auth/forgot-password` answers **identically for unknown accounts**, so it ordinary `project_user`, auto-provisioned), then get promoted from a shell:
can't be used to discover usernames. Misses are recorded in the audit log
(`password_reset_miss`) instead. ```bash
- One reset mail per account+client per `AUTH_RESET_COOLDOWN_SECONDS` (default 120) docker compose exec api python -m server.manage_users promote alice --role admin
so the form can't be used to flood someone's inbox. The throttle is per worker ```
and in-memory; the token expiry is the real control.
- Reset mails are sent **immediately, not through the notifications outbox** — a This is deliberate, not an oversight: a hand-typed username at account-creation
reset link must never be persisted where an admin could read it and take over an time risks a second, orphaned row if it doesn't exactly match what Okta sends,
account. so the CLI promotes an existing Okta-provisioned row rather than creating one
- Set `app_base_url` in the admin card, or the emailed link will be relative and blind (D16). Every admin after the first can be promoted from the User
therefore useless. Directory page — no shell access needed.
**No break-glass path.** If Okta is unreachable or misconfigured, the app is
unreachable for everyone, including admins, until Okta is restored — the same
posture the abandoned LDAPS design took, carried forward deliberately (D16).
## Permissions roles ## Permissions roles
@@ -382,7 +414,7 @@ which no longer manages accounts.
| Role | May do | | Role | May do |
|---|---| |---|---|
| `admin` | User administration everywhere, app settings, and every project | | `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_super_user` | Everything `project_admin` may do, **plus user administration on the projects they hold the role on**: pre-create accounts by username, set permissions, grant project access |
| `project_admin` | On assigned projects: delete work packages, change a **completed** SOP, delete the project | | `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 | | `project_user` | Create/edit work packages, author a SOP up to completion; may archive a WP but not delete one |
@@ -399,9 +431,9 @@ Its limits are what make it safe to hand out, and all of them are server-side
* **Scope comes from projects, not the job title.** A super user administers the users * **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 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. `ProjectMember.role` for a super user on one job only. No projects, no authority.
* **Account changes need EXCLUSIVE scope.** Resetting a password, disabling, renaming, * **Account changes need EXCLUSIVE scope.** Disabling, renaming, changing
changing permissions or deleting are global acts, so they are refused when the permissions or deleting are global acts, so they are refused when the target
target is also on a project the caller does not administer. The directory shows 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. 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 * **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 `project_admin` / `project_user` only, and may not touch an admin's or another

View File

@@ -27,10 +27,28 @@ services:
POSTGRES_HOST: db POSTGRES_HOST: db
# Optional full-URL override (must be URL-encoded if used). # Optional full-URL override (must be URL-encoded if used).
DATABASE_URL: ${DATABASE_URL:-} DATABASE_URL: ${DATABASE_URL:-}
# Signs login session cookies. REQUIRED — compose fails fast if it's unset, # Signs login session cookies, AFTER Okta has confirmed who someone is.
# and the API refuses to start in production without it (see server/auth.py). # REQUIRED — compose fails fast if it's unset, and the API refuses to
# start in production without it (see server/auth.py).
AUTH_SECRET_KEY: ${AUTH_SECRET_KEY:?set AUTH_SECRET_KEY in .env (see server/.env.example)} AUTH_SECRET_KEY: ${AUTH_SECRET_KEY:?set AUTH_SECRET_KEY in .env (see server/.env.example)}
AUTH_SESSION_HOURS: ${AUTH_SESSION_HOURS:-12} AUTH_SESSION_HOURS: ${AUTH_SESSION_HOURS:-12}
# Okta OIDC — the only sign-in path (D15/D16). Not marked required the
# way AUTH_SECRET_KEY is: the API starts without these, it just refuses
# every sign-in and says so in the startup log (server/okta_auth.py
# describe()). See server/.env.example for what each one is and how to
# get it from the Okta app integration.
OKTA_ISSUER: ${OKTA_ISSUER:-}
OKTA_CLIENT_ID: ${OKTA_CLIENT_ID:-}
OKTA_CLIENT_SECRET: ${OKTA_CLIENT_SECRET:-}
OKTA_REDIRECT_URI: ${OKTA_REDIRECT_URI:-}
# NOT ${OKTA_IDENTITY_CLAIM:-} — server/okta_auth.py's own default only
# applies when the env var is UNSET, and compose setting it to an empty
# string here is not the same thing as leaving it unset. An empty value
# would make the API look for a claim literally named "", which is
# never present, so EVERY sign-in would 503. Mirror the same default
# here instead, so an operator who leaves .env's copy commented out gets
# the real default, not a broken one.
OKTA_IDENTITY_CLAIM: ${OKTA_IDENTITY_CLAIM:-preferred_username}
# Optional — SMTP password for WP-assignment emails. Email is off by # Optional — SMTP password for WP-assignment emails. Email is off by
# default and enabled from the Admin console; this is the only email # default and enabled from the Admin console; this is the only email
# secret and it is never stored in the DB. Leave unset until configured. # secret and it is never stored in the DB. Leave unset until configured.

View File

@@ -12,15 +12,49 @@ DATABASE_URL=postgresql+psycopg://wpsuite:CHANGE_ME@localhost:5432/wpsuite
# CORS_ORIGINS=http://localhost:5500 # CORS_ORIGINS=http://localhost:5500
# ── Authentication ──────────────────────────────────────────────────────────── # ── Authentication ────────────────────────────────────────────────────────────
# Secret used to sign session cookies (JWTs). REQUIRED in production: if unset, # There is no local password (D15/D16) — Okta OIDC is the only way in. Sign-in
# the API falls back to a random per-process key, so logins reset on every # still ends the same way it always did: a signed JWT in an HttpOnly session
# restart and break across multiple gunicorn workers. Generate a strong one: # cookie, which is what the four vars right below this line are for. The five
# OKTA_* vars after that are what makes the actual sign-in possible; without
# them the API starts (this is not a hard failure like AUTH_SECRET_KEY), but
# describe()'s startup log line says so and nobody can sign in.
# Secret used to sign session cookies (JWTs), AFTER Okta has confirmed who
# someone is — this app still decides roles/authorization locally, unchanged
# by Okta (see server/okta_auth.py). REQUIRED in production: if unset, the API
# falls back to a random per-process key, so logins reset on every restart and
# break across multiple gunicorn workers. Generate a strong one:
# python -c "import secrets; print(secrets.token_urlsafe(48))" # python -c "import secrets; print(secrets.token_urlsafe(48))"
AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
# How long a login lasts before re-authentication (hours). Default 12. # How long a login lasts before re-authentication (hours). Default 12.
# AUTH_SESSION_HOURS=12 # AUTH_SESSION_HOURS=12
# ── Okta OIDC (required — this is the only sign-in path) ───────────────────────
# The Okta *authorization server* issuer, e.g. https://yourorg.okta.com/oauth2/default
# or a custom authorization server URL. The API discovers the authorize/token/
# jwks endpoints from <OKTA_ISSUER>/.well-known/openid-configuration — nothing
# else about Okta's endpoints is hand-entered.
OKTA_ISSUER=https://your-org.okta.com/oauth2/default
# Client ID and secret from the Okta app integration (Sign-in method: OIDC -
# Authorization Code, Application type: Web Application). The secret is exactly
# that — treat it like AUTH_SECRET_KEY, never commit it.
OKTA_CLIENT_ID=CHANGE_ME
OKTA_CLIENT_SECRET=CHANGE_ME
# Must exactly match a "Sign-in redirect URI" registered on the Okta app
# integration, scheme and path included, e.g.:
# https://wp-suite.company.local/api/auth/okta/callback
OKTA_REDIRECT_URI=CHANGE_ME
# Which ID token claim carries this person's directory identity, matched
# against the local users.username column (server/app.py's okta_callback()).
# preferred_username is Okta's usual default for an AD-imported user; override
# it if your security team's Okta configuration uses a different claim (upn,
# a custom claim, …) — no code change needed, just this value.
# OKTA_IDENTITY_CLAIM=preferred_username
# ── Email notifications (optional) ───────────────────────────────────────────── # ── Email notifications (optional) ─────────────────────────────────────────────
# WP-assignment emails are OFF by default and are turned on from the Admin # WP-assignment emails are OFF by default and are turned on from the Admin
# console (Notifications & email card), where the SMTP host/port/from-address # console (Notifications & email card), where the SMTP host/port/from-address

View File

@@ -14,12 +14,12 @@ browser → NGINX ──serves──> static site (index.html, …)
| Method | Path | Purpose | | Method | Path | Purpose |
|--------|------|---------| |--------|------|---------|
| GET | `/api/health` | liveness check (unauthenticated) | | GET | `/api/health` | liveness check (unauthenticated) |
| POST | `/api/auth/login` | sign in (`{username, password}`) — sets the session cookie | | GET | `/api/auth/okta/login` | redirects the browser to Okta's authorize endpoint (`?next=` optional) |
| GET | `/api/auth/okta/callback` | Okta redirects back here with the auth code; signs the person in |
| POST | `/api/auth/logout` | clear the session cookie | | POST | `/api/auth/logout` | clear the session cookie |
| GET | `/api/auth/me` | the logged-in user | | GET | `/api/auth/me` | the logged-in user |
| POST | `/api/auth/password` | change your own password |
| GET | `/api/auth/users` | list accounts (**admin**) | | GET | `/api/auth/users` | list accounts (**admin**) |
| POST | `/api/auth/users` | create an account (**admin**) | | POST | `/api/auth/users` | pre-create an account by username (**admin**) |
| DELETE | `/api/auth/users/{id}` | delete an account (**admin**) | | DELETE | `/api/auth/users/{id}` | delete an account (**admin**) |
| POST | `/api/sops` | create/update a SOP (upsert by `id`) | | POST | `/api/sops` | create/update a SOP (upsert by `id`) |
| GET | `/api/sops` | list SOP summaries | | GET | `/api/sops` | list SOP summaries |
@@ -40,50 +40,81 @@ fields (name, number, status, …) are promoted to columns for listing/filtering
--- ---
## Login portal (user accounts) ## Sign-in (Okta)
The suite is gated by a username/password login. Sign-in issues a signed JWT There is no local password anywhere in this app (D15/D16) — Okta OIDC is the
that rides in an **HttpOnly, SameSite=Lax** cookie (`wp_session`); the cookie is only way in. `login.html` is a single "Sign in with Okta" button; the actual
marked **Secure** automatically whenever the request arrives over HTTPS (via exchange is `server/okta_auth.py` (the Okta client config) and the two routes
NGINX's `X-Forwarded-Proto`). There is no server-side session store — each in `server/app.py`: `okta_login()` sends the browser to Okta's authorize
request is validated by checking the cookie's signature and expiry. endpoint, `okta_callback()` exchanges the code, matches the ID token's identity
claim against `users.username`, and signs the person in.
Sign-in still ends the same way it always did: a signed JWT in an **HttpOnly,
SameSite=Lax** cookie (`wp_session`), 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. Okta only confirms *who* someone is; this app still
decides *what* they may do — roles, project membership, everything below stays
local and unchanged by Okta.
**The real security boundary is the API:** every `/api/` data route is refused **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 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 `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. to `login.html` when there's no session — that's for UX, not protection.
Passwords are stored only as **bcrypt** hashes (`server/auth.py`). Roles are **Access gating is Okta's job, not this app's.** Only accounts assigned to the
`admin` (may manage users) and `user`. app integration in Okta can complete the sign-in flow at all, so there is no
required-group or claim check layered on top here. Once Okta lets someone
through, this app decides their role — see below.
### Set the signing secret Roles are `admin`, `project_super_user`, `project_admin`, `project_user`
(`html/users.js`, enforced server-side).
Add `AUTH_SECRET_KEY` to `.env` (see `.env.example`). **Required in production** ### Set the signing secret and the Okta app integration
without it the API uses a random per-process key, so logins reset on restart.
Add `AUTH_SECRET_KEY` and the five `OKTA_*` variables to `.env` — see
`.env.example` for what each one is and where it comes from. `AUTH_SECRET_KEY`
is **required in production**: without it the API uses a random per-process
key, so logins reset on restart. The `OKTA_*` variables are not a hard-fail the
same way — the API starts without them, it just refuses every sign-in and says
so in the startup log (`okta_auth.describe()`).
```bash ```bash
python -c "import secrets; print(secrets.token_urlsafe(48))" python -c "import secrets; print(secrets.token_urlsafe(48))"
``` ```
The Okta app integration itself (sign-in method OIDC, Application type Web
Application) needs its **Sign-in redirect URI** set to exactly
`OKTA_REDIRECT_URI`'s value, and the people who should have access assigned to
it — that assignment IS the access control (see above).
### Create the first admin ### Create the first admin
The `/api/auth/users` endpoint needs an existing admin, so bootstrap one from a There's no `create-admin` command anymore — creating an account from scratch
shell (run from the **project root**, like uvicorn): by hand-typed username risks a second, orphaned row if it doesn't exactly match
what Okta actually sends (see `OKTA_IDENTITY_CLAIM` in `.env.example`). Instead,
have the first admin **sign in through Okta once** — they land as an ordinary
`project_user`, JIT-provisioned — then promote that existing row from a shell
(run from the **project root**, like uvicorn):
```bash ```bash
python -m server.manage_users create-admin alice --name "Alice Smith" python -m server.manage_users promote alice --role admin
# prompts for a password (min 8 chars)
``` ```
In Docker: In Docker:
```bash ```bash
docker compose exec api python -m server.manage_users create-admin alice --name "Alice Smith" docker compose exec api python -m server.manage_users promote alice --role admin
``` ```
Other commands: `create <user> --role user`, `list`, `reset-password <user>`, Other commands: `list`, `disable <user>`, `enable <user>`. After the first
`disable <user>`, `enable <user>`. After that, admins can add users through the admin exists, they can promote others through the User Directory page (or keep
API (or you can keep using the CLI). using the CLI) — no shell access needed for anyone after the first.
**No break-glass path.** If Okta is unreachable or misconfigured, the app is
unreachable for everyone, including admins, until Okta is restored (D16) — this
is a deliberate choice, the same one the abandoned LDAPS design made, not an
oversight.
--- ---
@@ -291,25 +322,25 @@ docker compose down -v
## Quick test ## Quick test
`/api/health` is open; data routes now require a session, so log in first and `/api/health` is open; every other `/api/` route needs a session cookie:
reuse the cookie jar:
```bash ```bash
curl http://127.0.0.1:8000/api/health # {"ok":true} — no auth needed 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"}`. Without a session cookie, protected routes return `401 {"detail":"Not authenticated"}`.
Or via the nginx proxy (replace with your hostname): Or via the nginx proxy (replace with your hostname):
```bash ```bash
curl https://wp-suite.company.local/api/health curl https://wp-suite.company.local/api/health
``` ```
There's no `curl`-able login anymore — Okta requires a real browser to
complete, which is what `login.html`'s "Sign in with Okta" button is for. To
exercise a protected route from a script instead, use `server/smoketest.py`'s
own technique (mint a session with `auth.create_token()` and set it as the
`wp_session` cookie, the same thing `okta_callback()` does after Okta hands
back an identity) rather than reaching for curl by hand — see that script's
own AUTHENTICATION section for the exact steps, and why it has to run
somewhere that shares the target server's `AUTH_SECRET_KEY` and database.