diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 5d4ae93..72db5ba 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -52,27 +52,44 @@ POSTGRES_DB=wpsuite POSTGRES_USER=wpsuite POSTGRES_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: +# REQUIRED — signs login session cookies, AFTER Okta has confirmed who someone +# is. 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= +# 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= +OKTA_CLIENT_ID= +OKTA_CLIENT_SECRET= +OKTA_REDIRECT_URI= + # 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= -# 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. +# OPTIONAL — SMTP password for WP-assignment email. 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= - -# 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_*` @@ -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 > **Environment variables** section (Portainer doesn't read a local `.env`). > 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. 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. 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. +session — but there is no local password to sign in with (D15/D16), and Okta +requires a real browser to complete, which this script cannot do. So instead +of an HTTP login, it mints a session directly the same way `okta_callback()` +does after Okta hands back an identity, which means **it has to run somewhere +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 -export WP_SMOKE_USER= -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 \ +# From inside the api container — has AUTH_SECRET_KEY and DATABASE_URL, and +# hits FastAPI directly. This is the normal way to run it in production: +docker compose exec -e WP_SMOKE_USER api \ 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= +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. -# --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 @@ -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) | | `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 | `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 | | `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) | @@ -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`, `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`) · +Auth `GET /api/auth/okta/login` / `okta/callback` (the Okta sign-in round trip), +`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`, `POST /api/settings/test-email`, `GET /api/notifications`, `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. - 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 -link explains that an admin must reset it (`server/manage_users.py`, or the Admin -console's **Reset password** button). +There is no local password anywhere in this app — no "Forgot password," no +reset link, nothing email-related to sign-in (D15/D16). `SMTP_PASSWORD` above +is purely for WP-assignment notification emails. -- The emailed link carries a short-lived signed token — `AUTH_RESET_MINUTES` - (default 60). It is **single-use**: completing a reset bumps the account's - `token_version`, which both burns the link and signs out that user's other - sessions. A completed reset also clears any login lockout. -- `/api/auth/forgot-password` answers **identically for unknown accounts**, so it - can't be used to discover usernames. Misses are recorded in the audit log - (`password_reset_miss`) instead. -- One reset mail per account+client per `AUTH_RESET_COOLDOWN_SECONDS` (default 120) - 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 - reset link must never be persisted where an admin could read it and take over an - account. -- Set `app_base_url` in the admin card, or the emailed link will be relative and - therefore useless. +Sign-in is entirely Okta's job: `login.html` redirects to Okta, and access +control is **who is assigned to the app integration in Okta** — see step 2's +`OKTA_*` variables and [`server/README.md`](server/README.md#sign-in-okta) for +the full flow. The first admin has to sign in through Okta once (landing as an +ordinary `project_user`, auto-provisioned), then get promoted from a shell: + +```bash +docker compose exec api python -m server.manage_users promote alice --role admin +``` + +This is deliberate, not an oversight: a hand-typed username at account-creation +time risks a second, orphaned row if it doesn't exactly match what Okta sends, +so the CLI promotes an existing Okta-provisioned row rather than creating one +blind (D16). Every admin after the first can be promoted from the User +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 @@ -382,7 +414,7 @@ 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_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_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 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 +* **Account changes need EXCLUSIVE scope.** 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 diff --git a/docker-compose.yml b/docker-compose.yml index d4fd4f2..ac06ed1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,10 +27,28 @@ services: POSTGRES_HOST: db # Optional full-URL override (must be URL-encoded if used). DATABASE_URL: ${DATABASE_URL:-} - # Signs login session cookies. REQUIRED — compose fails fast if it's unset, - # and the API refuses to start in production without it (see server/auth.py). + # Signs login session cookies, AFTER Okta has confirmed who someone is. + # 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_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 # 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. diff --git a/server/.env.example b/server/.env.example index 6087901..ddbd99d 100644 --- a/server/.env.example +++ b/server/.env.example @@ -12,15 +12,49 @@ DATABASE_URL=postgresql+psycopg://wpsuite:CHANGE_ME@localhost:5432/wpsuite # CORS_ORIGINS=http://localhost:5500 # ── Authentication ──────────────────────────────────────────────────────────── -# Secret used to sign session cookies (JWTs). 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: +# There is no local password (D15/D16) — Okta OIDC is the only way in. Sign-in +# still ends the same way it always did: a signed JWT in an HttpOnly session +# 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))" AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above # How long a login lasts before re-authentication (hours). Default 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 /.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) ───────────────────────────────────────────── # 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 diff --git a/server/README.md b/server/README.md index fbc988b..62438e9 100644 --- a/server/README.md +++ b/server/README.md @@ -14,12 +14,12 @@ browser → NGINX ──serves──> static site (index.html, …) | Method | Path | Purpose | |--------|------|---------| | 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 | | GET | `/api/auth/me` | the logged-in user | -| POST | `/api/auth/password` | change your own password | | 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**) | | POST | `/api/sops` | create/update a SOP (upsert by `id`) | | 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 -that rides in an **HttpOnly, SameSite=Lax** cookie (`wp_session`); the cookie is -marked **Secure** automatically whenever the request arrives over HTTPS (via -NGINX's `X-Forwarded-Proto`). There is no server-side session store — each -request is validated by checking the cookie's signature and expiry. +There is no local password anywhere in this app (D15/D16) — Okta OIDC is the +only way in. `login.html` is a single "Sign in with Okta" button; the actual +exchange is `server/okta_auth.py` (the Okta client config) and the two routes +in `server/app.py`: `okta_login()` sends the browser to Okta's authorize +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 with `401` unless a valid session cookie is present (see `auth_gate` in `app.py`). The static pages additionally include `auth-guard.js`, which redirects to `login.html` when there's no session — that's for UX, not protection. -Passwords are stored only as **bcrypt** hashes (`server/auth.py`). Roles are -`admin` (may manage users) and `user`. +**Access gating is Okta's job, not this app's.** Only accounts assigned to the +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** — -without it the API uses a random per-process key, so logins reset on restart. +### Set the signing secret and the Okta app integration + +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 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 -The `/api/auth/users` endpoint needs an existing admin, so bootstrap one from a -shell (run from the **project root**, like uvicorn): +There's no `create-admin` command anymore — creating an account from scratch +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 -python -m server.manage_users create-admin alice --name "Alice Smith" -# prompts for a password (min 8 chars) +python -m server.manage_users promote alice --role admin ``` In Docker: ```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 --role user`, `list`, `reset-password `, -`disable `, `enable `. After that, admins can add users through the -API (or you can keep using the CLI). +Other commands: `list`, `disable `, `enable `. After the first +admin exists, they can promote others through the User Directory page (or keep +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 -`/api/health` is open; data routes now require a session, so log in first and -reuse the cookie jar: +`/api/health` is open; every other `/api/` route needs a session cookie: ```bash 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":""}' - -# 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): ```bash 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.