Compare commits
25 Commits
fix/materi
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| df20b8f18d | |||
| cc64c88c3e | |||
| dc13f9b0e3 | |||
| d6eae0d846 | |||
| d7d1e93dd8 | |||
| f023192b74 | |||
| 78d1942a0e | |||
| 45aff9c423 | |||
| 290c9b078c | |||
| 72b10283fc | |||
| 77f8f9f800 | |||
| 73da684b99 | |||
| c74289aa0d | |||
| 7ed3cbec4c | |||
| a9e5ee3892 | |||
| 0ee35ae4ed | |||
| 044862acba | |||
| 31c548318b | |||
| 8f117680b0 | |||
| 6034c08bad | |||
| 17cabbd032 | |||
| 222c0b1c29 | |||
| e31234beef | |||
| 6057d05b98 | |||
| 64eac0cbbb |
355
DEPLOY-runbook-2026-09-03.md
Normal file
@@ -0,0 +1,355 @@
|
||||
# Deploy runbook: WP Suite Okta cutover (wave 10)
|
||||
|
||||
**For:** IT / whoever administers the Docker host and Portainer
|
||||
**From:** m.mabrey@prime-controls.com
|
||||
**Revised:** 2026-09-03. First version of this runbook.
|
||||
**Expected duration:** 20-30 minutes, including the backup and the live sign-in check
|
||||
**Expected downtime:** under a minute, while containers are recreated
|
||||
|
||||
This is a separate runbook from `DEPLOY-runbook-2026-08-04.md`, not a revision of it.
|
||||
That one still applies to its own deploy. Read this one in full before starting.
|
||||
It has a step that the earlier one does not: this deploy removes the local password
|
||||
system entirely, and one part of that removal cannot be undone by the usual means.
|
||||
See "What this deploy changes" below.
|
||||
|
||||
---
|
||||
|
||||
## Fill these in before handing this over
|
||||
|
||||
| Thing | Value |
|
||||
|---|---|
|
||||
| Docker host (SSH target) | `________________` |
|
||||
| Stack name in Portainer | `________________` |
|
||||
| Site URL | `https://________________` |
|
||||
| Stack directory on the host (holds `docker-compose.yml` / `backups/`) | `________________` |
|
||||
| A real Okta account assigned to the app integration, for the live check in Step 4 | `________________` |
|
||||
|
||||
Container names are fixed by the compose file and are the same on every host:
|
||||
`nginx_webserver`, `wp_api`, `wp_db`, `wp_db_backup`.
|
||||
|
||||
---
|
||||
|
||||
## What this deploy changes
|
||||
|
||||
Sign-in changes from a local username/password to Okta OIDC, completely. Not a
|
||||
toggle, not a fallback. Three things make this more than a routine deploy:
|
||||
|
||||
1. **Five new environment variables are required**, and without them nobody can
|
||||
sign in: `OKTA_ISSUER`, `OKTA_CLIENT_ID`, `OKTA_CLIENT_SECRET`,
|
||||
`OKTA_REDIRECT_URI`, and optionally `OKTA_IDENTITY_CLAIM`. This is a genuine
|
||||
change from how deploys usually go here. Do not skip Step 1.5.
|
||||
|
||||
2. **A migration drops the `password_hash` column** (`1d60a608bb51`), and it is
|
||||
**one-way in practice.** Its `downgrade()` re-adds the column, but empty.
|
||||
The real password hashes are gone the moment this commits, and no `alembic
|
||||
downgrade` brings them back. If this deploy needs to be undone after that
|
||||
point, going back to the old local-password code does not work on its own.
|
||||
See the Rollback section, Case C. This is why Step 1's backup is not
|
||||
optional the way it sometimes reads in other runbooks.
|
||||
|
||||
3. **There is no break-glass path**, by design (recorded decision D16). If Okta
|
||||
is unreachable or misconfigured after this deploy, the app is unreachable for
|
||||
everyone, admins included, until Okta is fixed. That is expected behavior,
|
||||
not a bug to roll back from. See Rollback, Case A/B, before assuming
|
||||
something is broken.
|
||||
|
||||
The visible change for people using the app: the login page becomes "Sign in
|
||||
with Okta" instead of a username/password form. Nothing else in the app's
|
||||
day-to-day behavior changes.
|
||||
|
||||
---
|
||||
|
||||
## Step 0: Record the current state (needed for rollback)
|
||||
|
||||
SSH to the Docker host and run:
|
||||
|
||||
```bash
|
||||
docker exec wp_api alembic -c server/alembic.ini current
|
||||
docker inspect nginx_webserver --format 'nginx image: {{.Image}}'
|
||||
docker inspect wp_api --format 'api image: {{.Image}}'
|
||||
```
|
||||
|
||||
**Copy the output into your ticket.** Also note the Git commit the Portainer
|
||||
stack is currently on (Portainer, the stack, the Git reference / last-updated
|
||||
commit). Without these, rollback is guesswork.
|
||||
|
||||
Expected output of the first command before this deploy: `a1b8c6d4e2f9 (head)`.
|
||||
If it shows anything else, stop and check with me before continuing. This
|
||||
runbook assumes that starting point.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Back up the database
|
||||
|
||||
On the Docker host:
|
||||
|
||||
```bash
|
||||
docker exec wp_db_backup /scripts/db-backup.sh
|
||||
```
|
||||
|
||||
Expected output ends with a line like:
|
||||
|
||||
```
|
||||
[db-backup] wrote 1.4M /backups/wpsuite-20260903-141233Z.sql.gz.enc
|
||||
```
|
||||
|
||||
Confirm the file is on the host (substitute the stack directory):
|
||||
|
||||
```bash
|
||||
ls -lt <stack-dir>/backups | head -3
|
||||
```
|
||||
|
||||
**Record that filename.** Do not continue until you have seen the `wrote ...`
|
||||
line and the file in that listing.
|
||||
|
||||
This backup matters more than usual for this deploy. Once the migration in
|
||||
Step 3 commits, this file becomes the *only* way to get local password hashes
|
||||
back, for any reason. Treat it as the point you would restore to, not routine
|
||||
housekeeping.
|
||||
|
||||
- A `.sql.gz.enc` extension means backups are encrypted. Expected and correct.
|
||||
- A `.sql.gz` extension plus a `WARNING: BACKUP_ENC_PASSPHRASE not set` line
|
||||
means backups are unencrypted. Not a blocker for this deploy; report it back.
|
||||
- **No SSH access?** Portainer, **Containers**, `wp_db_backup`, **Console**,
|
||||
connect with `/bin/sh`, then run `/scripts/db-backup.sh`. Same result: the
|
||||
dump lands on the host, because `/backups` is a bind mount.
|
||||
|
||||
---
|
||||
|
||||
## Step 1.5: Confirm the Okta app integration is actually ready
|
||||
|
||||
Do this before redeploying, not after. Everything here is checked in Okta's own
|
||||
admin console and in the values that will go into the stack's environment
|
||||
variables. Nothing touches the WP Suite host yet.
|
||||
|
||||
1. The Okta app integration exists (Sign-in method: OIDC, Authorization Code,
|
||||
Application type: Web Application), and the account listed in the fill-in
|
||||
table above is assigned to it.
|
||||
2. `OKTA_REDIRECT_URI` matches a "Sign-in redirect URI" registered on that app
|
||||
integration **exactly**: scheme, host, and path, including whether it ends
|
||||
in `/api/auth/okta/callback`.
|
||||
3. `OKTA_ISSUER`, `OKTA_CLIENT_ID`, and `OKTA_CLIENT_SECRET` are the values from
|
||||
that same app integration, not a different one.
|
||||
4. If your Okta configuration puts the directory identity somewhere other than
|
||||
the `preferred_username` claim, `OKTA_IDENTITY_CLAIM` is set to the right
|
||||
claim name. If unsure, leave it unset; `preferred_username` is the default.
|
||||
|
||||
Add all five to the stack's **Environment variables** in Portainer now, before
|
||||
Step 2. `OKTA_CLIENT_SECRET` should be handled the same way `AUTH_SECRET_KEY`
|
||||
already is: not typed anywhere it will be logged.
|
||||
|
||||
If any of items 1-3 above are not yet confirmed, stop here and get them
|
||||
confirmed first. A wrong redirect URI or an unassigned account will not corrupt
|
||||
anything, but it does mean nobody signs in after this deploy until it is fixed.
|
||||
See Rollback, Case B, which is the ordinary way that gets fixed and does not
|
||||
involve the database at all.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Redeploy the stack in Portainer
|
||||
|
||||
1. Portainer, **Stacks**, select the stack.
|
||||
2. **Pull and redeploy**, with re-pull / re-build **enabled**.
|
||||
3. Wait for it to report success.
|
||||
|
||||
A plain "restart" or "stop/start" will not pick up new code, and will not pick
|
||||
up the environment variables added in Step 1.5 either.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Confirm the containers came up
|
||||
|
||||
```bash
|
||||
docker ps --filter name=nginx_webserver --filter name=wp_api --filter name=wp_db
|
||||
```
|
||||
|
||||
All three must be `Up`, and `wp_db` should show `(healthy)`. Then check the API
|
||||
applied its migration cleanly:
|
||||
|
||||
```bash
|
||||
docker logs wp_api --tail 40
|
||||
```
|
||||
|
||||
You are looking for an Alembic `Running upgrade a1b8c6d4e2f9 -> 1d60a608bb51`
|
||||
line followed by gunicorn starting up, and no traceback. The API refuses to
|
||||
start if a migration fails, so a restarting `wp_api` container means it failed.
|
||||
Go to Rollback, Case B, and read the "did the migration commit" note there
|
||||
before doing anything to the database.
|
||||
|
||||
Confirm the database landed on the new revision:
|
||||
|
||||
```bash
|
||||
docker exec wp_api alembic -c server/alembic.ini current
|
||||
```
|
||||
|
||||
Expected: `1d60a608bb51 (head)`. **Once you see this, you have passed the
|
||||
point of no return described above.** The backup from Step 1 is now the only
|
||||
way back to a working local-password system, if that is ever needed.
|
||||
|
||||
Then verify nginx's own view of its config:
|
||||
|
||||
```bash
|
||||
docker exec nginx_webserver nginx -t
|
||||
```
|
||||
|
||||
Expected: `syntax is ok` / `test is successful`.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Confirm Okta sign-in actually works, live
|
||||
|
||||
This is the step that matters most for this deploy. A clean container start
|
||||
does not by itself prove sign-in works, and there is currently no startup log
|
||||
line that confirms Okta config is good (logged separately as a follow-up, not
|
||||
fixed as part of this runbook). The only real proof is a live sign-in.
|
||||
|
||||
1. Open the site's normal URL in a private/incognito window. It should land on
|
||||
`login.html` with a "Sign in with Okta" button, not a username/password
|
||||
form.
|
||||
2. Click it. You should be redirected to your organization's actual Okta
|
||||
sign-in page (the real Okta domain from `OKTA_ISSUER`, not this app's own
|
||||
domain).
|
||||
3. Sign in with the account from the fill-in table. You should land back on
|
||||
the WP Suite site, signed in.
|
||||
4. If this is the account's first-ever sign-in, it is now JIT-provisioned as a
|
||||
regular user (`project_user`). To make it an admin, on the Docker host:
|
||||
|
||||
```bash
|
||||
docker exec -it wp_api python -m server.manage_users promote <username> --role admin
|
||||
```
|
||||
|
||||
This only works on an account that has already signed in once through Okta.
|
||||
It promotes an existing row; it does not create one. That is deliberate
|
||||
(recorded decision D16): there is no other admin-bootstrap path.
|
||||
|
||||
If step 2 or 3 fails (redirected to an Okta error page, redirected back to
|
||||
`login.html` with an error, or nothing happens), this is almost always a
|
||||
configuration problem from Step 1.5, not a code or database problem. Go to
|
||||
Rollback, Case B, before considering anything more drastic.
|
||||
|
||||
Also confirm the API is reachable through the proxy and the redirect itself is
|
||||
wired up:
|
||||
|
||||
```bash
|
||||
curl -s https://<site-url>/api/health # -> {"ok": true}
|
||||
curl -sI https://<site-url>/api/auth/okta/login | grep -i ^location # -> your Okta authorize URL
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Hard-reload once in a browser, then sanity-check
|
||||
|
||||
Press **Ctrl+Shift+R** (Cmd+Shift+R on macOS) once. The app uses a service
|
||||
worker; a normal reload can serve the previous version.
|
||||
|
||||
1. Signed in as the account from Step 4, the home page offers to select or
|
||||
create a project, same as before.
|
||||
2. Open **Admin Console** as the promoted admin account. The user table shows
|
||||
the account you just signed in with. There is no password column, no
|
||||
"reset password" action anywhere in the UI.
|
||||
3. Open a project and confirm a work package can be opened and edited
|
||||
normally. Sign-in is the only thing this deploy changes, so the rest of
|
||||
the app should look untouched.
|
||||
|
||||
**Deploy complete.** Please report back: the Step 0 output, the backup
|
||||
filename from Step 1, and confirmation that Step 4's live sign-in worked.
|
||||
|
||||
---
|
||||
|
||||
## Rollback
|
||||
|
||||
Read this before assuming a rollback is needed. Cases A and B below do **not**
|
||||
touch the database and are the far more likely outcome of something going
|
||||
wrong here. Okta configuration is fiddly and easy to get slightly wrong. Case
|
||||
C is the severe, destructive one, and should be a last resort, not a first
|
||||
reaction.
|
||||
|
||||
### Case A: nginx won't start, or containers won't come up at all
|
||||
|
||||
Same as any other deploy: the database is untouched by container start-up
|
||||
failures. In Portainer, redeploy the stack pinned to the **previous Git
|
||||
commit** recorded in Step 0, then re-run Step 3.
|
||||
|
||||
```bash
|
||||
docker logs nginx_webserver --tail 100
|
||||
docker logs wp_api --tail 100
|
||||
```
|
||||
|
||||
Send me whichever of those is relevant.
|
||||
|
||||
### Case B: containers are up, but Okta sign-in doesn't work
|
||||
|
||||
This is a configuration problem, not a data problem, and does **not** need a
|
||||
code rollback or a database restore. Check, in order:
|
||||
|
||||
1. Is `wp_api`'s log showing anything at all when a sign-in is attempted?
|
||||
`docker logs wp_api --tail 100`.
|
||||
2. Do the five `OKTA_*` values in the stack's environment variables actually
|
||||
match the Okta app integration (Step 1.5)? A copy-paste error in
|
||||
`OKTA_CLIENT_SECRET` or a redirect URI that's off by a trailing slash are
|
||||
the two most common causes.
|
||||
3. Is the account assigned to the Okta app integration? An unassigned account
|
||||
gets denied by Okta itself, before it ever reaches this app.
|
||||
4. If a specific person can't sign in but others can, check
|
||||
`OKTA_IDENTITY_CLAIM`. The claim it reads may not carry that person's
|
||||
directory identity in the format expected. Confirm with security which
|
||||
claim Okta is actually issuing.
|
||||
|
||||
Fix the environment variable(s) in Portainer, then redeploy (Pull and redeploy
|
||||
is fine; the migration already applied and does not run again). No backup
|
||||
restore, no code rollback.
|
||||
|
||||
If it's still not working after checking all four, send me the `wp_api` log
|
||||
from item 1 along with which of items 2-4 you already ruled out.
|
||||
|
||||
### Case C: the decision is made to abandon Okta and restore local-password sign-in
|
||||
|
||||
This is the case the point-of-no-return warning in "What this deploy changes"
|
||||
is about. Only reach for this if Case A and B do not apply. That is, Okta
|
||||
itself is working correctly but a decision has been made to go back to the old
|
||||
system entirely.
|
||||
|
||||
**This cannot be done with a code rollback alone.** The old code expects a
|
||||
real `password_hash` on every user row. After Step 3 commits, that column is
|
||||
either gone or (if `alembic downgrade` is run) present but empty. Either way,
|
||||
nobody's stored password survived, including admins'. The only way to get a
|
||||
working local-password system back is to restore the full database from the
|
||||
Step 1 backup, which also rolls back every other change made since that
|
||||
backup: new work packages, comments, uploaded files, everything.
|
||||
|
||||
**Do not do this without confirming with me first.** If it is confirmed, for
|
||||
an encrypted dump, on the Docker host, in the `backups` directory:
|
||||
|
||||
```bash
|
||||
export BACKUP_ENC_PASSPHRASE='<the passphrase, from the stack env vars>'
|
||||
openssl enc -d -aes-256-cbc -pbkdf2 -pass env:BACKUP_ENC_PASSPHRASE \
|
||||
-in wpsuite-<timestamp>.sql.gz.enc \
|
||||
| gunzip \
|
||||
| docker exec -i wp_db psql -U wpsuite -d wpsuite
|
||||
unset BACKUP_ENC_PASSPHRASE
|
||||
```
|
||||
|
||||
For an unencrypted dump, drop the `openssl` stage and pipe `gunzip` straight
|
||||
into `psql`. Substitute the real values if `POSTGRES_USER` / `POSTGRES_DB` are
|
||||
not `wpsuite`. After restoring, redeploy pinned to the Git commit recorded in
|
||||
Step 0, since the restored database matches the old schema, not this one.
|
||||
|
||||
Reach me at m.mabrey@prime-controls.com.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Do not run `docker compose down -v`. The `-v` flag deletes the `pgdata`
|
||||
volume and with it the entire database.
|
||||
- `docker exec <container-name>` is used throughout rather than
|
||||
`docker compose ...`, because a Portainer-managed Git stack's compose project
|
||||
lives under Portainer's own data directory and usually isn't reachable from
|
||||
an ad hoc SSH session the same way.
|
||||
- There is currently no startup log line confirming Okta config is valid
|
||||
(`okta_auth.describe()` exists but nothing calls it yet, logged as a
|
||||
follow-up). Step 4's live sign-in is the real verification until that's
|
||||
wired in.
|
||||
- Full background documentation: `DEPLOYMENT.md` and `server/README.md` in the
|
||||
repository. `docs/waves/decisions-2026-09-03.md` (D16, D17) records why
|
||||
there is no break-glass path and why this runbook exists as its own document.
|
||||
136
DEPLOYMENT.md
@@ -52,27 +52,44 @@ 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:
|
||||
# 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=<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
|
||||
# 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.
|
||||
# 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=<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_*`
|
||||
@@ -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=<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 \
|
||||
# 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=<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.
|
||||
# --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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
Before Width: | Height: | Size: 296 KiB After Width: | Height: | Size: 382 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 315 KiB |
|
Before Width: | Height: | Size: 340 KiB After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 299 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 98 KiB |
@@ -292,7 +292,7 @@ Wave 8 adds these:
|
||||
```bash
|
||||
python tests/kitting_check.py # CR-009/010/012 - statuses, owner, delivery 26 checks
|
||||
python tests/kitting_notify_check.py # CR-011 - kitting mail, coalesced, gated 17 checks
|
||||
python tests/materials_check.py # D6 - material list, the CR-005 pattern 17 checks
|
||||
python tests/materials_check.py # D6 - material list, the CR-005 pattern 20 checks
|
||||
python tests/mreq_check.py # CR-013 - lightweight request, end to end 19 checks
|
||||
```
|
||||
|
||||
|
||||
@@ -554,3 +554,111 @@ deliberately deferred.
|
||||
entry rather than a drive-by.
|
||||
- **Suggested wave or follow-up:** next housekeeping pass, with the check
|
||||
widened so it cannot recur.
|
||||
|
||||
### BL-026 — No version stamp: "is live current?" cannot be answered from the app
|
||||
|
||||
- **Found during:** the 2026-08-21 outage triage (the question that started it)
|
||||
- **Where:** `Dockerfile` / build, `server/app.py` `/api/health`, admin console
|
||||
- **What:** the app carries no record of what code it is running. `/api/health`
|
||||
returns `{"ok": true}` and nothing identifies the deployed commit, so
|
||||
answering "is the live site on the latest code?" took fingerprinting
|
||||
(probing for files/routes that only exist after certain merges) in the
|
||||
middle of an outage. The fix: bake the git SHA into the image at build time
|
||||
(`ARG GIT_SHA`), return it from `/api/health`
|
||||
(`{"ok": true, "version": "<sha>"}`), and show it on the admin console's
|
||||
diagnostics card. Then currency is one glance against `git log -1`.
|
||||
- **Why not now:** new scope — needs its own item id per the working rules
|
||||
(D13 is the natural next), and it touches the image build, which deserves a
|
||||
deploy alongside someone with host access.
|
||||
- **Suggested wave or follow-up:** next housekeeping pass; ~1 task including a
|
||||
probe check that /api/health carries a version field.
|
||||
|
||||
### BL-027 — Migrations are rehearsed on SQLite only; production is Postgres
|
||||
|
||||
- **Found during:** the 2026-08-21 production outage (D6's `material_items`
|
||||
migration crash-looped the api container)
|
||||
- **Where:** `DEPLOYMENT.md` (the update/deploy steps), `tests/`
|
||||
- **What:** the migration chain is verified end-to-end on scratch SQLite, but
|
||||
production runs Postgres, and the dialects disagree exactly where it hurts:
|
||||
`server_default=sa.text('1')` on a Boolean passed every SQLite rehearsal and
|
||||
was refused by Postgres at deploy (DatatypeMismatch), taking the API down
|
||||
until the table was created by hand. The hotfix (64eac0c) fixed that one
|
||||
instance and pinned the Boolean-default class in `materials_check`; the
|
||||
CLASS of dialect drift is still unguarded. Two cheap layers: (1) a runbook
|
||||
step — render `alembic upgrade --sql` for the postgresql dialect and read it
|
||||
before restarting (offline, needs no live DB; this render would have shown
|
||||
`DEFAULT 1` on a boolean); (2) better, a probe that renders every migration
|
||||
for the postgresql dialect on each run and fails on anything the dialect
|
||||
rejects or on known-bad patterns.
|
||||
- **Why not now:** the outage is resolved and the one known instance is fixed
|
||||
and pinned; the systematic guard is its own small task, not a hotfix rider.
|
||||
- **Suggested wave or follow-up:** next housekeeping pass, paired with BL-026
|
||||
(both are "deploys should be boring" work).
|
||||
|
||||
### BL-028 — `okta_auth.describe()` is never called
|
||||
|
||||
- **Found during:** T10.9 (writing the deploy runbook's live-verification step)
|
||||
- **Where:** `server/okta_auth.py` (`describe()`), `server/app.py` (no caller anywhere)
|
||||
- **What:** `describe()` exists specifically to shout `"*** FAKE OKTA PROVIDER
|
||||
ACTIVE..."` or report missing config at a glance, and both
|
||||
`server/.env.example` and `server/README.md` tell an operator to "check the
|
||||
startup log line" for it. Nothing prints it. `app.py` never imports or calls
|
||||
`describe()` at process start, so that log line does not exist and an operator
|
||||
following the docs will not find it.
|
||||
- **Why not now:** a runbook is documentation, not server code; wiring a
|
||||
startup log call is a real (if small) change to `app.py` and wants its own
|
||||
diff and its own verification, not a rider on T10.9.
|
||||
- **Suggested wave or follow-up:** next housekeeping pass. One call
|
||||
(`logger.info(okta_auth.describe())` near startup) plus updating
|
||||
`DEPLOY-runbook-2026-09-03.md` Step 4 to check the log line once it exists.
|
||||
|
||||
### BL-029 — `users.failed_attempts` / `users.locked_until` are vestigial
|
||||
|
||||
- **Found during:** T10.6
|
||||
- **Where:** `server/models.py` (`User.failed_attempts`, `User.locked_until`)
|
||||
- **What:** both columns exist to support local-password lockout, which T10.4
|
||||
removed. They are still reset to `0`/`None` on every Okta sign-in but nothing
|
||||
increments them anymore — dead columns, not a bug, but schema drift from the
|
||||
D15 cutover.
|
||||
- **Why not now:** T10.6 is documentation scope; dropping columns is a migration
|
||||
and belongs with the rest of the local-password cleanup, not folded into a
|
||||
docs task.
|
||||
- **Suggested wave or follow-up:** next housekeeping pass, alongside any other
|
||||
post-cutover schema tidy-up.
|
||||
|
||||
### BL-031 — A raw `rgba()` shadow survives in the creator's stylesheet
|
||||
|
||||
- **Found during:** T10.8 (the full suite could finally run through Docker; the
|
||||
dev sandbox never had a headless browser to run it in before)
|
||||
- **Where:** `html/wp-creation-styles.css:929`, `.asset-results { ... box-shadow:0
|
||||
8px 24px rgba(20,30,50,.18); }`
|
||||
- **What:** `generalinfo_check.py` asserts the whole file carries no raw colour
|
||||
literal (comments excluded) as part of its CR-003 priority-colour check, and
|
||||
this one line fails it: 48/49. Confirmed via `git show HEAD` that the literal
|
||||
is already committed, byte-identical, unrelated to wave 10 — it is the Micron
|
||||
asset picker's dropdown shadow, which arrived with the `origin/Micron-Assets`
|
||||
merge (D11, 2026-08-20) and was never swept by `C4`'s token pass (`T9.9`
|
||||
closed before D11 merged). The fix is a straight swap:
|
||||
`theme-light.css:217` already declares `--wp-shadow-menu: 0 8px 24px
|
||||
rgba(20, 30, 50, .18)`, the identical value — this line should read
|
||||
`box-shadow:var(--wp-shadow-menu);`.
|
||||
- **Why not now:** unrelated to the Okta wave; fixing a D11-era CSS literal
|
||||
inside T10.8 (auth verification) is exactly the drive-by `CLAUDE.md` forbids.
|
||||
- **Suggested wave or follow-up:** next housekeeping pass, with `C4`'s other
|
||||
leftovers. One-line fix, `generalinfo_check.py` already pins it (49/49 once
|
||||
fixed).
|
||||
|
||||
### BL-030 — `DEPLOY-login-portal.md` is fully stale
|
||||
|
||||
- **Found during:** T10.9
|
||||
- **Where:** `DEPLOY-login-portal.md` (repo root)
|
||||
- **What:** the original username/password login rollout doc. References
|
||||
`bcrypt`, `create-admin --password`, `AUTH_SECRET_KEY` as the only secret,
|
||||
and a login form — none of which describe the app since T10.4/T10.5. Someone
|
||||
handed this to IT today would be told to do things that no longer work.
|
||||
- **Why not now:** no task currently owns deploy-doc cleanup as a category;
|
||||
deleting or archiving a doc is a product/records call (`CLAUDE.md`'s "removed
|
||||
fields are hidden, not deleted" spirit likely applies to docs too, but that is
|
||||
worth confirming rather than assuming).
|
||||
- **Suggested wave or follow-up:** next housekeeping pass — needs Nick on
|
||||
whether to delete, archive, or rewrite it as historical record.
|
||||
|
||||
75
docs/waves/decisions-2026-09-02.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Decisions — September 2, 2026
|
||||
|
||||
One item, and it retires two decided-and-built items rather than amending them.
|
||||
|
||||
---
|
||||
|
||||
## D15 — Authentication moves to Okta OIDC; D13 and D14 are retired before deployment
|
||||
|
||||
- **Amends:** retires `D13` (LDAPS simple bind against `prime.local`) and `D14` (the CLI
|
||||
authenticates against the domain). Both were decided and reaffirmed August 21 2026,
|
||||
built across nine tasks (`T10.1`–`T10.9`), and verified against the live domain. Neither
|
||||
reached production. Approved by Nick Siegfried.
|
||||
- **Surface:** `server/auth.py`, `server/app.py` (`login()`), `html/login.html`,
|
||||
`html/login.js`, `html/auth-guard.js`. `server/ldap_auth.py` does not carry forward —
|
||||
there is no LDAPS bind in the new design, not even as a fallback.
|
||||
- **Wave:** 10. The label is reused fresh: the LDAPS work that previously answered to
|
||||
"wave 10" was built on `feat/ldaps-directory-auth`, which is deleted rather than merged,
|
||||
and never appeared in `IMPLEMENTATION.md`'s wave table. It carries no claim on the
|
||||
number.
|
||||
|
||||
### What D13/D14 were, for the record
|
||||
|
||||
The branch carrying them is deleted, not merged, so their decision record
|
||||
(`docs/waves/decisions-2026-08-21.md`) no longer exists on any branch. Preserved here so
|
||||
the reasoning isn't lost along with it:
|
||||
|
||||
D13 chose a direct LDAPS simple bind to `ldaps://prime.local:636` as the sign-in
|
||||
mechanism: no password stored, a successful bind was the authentication, accounts were
|
||||
provisioned just-in-time from the directory, and roles stayed local. D14 moved the CLI
|
||||
onto the same bind, removing `create-admin`/`create`. Both were built, tested
|
||||
(1284/1288 checks, Aug 24), and screenshotted at 390px and 1440px. Neither ever deployed —
|
||||
`wp.controls.dev` still runs the pre-D13 local-password login as of this decision.
|
||||
|
||||
### The decision
|
||||
|
||||
Skip LDAPS entirely. Authentication becomes an Okta OIDC authorization-code flow,
|
||||
replacing local passwords directly — the same full replacement D13 intended, just via
|
||||
Okta instead of a domain bind. No LDAPS bind exists in this design at any point.
|
||||
|
||||
Four things carry forward from D13 unchanged, because they were never LDAPS-specific to
|
||||
begin with:
|
||||
|
||||
1. **No password is stored.** The app never sees a credential of any kind; Okta owns
|
||||
authentication entirely.
|
||||
2. **Accounts are provisioned just-in-time.** A first successful Okta sign-in with no
|
||||
matching local `users` row creates one, at the default role. The matching logic that
|
||||
was going to key off a directory search instead keys off an OIDC identity claim.
|
||||
3. **Roles stay local.** Okta, and AD behind it, supplies identity only. This app decides
|
||||
what an identity may do. Restated because it is the one rule the whole access-control
|
||||
design depends on — see `BL-029` and the governance discussion that followed it.
|
||||
4. **Existing accounts keep their roles** on first Okta login, exactly as D13's criterion
|
||||
4 read for LDAPS.
|
||||
|
||||
### Why this, and not LDAPS first and Okta second
|
||||
|
||||
`BL-029` (recorded on this branch as `BL-027` before the renumbering forced by main's
|
||||
independent use of that number) already laid out why OIDC beats the LDAPS bind on three
|
||||
counts: this app never sees a password, MFA comes from Okta rather than needing to be
|
||||
built, and the domain-lockout hazard that forced `AUTH_MAX_ATTEMPTS` down to 2 disappears,
|
||||
because failed attempts land on Okta rather than on a bind this app makes. D13 was decided
|
||||
before it was known the company already runs an Okta tenant. Once that was confirmed
|
||||
(security's scoping reply, September 2026), shipping LDAPS first and replacing it with
|
||||
Okta days later would mean building and deploying the weaker mechanism on purpose. Going
|
||||
straight to Okta avoids that.
|
||||
|
||||
### What still needs answering before this is buildable
|
||||
|
||||
Open from the security scoping thread, not yet closed:
|
||||
|
||||
- Which OIDC claim carries the AD `sAMAccountName` equivalent (`preferred_username`,
|
||||
`upn`, or a custom claim) — asked of security, answer pending.
|
||||
- The exact redirect/callback URI once the hostname situation is reconfirmed
|
||||
(`https://wp.controls.dev/api/auth/okta/callback` proposed).
|
||||
- The `Business Technology Group` pilot in Okta, requested for initial testing, with
|
||||
normal Okta session/MFA behavior rather than a stricter per-app rule.
|
||||
122
docs/waves/decisions-2026-09-03.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# Decisions — 2026-09-03
|
||||
|
||||
## D16: Okta admin bootstrap, break-glass posture, and the real scope of T10.4
|
||||
|
||||
Raised during hazard review for T10.4 (remove the local password path). D15 settled
|
||||
*that* local passwords go away and Okta OIDC is the sole replacement; it did not settle
|
||||
how an admin account gets named once there is no password to set, or what happens if
|
||||
Okta itself is unreachable. Both are decided here.
|
||||
|
||||
### Admin bootstrap
|
||||
|
||||
`server/manage_users.py` stays the bootstrap tool — its own docstring already says so
|
||||
("the `/api/auth/users` endpoint needs an existing admin, so you have to bootstrap one
|
||||
here") — but it changes from *creating* an account to *promoting* one:
|
||||
|
||||
- An operator with shell/DB access on the server runs it against an account that
|
||||
already signed in through Okta once and was JIT-provisioned by T10.3 (landing at
|
||||
`project_user`, per that task). The command sets `role = admin` on that existing row
|
||||
by username.
|
||||
- It does **not** create a `User` row from scratch and does not touch `password_hash`
|
||||
(the column is gone after T10.4's migration).
|
||||
|
||||
Rejected: minting a brand-new admin row by hand-typed username. `OKTA_IDENTITY_CLAIM`'s
|
||||
exact format is still unconfirmed by security (open item carried from D15/wave-10.md).
|
||||
A hand-typed username that doesn't exactly match what Okta actually sends produces a
|
||||
second, orphaned account instead of promoting the real one. Promoting an
|
||||
already-JIT-provisioned row sidesteps that entirely — it never has to guess the future
|
||||
claim value.
|
||||
|
||||
Ongoing (non-bootstrap) admin naming needs no new work: `html/users.js` already has a
|
||||
live role dropdown (`roleSelect`, gated by server-supplied `grantable_roles`) that lets
|
||||
an existing admin promote any other account, including one JIT-provisioned via Okta.
|
||||
That path is unrelated to the password removal and keeps working unchanged.
|
||||
|
||||
### Break glass
|
||||
|
||||
No break-glass path, by design. If Okta is unreachable or misconfigured, the app is
|
||||
unreachable for everyone, including admins, until Okta is restored.
|
||||
|
||||
This matches the precedent already on record for the abandoned LDAPS design (D13/D14):
|
||||
"LDAPS is the *only* path, no local fallback, no break-glass." Carried forward
|
||||
deliberately rather than assumed to still apply, given Okta's failure modes differ from
|
||||
an internal LDAP bind — considered and confirmed, not defaulted into.
|
||||
|
||||
Rejected: a toggleable emergency local login gated behind an env flag. It would
|
||||
reintroduce a stored local credential, exactly what D15 exists to eliminate, for a
|
||||
scenario (Okta down) judged less likely and less costly than the standing risk of a
|
||||
forgotten emergency backdoor.
|
||||
|
||||
The server-shell CLI (`manage_users.py`, promoting an existing row) is not a formal
|
||||
break-glass mechanism — it cannot help if no account has ever signed in through Okta —
|
||||
but it is the same trust tier as "someone with SSH/container access to prod could
|
||||
already edit the database directly," and it costs no new engineering.
|
||||
|
||||
### T10.4 scope correction
|
||||
|
||||
Hazard review found real call sites of `hash_password` / `verify_password` /
|
||||
`password_problem` that the original T10.4 bullet in `wave-10.md` didn't name and that
|
||||
break the moment those functions are deleted:
|
||||
|
||||
- `create_user()` and `admin_reset_password()` in `server/app.py` (the admin console's
|
||||
"add user" and "reset password" routes).
|
||||
- `html/users.js`'s "add user" form (`nu-password` field) and "Reset password" button.
|
||||
- `server/manage_users.py`'s `create`, `create-admin`, and `reset-password` subcommands
|
||||
(see bootstrap section above for its replacement).
|
||||
- `tests/browser_check.py` and `tests/launcher_check.py`, which call
|
||||
`auth.hash_password()` to seed fixture rows.
|
||||
|
||||
Also found: `server/smoketest.py` and `server/seed_demo.py` authenticate via
|
||||
`POST /api/auth/login`, which T10.4 removes, and CLAUDE.md's own verification section
|
||||
names both scripts as required checks. Fix folded into T10.4 rather than deferred to
|
||||
T10.7: both scripts switch to minting a session with `auth.create_token()` and setting
|
||||
the cookie directly, the same technique `tests/browser_check.py` already uses instead
|
||||
of scripting a login form. No live Okta tenant needed, and T10.4 no longer depends on
|
||||
T10.7's timing.
|
||||
|
||||
`wave-10.md`'s T10.4 bullet is updated to reflect this full scope.
|
||||
|
||||
## D17: A dedicated rollback-aware runbook for the Okta cutover deploy
|
||||
|
||||
Raised while preparing to close out wave 10: T10.4's migration
|
||||
(`server/alembic/versions/1d60a608bb51_drop_local_password.py`) drops the
|
||||
`password_hash` column, and its `downgrade()` re-adds the column with
|
||||
`server_default=''`. That restores the schema, not the data — the real bcrypt hashes
|
||||
are destroyed the moment `upgrade()`'s `op.drop_column` commits, and no amount of
|
||||
`alembic downgrade` brings them back. `DEPLOY-runbook-2026-08-04.md`, the existing
|
||||
precedent for how a deploy of this repo is handed to IT, has no equivalent case in its
|
||||
own Rollback section — its migrations are additive or reversible, so nothing there
|
||||
warns an operator that this one is different.
|
||||
|
||||
Three options were on the table: write this runbook now; also document a staged
|
||||
deploy sequence (ship T10.5's Okta-live-alongside-password state first, verify real
|
||||
sign-ins, then ship T10.4's column drop as a separate follow-up deploy); or log the
|
||||
hazard to `backlog.md` and stop there. Decided: write the runbook only. Staged
|
||||
sequencing is real risk reduction but is a second deploy plan on top of a wave that is
|
||||
otherwise a single cutover (D15's "full replacement," not a toggle) — worth
|
||||
proposing on its own if IT wants it, not worth building unasked. Backlog-only was
|
||||
rejected because the hazard is concrete and dated (this wave, this migration), not a
|
||||
someday item.
|
||||
|
||||
### What the runbook has to do differently from the 2026-08-04 precedent
|
||||
|
||||
- Name the five `OKTA_*` environment variables as newly required for this deploy —
|
||||
the precedent's own "no new environment variables" note does not apply here and
|
||||
restating it unchanged would be actively wrong.
|
||||
- Treat the pre-deploy backup (`docker exec wp_db_backup /scripts/db-backup.sh`) as
|
||||
the only way back once `1d60a608bb51` commits, not as routine due diligence.
|
||||
- Separate two failure modes that look similar but are not: an Okta app integration
|
||||
that is misconfigured after a clean deploy (redirect URI, client secret, an
|
||||
unassigned test account) is fixable in place — fix the config, redeploy the `api`
|
||||
service, no data at risk, migration already applied and stays applied. Deciding to
|
||||
abandon Okta and restore local-password code is the severe case — the empty
|
||||
`password_hash` column means the old code has nothing to check a password against,
|
||||
so the only way back is the destructive backup restore (`DEPLOY-runbook-2026-08-04.md`
|
||||
Case C's own procedure, reused here). Conflating these two would send an operator
|
||||
straight to a destructive restore for a problem that a config fix would have solved.
|
||||
- Fold in D16's no-break-glass posture: if Okta is down or misconfigured, the app is
|
||||
down for everyone, including admins, by design — not a defect to roll back from.
|
||||
|
||||
Filed as **T10.9** in `wave-10.md` rather than folded into T10.6 (already merged and
|
||||
verified) or T10.8 (UI/test verification, a different kind of check). New scope found
|
||||
after the task that raised it closed gets a new ID, per `CLAUDE.md`.
|
||||
233
docs/waves/wave-10.md
Normal file
@@ -0,0 +1,233 @@
|
||||
# Wave 10 — Okta OIDC authentication
|
||||
|
||||
Fresh wave 10. The label was previously used by the LDAPS work under `D13`/`D14`, built on
|
||||
`feat/ldaps-directory-auth`; that branch was deleted rather than merged and never appeared
|
||||
in `IMPLEMENTATION.md`'s wave table, so it carries no claim on the number. See
|
||||
`docs/waves/decisions-2026-09-02.md` (`D15`) for why LDAPS was retired before deployment
|
||||
and Okta chosen instead.
|
||||
|
||||
Depends only on `main` as it stands after `D15`. Not sequenced behind any other wave.
|
||||
|
||||
## Tasks
|
||||
|
||||
- **T10.1 — Add the Okta OIDC client.** Authlib as a dependency. Config via env vars
|
||||
(`OKTA_ISSUER`, `OKTA_CLIENT_ID`, `OKTA_CLIENT_SECRET`, `OKTA_REDIRECT_URI`), same
|
||||
pattern `AUTH_SECRET_KEY` already uses in `server/auth.py`.
|
||||
|
||||
- **T10.2 — Login-redirect and callback routes.** A route that sends the browser to
|
||||
Okta's authorize endpoint, and a callback route that exchanges the code for tokens and
|
||||
validates the ID token. Access gating is Okta's job, not this app's: only accounts
|
||||
assigned to the app integration in Okta can reach it at all, so there is no app-side
|
||||
required-group or claim check layered on top. This is a deliberate difference from D13,
|
||||
which had to gate on a required AD group itself because an LDAPS bind alone could not
|
||||
distinguish an assigned user from any other domain account.
|
||||
|
||||
- **T10.3 — Identity matching and JIT provisioning.** Reuses D13's shape
|
||||
(`_provision_from_directory`-style matching) keyed off an OIDC claim instead of an LDAP
|
||||
search result. **Open dependency:** which claim carries the AD `sAMAccountName`
|
||||
equivalent (`preferred_username`, `upn`, or a custom claim) is asked of security and not
|
||||
yet answered. Build with a configurable claim name and a documented default, not a
|
||||
hardcoded one, so the answer can drop in without a code change.
|
||||
|
||||
- **T10.4 — Remove the local password path entirely.** Drop `password_hash` (Alembic
|
||||
migration; plain `op.drop_column`, matching existing precedent for other NOT NULL
|
||||
columns on `users` — no `batch_alter_table` needed), remove the bcrypt-based
|
||||
`login()`, remove the username/password form. Real deletion, matching `D15`'s "full
|
||||
replacement," not a toggle or a fallback. Scope corrected by `D16` after hazard
|
||||
review turned up more call sites than the original bullet named:
|
||||
- `create_user()` and `admin_reset_password()` in `server/app.py` (admin console's
|
||||
"add user" and "reset password" routes) — rework to drop the password field
|
||||
entirely rather than break.
|
||||
- `html/users.js`'s "add user" form (`nu-password`) and "Reset password" button —
|
||||
matching frontend change.
|
||||
- `server/manage_users.py` — reworked per `D16` from account *creation* to
|
||||
*promotion*: `create-admin`/`create`/`reset-password` (password-based) are
|
||||
replaced by a promote-by-username command that operates on a row Okta's JIT
|
||||
provisioning (T10.3) already created, never a hand-typed new one. This is now
|
||||
the documented admin-bootstrap path — see `D16`.
|
||||
- `tests/browser_check.py` and `tests/launcher_check.py` — stop calling
|
||||
`auth.hash_password()` to seed fixture rows.
|
||||
- `server/smoketest.py` and `server/seed_demo.py` — currently authenticate via
|
||||
`POST /api/auth/login`. Switch to minting a session with `auth.create_token()`
|
||||
directly, the same technique `browser_check.py` already uses, so both scripts
|
||||
(named explicitly in `CLAUDE.md`'s verification section) keep working without
|
||||
depending on `T10.7`'s timing.
|
||||
|
||||
- **T10.5 — Frontend: login becomes a redirect, not a form.** `login.html`/`login.js`
|
||||
change to a "Sign in with Okta" flow. Sign-out lands back on the app's own login page.
|
||||
|
||||
- **T10.6 — Deployment docs and env var reference.** `DEPLOYMENT.md`,
|
||||
`server/.env.example`, `server/README.md` describe the Okta config in place of the LDAP
|
||||
config they never ended up describing (D13 never shipped, so these still describe the
|
||||
original local-password system today).
|
||||
|
||||
Built: all three rewritten — the five `OKTA_*` vars documented the same way
|
||||
`AUTH_SECRET_KEY` already was, the login-portal/self-service-reset sections replaced
|
||||
with the Okta flow and the promote-not-create admin bootstrap (D16), the smoke-test
|
||||
walkthrough updated for `WP_SMOKE_USER`-only / must-share-`AUTH_SECRET_KEY`-and-DB
|
||||
(T10.4's `smoketest.py` rewrite). Also `docker-compose.yml`, not originally named in
|
||||
this bullet: its `api` service sets `environment:` as an explicit allowlist, not
|
||||
`env_file`, so the documented vars would silently never reach the container without
|
||||
adding them there too — found and fixed in the same commit rather than shipping docs
|
||||
for a config path that doesn't work. `OKTA_IDENTITY_CLAIM` mirrors
|
||||
`okta_auth.py`'s own default (`preferred_username`) in the compose file rather than
|
||||
defaulting to an empty string, which would 503 every sign-in.
|
||||
|
||||
Logged, not fixed here (out of scope): `users.failed_attempts`/`locked_until` are
|
||||
vestigial (still reset on every Okta sign-in, nothing increments them since local
|
||||
`login()` is gone); `server/README.md`'s own "Production — Docker Compose" section
|
||||
is a self-contained alternate quickstart that already diverged from the real root
|
||||
`docker-compose.yml` before this task and still does.
|
||||
|
||||
- **T10.7 — Test coverage without a live Okta dependency.** A fake-OIDC-provider test
|
||||
seam, mirroring `ldap_fake.py`, so the suite runs with no live Okta tenant reachable.
|
||||
Built: `server/okta_fake.py` (env-driven, `WP_OKTA_FAKE_DIRECTORY`, production-refusing
|
||||
the same way `ldap_fake.py` does), dispatched from `okta_auth._build_oauth()` before
|
||||
the real Authlib client is considered. Only the two Authlib calls that touch the
|
||||
network — `authorize_redirect` / `authorize_access_token` — are faked; `app.py`'s
|
||||
`okta_login()`/`okta_callback()` (the `?next=` guard, the disabled-account check, JIT
|
||||
provisioning, the identity-claim lookup) run unmodified against the fake, same
|
||||
boundary the LDAP predecessor drew around the anonymous-bind guard. Two fake-only
|
||||
routes (`/_fake_provider`, `/_fake_provider/consent`) stand in for Okta's own sign-in
|
||||
screen and are registered in `app.py` only when the fake is active at import time —
|
||||
in production they do not exist, not merely refuse. `tests/browser_check.py`'s
|
||||
`start_server()` now takes an optional `extra_env` and sets
|
||||
`WP_OKTA_FAKE_DIRECTORY` unconditionally (same reasoning `ldap_fake`'s equivalent
|
||||
used: almost nothing signs in, but the one check that does should not fail
|
||||
mysteriously). `tests/url_state_check.py` scenario 2 is un-skipped and drives the
|
||||
real round trip — login.html's button, the fake picker page, the fake consent
|
||||
redirect, `okta_callback()` — proving `?next=` survives it, same tightened
|
||||
"actually left login.html" assertion the LDAP predecessor's own bug fix used.
|
||||
`tests/okta_auth_check.py` is new: the production guard, single-use/replay on the
|
||||
authorization code, an unsolicited callback hit, a tampered state, a denied consent,
|
||||
an unknown identity, a disabled account, JIT provisioning, an existing admin
|
||||
surviving unchanged, same-site vs. off-site `?next=`, and `OKTA_IDENTITY_CLAIM`
|
||||
genuinely working under a non-default claim name — 22/22.
|
||||
|
||||
The dev sandbox this was built in has no headless browser and no way to install
|
||||
one, so `tests/url_state_check.py` and `tests/browser_check.py` (both need
|
||||
`tests/cdp.py`'s real headless Chromium) could not be run there — only
|
||||
`okta_auth_check.py`'s HTTP-level coverage of the same mechanism. Run for real
|
||||
afterward on a machine with Docker, via a separate general-purpose tool
|
||||
(`headless-py-test-runner`, kept out of this repo — it is not Work Package Suite
|
||||
specific): `url_state_check.py` 26/26, including scenario 2's real click-through of
|
||||
the fake-Okta round trip, and `browser_check.py` 71/71. Gap closed.
|
||||
|
||||
**Validated 2026-09-03:** `tests/okta_auth_check.py` 22/22 (no browser needed) ·
|
||||
`tests/url_state_check.py` 26/26 · `tests/browser_check.py` 71/71 — all three run
|
||||
clean, the last two against real headless Chromium via Docker.
|
||||
|
||||
- **T10.8 — Verification.** 390px and 1440px, full suite, done-when checks per task,
|
||||
matching the rigor D13 was held to.
|
||||
|
||||
Done-when checks per task, verified against the actual code rather than
|
||||
re-reading this file's own claims: T10.1 (Authlib pinned, the four env vars,
|
||||
`is_configured()`/`describe()`), T10.2 (the login/callback routes, no
|
||||
app-side group or claim gate layered on Okta's own), T10.3 (identity-claim
|
||||
matching, JIT at the lowest role, local deprovisioning still enforced after
|
||||
Okta approves), T10.4 (zero remaining references to `bcrypt` /
|
||||
`password_hash` / `hash_password` / `verify_password` anywhere in `.py` or
|
||||
`.js`, `manage_users.py promote`, `smoketest.py` / `seed_demo.py` /
|
||||
`browser_check.py` / `launcher_check.py` all minting via `create_token()`),
|
||||
T10.5 (`login.html` is one Okta link, no password field). All matched what
|
||||
this file already claimed — no drift found.
|
||||
|
||||
Full suite, run through the Docker test runner (all 41 files in `tests/`,
|
||||
bare invocation): 39 passed clean. `tests/token_check.py` "failed" at exit 2,
|
||||
but that is a harness mismatch, not a check failure — it is a two-step
|
||||
snapshot/diff tool (`--out` to capture, `--compare A B` to diff) and prints
|
||||
usage + exits 2 when run with no arguments, which is what a bare full-suite
|
||||
pass does to every file. `tests/generalinfo_check.py` scored 48/49 — the one
|
||||
failure is a raw `rgba()` shadow literal in `wp-creation-styles.css`,
|
||||
confirmed via `git show HEAD` to already be committed and unrelated to this
|
||||
wave (it is the Micron asset picker's dropdown shadow from the `D11` merge,
|
||||
2026-08-20, predating this wave by two weeks). Logged as `BL-031` rather than
|
||||
fixed here — an unrelated CSS token-rule violation is not this wave's to fix.
|
||||
`tests/okta_auth_check.py` re-run fresh (no browser needed): 22/22.
|
||||
|
||||
390px and 1440px: `tests/baseline_shots.py` captured all fourteen shots
|
||||
(login, launcher, sop, creator, admin, users, field × two widths) into
|
||||
`docs/reference/baseline/`. `login-390.png`/`login-1440.png` and
|
||||
`users-390.png`/`users-1440.png` visually confirmed: the login page is a
|
||||
single "Sign in with Okta" button with no username/password form at either
|
||||
width, and the User Directory's table and "Add a user" form both carry no
|
||||
password column and no reset-password action anywhere, at either width.
|
||||
|
||||
Wave 10 is complete. The three items in "Still open" below are external
|
||||
(security team / Okta admin), not blocked on any task in this wave.
|
||||
|
||||
- **T10.10 — Audit `manage_users.py promote`.** Raised in review after T10.8:
|
||||
`cmd_promote()` changed a user's role with no audit trail at all, unlike the
|
||||
identical role change from the web Admin Console (`app.py`'s
|
||||
`set_user_role()` → `log_event()`, action `"role_changed"`). Not a new
|
||||
privilege — anyone with Portainer/container-exec access to `wp_api` already
|
||||
has shell access to the database directly, same trust tier D16 already named
|
||||
for this command — but there was no record of who ran it or what changed.
|
||||
|
||||
Built: `cmd_promote()` now writes an `AuditLog` row with the same
|
||||
`action`/`detail` shape `set_user_role()` uses (`{"from": old_role, "to":
|
||||
role}`), tagged `"via": "cli"` (mirrors JIT provisioning's own `"via":
|
||||
"okta_jit"` tag) and `actor="cli:manage_users"` — a container shell exec
|
||||
carries no signed-in identity to attribute the change to a real person, so
|
||||
it names the tool rather than guessing one. Verified end to end against a
|
||||
scratch SQLite database: the audit row lands with the exact expected shape,
|
||||
the role change persists, and the existing "no such user" refusal still
|
||||
exits 1 with no partial write.
|
||||
|
||||
- **T10.9 — Rollback-aware deploy runbook.** Raised after hazard review found
|
||||
`DEPLOY-runbook-2026-08-04.md`'s Rollback section has no case for a migration whose
|
||||
`downgrade()` cannot restore the data it drops — see `D17`. `T10.4`'s
|
||||
`1d60a608bb51_drop_local_password` is exactly that: the schema comes back, the
|
||||
bcrypt hashes do not.
|
||||
|
||||
Built: `DEPLOY-runbook-2026-09-03.md`, following the 2026-08-04 runbook's structure
|
||||
(fill-in table, numbered deploy steps, case-by-case Rollback section, Notes). Names
|
||||
the five `OKTA_*` vars as newly required (the precedent's "no new environment
|
||||
variables" note does not carry over), treats the pre-deploy backup as the only way
|
||||
back once the migration commits, and splits Rollback into the fixable case (Okta app
|
||||
integration misconfigured — fix and redeploy `api`, no data at risk, migration stays
|
||||
applied) versus the severe case (abandoning Okta for local-password code — only the
|
||||
destructive backup restore gets there, reusing the 2026-08-04 runbook's own Case C
|
||||
procedure). D16's no-break-glass posture is stated plainly rather than left implicit.
|
||||
|
||||
Logged, not fixed here (out of scope): `okta_auth.describe()`'s startup log line
|
||||
(referenced by `DEPLOYMENT.md`/`server/README.md`) has no caller anywhere in
|
||||
`server/app.py` — nothing actually prints it at process start. The runbook's Step 4
|
||||
therefore verifies via a live Okta sign-in rather than a log line, and this gap is
|
||||
flagged in `docs/waves/backlog.md` as a candidate fix (wiring `describe()` into
|
||||
startup) since it directly bears on deploy verifiability. `DEPLOY-login-portal.md`
|
||||
is now fully stale (bcrypt, `create-admin --password`, none of which still exist) —
|
||||
not touched, no task claims it.
|
||||
|
||||
## Still open
|
||||
|
||||
- The `Business Technology Group` pilot assignment in Okta. Originally six names
|
||||
(Carlee Swihart, Drew Hilliard, Matt Mabrey, Nick Siegfried, Rachel Schreiber, Terry
|
||||
Sajan); Cody and Cameron added 2026-09-09. Adrian added only Matt at first,
|
||||
deliberately, pending the live sign-in confirmation below — awaiting his response to
|
||||
add the rest of the group now that it has.
|
||||
|
||||
Closed since first written: admin bootstrap and break-glass posture, previously open
|
||||
questions, decided in `D16` (2026-09-03) and folded into `T10.4` above.
|
||||
|
||||
**Closed 2026-09-09, live in production:** the redirect/callback URI
|
||||
(`https://wp.controls.dev/api/auth/okta/callback`) is confirmed working, and so is the
|
||||
OIDC claim mapping (`T10.3`) — `preferred_username` (the code's documented default,
|
||||
never actually confirmed by name in Request 50649's thread) is correct, no
|
||||
`OKTA_IDENTITY_CLAIM` override needed. Both settled by an actual live sign-in against
|
||||
the real Okta tenant after `main` was merged (`cc64c88`) and deployed: Matt signed in
|
||||
as himself, matched his existing pre-Okta admin account by `find_user()` rather than
|
||||
JIT-provisioning a duplicate (the account already existed — this app has ~40 real
|
||||
users, not the seeded test fixture), landed on `index.html` signed in, admin role and
|
||||
project access untouched. One real deploy-time snag on the way, worth recording since
|
||||
it's exactly the Case B scenario `DEPLOY-runbook-2026-09-03.md` anticipated: the first
|
||||
redeploy left `OKTA_CLIENT_ID`/`OKTA_CLIENT_SECRET`/`OKTA_ISSUER` as empty rows in
|
||||
Portainer (env var names added, values never filled in) — caught via
|
||||
`is_configured()`'s all-four-required check failing closed (the 503 "Sign-in is
|
||||
temporarily unavailable"), not silently. A second snag after filling those in:
|
||||
`OKTA_ISSUER` was pasted without its `https://` scheme, which surfaced as
|
||||
`httpx.UnsupportedProtocol` from Authlib's OIDC discovery fetch rather than anything
|
||||
the app's own code raises deliberately — the exact case the runbook's Notes flagged as
|
||||
having no startup-time confirmation (`okta_auth.describe()` still has no caller,
|
||||
`BL-028`). Both fixed by correcting the env var values in Portainer and redeploying;
|
||||
neither needed the backup or the database.
|
||||
@@ -60,62 +60,10 @@
|
||||
.then(function () { window.location.replace('login.html'); });
|
||||
};
|
||||
|
||||
// Change-password dialog (uses POST /api/auth/password, which requires the
|
||||
// current password). Available from the top-right pill on any page.
|
||||
window.wpChangePassword = function () {
|
||||
if (document.getElementById('wp-pw-modal')) return;
|
||||
var ov = document.createElement('div');
|
||||
ov.id = 'wp-pw-modal';
|
||||
ov.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;' +
|
||||
'justify-content:center;z-index:10002;padding:20px;font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
|
||||
var inp = 'width:100%;padding:9px 10px;margin-bottom:12px;border:1px solid var(--cds-border-strong);border-radius:4px;font-size:14px;';
|
||||
var lbl = 'display:block;font-size:12px;color:var(--cds-text-secondary);margin-bottom:4px;';
|
||||
ov.innerHTML =
|
||||
'<div style="background:var(--cds-layer);color:var(--cds-text-primary);border-radius:10px;max-width:380px;width:100%;box-shadow:0 12px 40px rgba(20,30,50,.3);overflow:hidden;">' +
|
||||
'<div style="padding:14px 18px;border-bottom:1px solid var(--cds-border-subtle);font-weight:700;">Change password</div>' +
|
||||
'<div style="padding:16px 18px;">' +
|
||||
'<div id="wp-pw-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin-bottom:12px;"></div>' +
|
||||
'<label style="' + lbl + '">Current password</label>' +
|
||||
'<input id="wp-pw-cur" type="password" autocomplete="current-password" style="' + inp + '">' +
|
||||
'<label style="' + lbl + '">New password (at least 12 characters)</label>' +
|
||||
'<input id="wp-pw-new" type="password" autocomplete="new-password" style="' + inp + '">' +
|
||||
'<label style="' + lbl + '">Confirm new password</label>' +
|
||||
'<input id="wp-pw-new2" type="password" autocomplete="new-password" style="' + inp + 'margin-bottom:0;">' +
|
||||
'</div>' +
|
||||
'<div style="padding:12px 18px;border-top:1px solid var(--cds-border-subtle);display:flex;gap:8px;justify-content:flex-end;">' +
|
||||
'<button type="button" id="wp-pw-cancel" style="padding:8px 14px;border:1px solid var(--cds-border-strong);background:var(--cds-layer);border-radius:6px;cursor:pointer;font-weight:600;">Cancel</button>' +
|
||||
'<button type="button" id="wp-pw-save" style="padding:8px 14px;border:none;background:var(--cds-interactive-01);color:var(--cds-text-on-color);border-radius:6px;cursor:pointer;font-weight:600;">Update password</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
function close() { var m = document.getElementById('wp-pw-modal'); if (m) m.remove(); }
|
||||
function msg(text, ok) {
|
||||
var el = document.getElementById('wp-pw-msg');
|
||||
el.style.display = 'block'; el.textContent = text;
|
||||
el.style.background = ok ? 'var(--wp-status-success-bg)' : 'var(--wp-status-error-bg)'; el.style.color = ok ? 'var(--wp-status-success-text)' : 'var(--cds-support-error)';
|
||||
}
|
||||
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
|
||||
document.body.appendChild(ov);
|
||||
document.getElementById('wp-pw-cancel').onclick = close;
|
||||
document.getElementById('wp-pw-cur').focus();
|
||||
document.getElementById('wp-pw-save').onclick = function () {
|
||||
var cur = document.getElementById('wp-pw-cur').value;
|
||||
var n1 = document.getElementById('wp-pw-new').value;
|
||||
var n2 = document.getElementById('wp-pw-new2').value;
|
||||
if (!cur || !n1) { msg('Please fill in every field.', false); return; }
|
||||
if (n1.length < 12) { msg('New password must be at least 12 characters.', false); return; }
|
||||
if (n1 !== n2) { msg('New passwords do not match.', false); return; }
|
||||
fetch('/api/auth/password', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ current_password: cur, new_password: n1 })
|
||||
})
|
||||
.then(function (r) { return r.json().catch(function () { return null; }).then(function (j) { return { ok: r.ok, status: r.status, j: j }; }); })
|
||||
.then(function (res) {
|
||||
if (res.ok) { msg('Password updated.', true); setTimeout(close, 1200); }
|
||||
else { msg((res.j && res.j.detail) || ('Could not update (HTTP ' + res.status + ').'), false); }
|
||||
})
|
||||
.catch(function () { msg('Could not reach the server.', false); });
|
||||
};
|
||||
};
|
||||
// window.wpChangePassword used to open a change-password dialog here. Removed in
|
||||
// T10.4 (D15/D16): there is no local password to change anymore — identity is
|
||||
// Okta's job. The "Password" item that called this is gone from wp-sidenav.js
|
||||
// too.
|
||||
|
||||
// ── permissions helpers ────────────────────────────────────────────────────
|
||||
// The server enforces all of this; these are for hiding controls the signed-in
|
||||
@@ -171,7 +119,8 @@
|
||||
// Admin, Users and Sign out from the navigation drawer, and being one unbreakable
|
||||
// 412px run with an inline white-space:nowrap, it was what clipped the bar at 390px
|
||||
// and cut "Sign out" in half — F2. wp-sidenav.js now carries all of it, including
|
||||
// the two items that were only here: Language & time, and Password.
|
||||
// the item that was only here: Language & time. (Password was the other one; T10.4
|
||||
// removed it along with the rest of local auth — D15/D16.)
|
||||
//
|
||||
// Nothing replaces it. Every signed-in page mounts the drawer, so there is no page
|
||||
// left that would need a floating fallback pill.
|
||||
|
||||
@@ -31,34 +31,23 @@
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.brand img { height: 36px; width: auto; }
|
||||
.brand .name { font-weight: 700; font-size: 0.95rem; color: var(--cds-text-primary); }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
|
||||
.sub { color: var(--cds-text-secondary); font-size: 0.875rem; margin-bottom: 1.75rem; }
|
||||
label { display: block; font-size: 0.75rem; color: var(--cds-text-secondary); margin-bottom: 0.375rem; }
|
||||
.field { margin-bottom: 1.25rem; }
|
||||
input[type=text], input[type=password] {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
font-size: 1rem;
|
||||
background: var(--cds-field);
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--cds-border-strong);
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
input:focus { outline: 2px solid var(--cds-focus); background: var(--cds-field-hover); }
|
||||
button {
|
||||
.btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: var(--cds-text-on-color);
|
||||
background: var(--cds-button-primary);
|
||||
border: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
button:hover:not(:disabled) { background: var(--cds-hover-primary); }
|
||||
button:disabled { background: var(--cds-disabled-02); cursor: not-allowed; }
|
||||
.btn:hover { background: var(--cds-hover-primary); }
|
||||
.btn:focus-visible { outline: 2px solid var(--cds-focus); outline-offset: 2px; }
|
||||
.error {
|
||||
display: none;
|
||||
background: var(--wp-status-error-bg);
|
||||
@@ -69,7 +58,6 @@
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.error.show { display: block; }
|
||||
.foot { margin-top: 1.5rem; font-size: 0.75rem; color: var(--cds-text-helper); text-align: center; }
|
||||
.ok {
|
||||
display: none;
|
||||
background: var(--wp-status-success-bg);
|
||||
@@ -80,15 +68,7 @@
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.ok.show { display: block; }
|
||||
.note {
|
||||
font-size: 0.8125rem; color: var(--cds-text-secondary);
|
||||
background: var(--cds-layer-accent); border-left: 3px solid var(--cds-link-primary);
|
||||
padding: 0.75rem; margin-bottom: 1.25rem;
|
||||
}
|
||||
.hint { font-size: 0.75rem; color: var(--cds-text-helper); margin-top: -0.75rem; margin-bottom: 1.25rem; }
|
||||
a.link { color: var(--cds-link-primary); text-decoration: none; font-size: 0.8125rem; }
|
||||
a.link:hover { text-decoration: underline; }
|
||||
.center { text-align: center; margin-top: 1.25rem; }
|
||||
.foot { margin-top: 1.5rem; font-size: 0.75rem; color: var(--cds-text-helper); text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -99,61 +79,10 @@
|
||||
<div id="error" class="error" role="alert"></div>
|
||||
<div id="ok" class="ok" role="status"></div>
|
||||
|
||||
<!-- SIGN IN -->
|
||||
<section id="view-login">
|
||||
<h1>Sign in</h1>
|
||||
<p class="sub">Work Package Suite</p>
|
||||
<form id="login-form" autocomplete="on">
|
||||
<div class="field">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" type="text" autocomplete="username" autofocus required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
<button id="submit" type="submit">Sign in</button>
|
||||
</form>
|
||||
<p class="center"><a href="#" id="forgot-link" class="link">Forgot password?</a></p>
|
||||
</section>
|
||||
|
||||
<!-- FORGOT PASSWORD (email reset) -->
|
||||
<section id="view-forgot" style="display:none">
|
||||
<h1>Reset password</h1>
|
||||
<p class="sub">We'll email you a link to set a new one.</p>
|
||||
<div id="forgot-unavailable" class="note" style="display:none">
|
||||
Password reset by email isn't switched on yet. Contact your project admin and
|
||||
they'll set a new password for you. Once you're signed in you can change it
|
||||
yourself from the menu in the top-right corner.
|
||||
</div>
|
||||
<form id="forgot-form" autocomplete="on">
|
||||
<div class="field">
|
||||
<label for="forgot-username">Username or email</label>
|
||||
<input id="forgot-username" type="text" autocomplete="username" required>
|
||||
</div>
|
||||
<button id="forgot-submit" type="submit">Email me a reset link</button>
|
||||
</form>
|
||||
<p class="center"><a href="#" id="back-to-login" class="link">← Back to sign in</a></p>
|
||||
</section>
|
||||
|
||||
<!-- SET A NEW PASSWORD (arrived from the emailed link) -->
|
||||
<section id="view-reset" style="display:none">
|
||||
<h1>Set a new password</h1>
|
||||
<p class="sub">Choose a password you don't use anywhere else.</p>
|
||||
<form id="reset-form" autocomplete="on">
|
||||
<div class="field">
|
||||
<label for="new-password">New password</label>
|
||||
<input id="new-password" type="password" autocomplete="new-password" autofocus required>
|
||||
</div>
|
||||
<div class="hint">At least 12 characters.</div>
|
||||
<div class="field">
|
||||
<label for="new-password2">Confirm new password</label>
|
||||
<input id="new-password2" type="password" autocomplete="new-password" required>
|
||||
</div>
|
||||
<button id="reset-submit" type="submit">Set password & sign in</button>
|
||||
</form>
|
||||
<p class="center"><a href="#" id="reset-to-login" class="link">← Back to sign in</a></p>
|
||||
</section>
|
||||
<h1>Sign in</h1>
|
||||
<p class="sub">Work Package Suite uses your organization's Okta sign-in. Select the
|
||||
button below and follow the prompts there.</p>
|
||||
<a id="okta-signin" class="btn" href="/api/auth/okta/login" autofocus>Sign in with Okta</a>
|
||||
|
||||
<p class="foot">Authorized use only · BTG / Pilot</p>
|
||||
</main>
|
||||
|
||||
218
html/login.js
@@ -1,26 +1,16 @@
|
||||
/* Login page logic for the Work Package Suite.
|
||||
|
||||
Three views on one page:
|
||||
• sign in posts to /api/auth/login. On success the server sets an
|
||||
HttpOnly session cookie (not readable here — that's the
|
||||
point) and we redirect to ?next= or the home page.
|
||||
• forgot password posts to /api/auth/forgot-password, which emails a
|
||||
single-use link. Only offered when the server reports
|
||||
email is actually configured (/api/auth/reset-available);
|
||||
otherwise we say to ask an admin.
|
||||
• set a new password shown when the page is opened as login.html?reset=<token>
|
||||
from that email. Posts to /api/auth/reset-password.
|
||||
|
||||
The reset token stays in the URL only until it's used; on success we strip it
|
||||
from the address bar so it isn't left in history or copied out of the bar. */
|
||||
One action: sign in with Okta. There is no local password anymore (D15/D16,
|
||||
T10.4) — this page's only job is building the link to /api/auth/okta/login
|
||||
(carrying ?next=, if there was one) and showing a plain-language message for
|
||||
the failure states server/app.py's okta_callback() sends back here (T10.5). */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var errorBox = document.getElementById('error');
|
||||
var okBox = document.getElementById('ok');
|
||||
var signinLink = document.getElementById('okta-signin');
|
||||
|
||||
function show(el) { if (el) el.style.display = ''; }
|
||||
function hide(el) { if (el) el.style.display = 'none'; }
|
||||
function byId(id) { return document.getElementById(id); }
|
||||
|
||||
function showError(msg) {
|
||||
@@ -28,188 +18,38 @@
|
||||
errorBox.textContent = msg;
|
||||
errorBox.classList.add('show');
|
||||
}
|
||||
function showOk(msg) {
|
||||
errorBox.classList.remove('show');
|
||||
okBox.textContent = msg;
|
||||
okBox.classList.add('show');
|
||||
}
|
||||
function clearBanners() {
|
||||
errorBox.classList.remove('show');
|
||||
okBox.classList.remove('show');
|
||||
}
|
||||
|
||||
// Where to go after signing in: the ?next= param if it's a safe same-site
|
||||
// path, otherwise the home page. (Reject absolute/scheme URLs to avoid an
|
||||
// open-redirect.)
|
||||
function nextTarget() {
|
||||
// Same-site path only — mirrors the check server/app.py's _safe_next_path()
|
||||
// makes again on the way back, so a crafted ?next= can't become an open
|
||||
// redirect even if this client-side check were somehow bypassed.
|
||||
function safeNext() {
|
||||
try {
|
||||
var next = new URLSearchParams(location.search).get('next') || '';
|
||||
if (next && next.charAt(0) === '/' && next.charAt(1) !== '/') return next;
|
||||
} catch (e) {}
|
||||
return 'index.html';
|
||||
return '';
|
||||
}
|
||||
|
||||
function resetToken() {
|
||||
try { return new URLSearchParams(location.search).get('reset') || ''; } catch (e) { return ''; }
|
||||
if (signinLink) {
|
||||
var next = safeNext();
|
||||
if (next) signinLink.href = '/api/auth/okta/login?next=' + encodeURIComponent(next);
|
||||
}
|
||||
|
||||
function postJson(url, payload) {
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
}).then(function (r) {
|
||||
return r.json().catch(function () { return null; }).then(function (j) {
|
||||
return { status: r.status, ok: r.ok, json: j };
|
||||
});
|
||||
});
|
||||
}
|
||||
var ERROR_MESSAGES = {
|
||||
disabled: 'Your account has been disabled. Contact an administrator.',
|
||||
cancelled: 'Sign-in was not completed. Select the button below to try again.'
|
||||
};
|
||||
|
||||
function detail(res, fallback) {
|
||||
var d = res && res.json && res.json.detail;
|
||||
return (typeof d === 'string' && d) ? d : fallback;
|
||||
}
|
||||
|
||||
function view(which) {
|
||||
clearBanners();
|
||||
['login', 'forgot', 'reset'].forEach(function (v) {
|
||||
(which === v ? show : hide)(byId('view-' + v));
|
||||
});
|
||||
}
|
||||
|
||||
// ── sign in ────────────────────────────────────────────────────────────────
|
||||
var form = byId('login-form');
|
||||
var submitBtn = byId('submit');
|
||||
// Guarded because a cached older login.html may not have the reset views; an
|
||||
// unguarded addEventListener on null would break sign-in itself.
|
||||
if (!form || !submitBtn) return;
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
clearBanners();
|
||||
var username = byId('username').value.trim();
|
||||
var password = byId('password').value;
|
||||
if (!username || !password) { showError('Enter your username and password.'); return; }
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Signing in…';
|
||||
postJson('/api/auth/login', { username: username, password: password })
|
||||
.then(function (res) {
|
||||
if (res.ok) { location.replace(nextTarget()); return; }
|
||||
if (res.status === 401) showError('Invalid username or password.');
|
||||
else if (res.status === 403) showError(detail(res, 'Your account is disabled.'));
|
||||
else if (res.status === 429) showError(detail(res, 'Too many failed attempts. Try again later.'));
|
||||
else showError(detail(res, 'Sign-in failed (HTTP ' + res.status + ').'));
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Sign in';
|
||||
})
|
||||
.catch(function () {
|
||||
showError('Could not reach the server. Check your connection and try again.');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Sign in';
|
||||
});
|
||||
});
|
||||
|
||||
// ── forgot password ────────────────────────────────────────────────────────
|
||||
var resetAvailable = null; // null = not checked yet
|
||||
|
||||
function checkResetAvailable() {
|
||||
if (resetAvailable !== null) return Promise.resolve(resetAvailable);
|
||||
return fetch('/api/auth/reset-available')
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (j) { resetAvailable = !!(j && j.enabled); return resetAvailable; })
|
||||
.catch(function () { resetAvailable = false; return false; });
|
||||
}
|
||||
|
||||
(byId('forgot-link') || {addEventListener: function(){}}).addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
view('forgot');
|
||||
// Prefill from the sign-in box so nobody types their username twice.
|
||||
var u = byId('username').value.trim();
|
||||
if (u) byId('forgot-username').value = u;
|
||||
checkResetAvailable().then(function (enabled) {
|
||||
// With email off there's nothing to submit — say so and hide the form.
|
||||
(enabled ? hide : show)(byId('forgot-unavailable'));
|
||||
(enabled ? show : hide)(byId('forgot-form'));
|
||||
if (enabled) byId('forgot-username').focus();
|
||||
});
|
||||
});
|
||||
|
||||
(byId('back-to-login') || {addEventListener: function(){}}).addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
view('login');
|
||||
});
|
||||
|
||||
var forgotForm = byId('forgot-form') || document.createElement('form');
|
||||
var forgotBtn = byId('forgot-submit') || document.createElement('button');
|
||||
forgotForm.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
clearBanners();
|
||||
var who = byId('forgot-username').value.trim();
|
||||
if (!who) { showError('Enter your username or email.'); return; }
|
||||
forgotBtn.disabled = true;
|
||||
forgotBtn.textContent = 'Sending…';
|
||||
postJson('/api/auth/forgot-password', { username: who })
|
||||
.then(function (res) {
|
||||
if (res.status === 503) {
|
||||
showError(detail(res, "Password reset by email isn't available. Ask an administrator."));
|
||||
} else if (res.ok) {
|
||||
// Deliberately the same message whether or not the account exists.
|
||||
showOk('If that account exists, a reset link is on its way. The link expires in an hour.');
|
||||
hide(forgotForm);
|
||||
} else {
|
||||
showError(detail(res, 'Could not send the reset email (HTTP ' + res.status + ').'));
|
||||
}
|
||||
forgotBtn.disabled = false;
|
||||
forgotBtn.textContent = 'Email me a reset link';
|
||||
})
|
||||
.catch(function () {
|
||||
showError('Could not reach the server. Check your connection and try again.');
|
||||
forgotBtn.disabled = false;
|
||||
forgotBtn.textContent = 'Email me a reset link';
|
||||
});
|
||||
});
|
||||
|
||||
// ── set a new password (from the emailed link) ──────────────────────────────
|
||||
(byId('reset-to-login') || {addEventListener: function(){}}).addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
view('login');
|
||||
});
|
||||
|
||||
var resetForm = byId('reset-form') || document.createElement('form');
|
||||
var resetBtn = byId('reset-submit') || document.createElement('button');
|
||||
resetForm.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
clearBanners();
|
||||
var token = resetToken();
|
||||
var pw = byId('new-password').value;
|
||||
var pw2 = byId('new-password2').value;
|
||||
if (!token) { showError('This reset link is incomplete. Request a new one.'); return; }
|
||||
if (pw !== pw2) { showError('The two passwords do not match.'); return; }
|
||||
if (pw.length < 12) { showError('Password must be at least 12 characters.'); return; }
|
||||
|
||||
resetBtn.disabled = true;
|
||||
resetBtn.textContent = 'Saving…';
|
||||
postJson('/api/auth/reset-password', { token: token, new_password: pw })
|
||||
.then(function (res) {
|
||||
if (res.ok) {
|
||||
// Take the token out of the URL before anything else — it's spent.
|
||||
try { history.replaceState(null, '', 'login.html'); } catch (err) {}
|
||||
view('login');
|
||||
showOk('Password updated. Sign in with your new password.');
|
||||
byId('username').focus();
|
||||
return;
|
||||
}
|
||||
showError(detail(res, 'Could not set your password (HTTP ' + res.status + ').'));
|
||||
resetBtn.disabled = false;
|
||||
resetBtn.textContent = 'Set password & sign in';
|
||||
})
|
||||
.catch(function () {
|
||||
showError('Could not reach the server. Check your connection and try again.');
|
||||
resetBtn.disabled = false;
|
||||
resetBtn.textContent = 'Set password & sign in';
|
||||
});
|
||||
});
|
||||
|
||||
// Arriving from the reset email opens straight into the new-password view.
|
||||
if (resetToken()) view('reset');
|
||||
(function showErrorFromQuery() {
|
||||
try {
|
||||
var code = new URLSearchParams(location.search).get('error') || '';
|
||||
if (!code) return;
|
||||
showError(ERROR_MESSAGES[code] || 'Sign-in was not completed. Select the button below to try again.');
|
||||
// Out of the address bar once shown — an error code has no reason to
|
||||
// survive a refresh or get copied along with the link.
|
||||
var url = new URL(location.href);
|
||||
url.searchParams.delete('error');
|
||||
history.replaceState(null, '', url.pathname + url.search);
|
||||
} catch (e) {}
|
||||
})();
|
||||
})();
|
||||
|
||||
@@ -26,9 +26,6 @@
|
||||
room for "Assistant Project Manager" without pushing Actions off screen. */
|
||||
#users-table table td:nth-child(3){ max-width:230px; overflow:hidden; text-overflow:ellipsis; }
|
||||
#users-banner:not(:empty), #scope-banner:not(:empty){ margin-bottom:var(--s3); }
|
||||
/* The create form is a lot of fields; give the password one room to breathe and
|
||||
let the project picker take a full row of its own. */
|
||||
#nu-password{ flex:1 1 200px; }
|
||||
#nu-projects{ margin-top:var(--s2); }
|
||||
#nu-projects .pickrow{ padding:var(--s1) var(--s1); }
|
||||
/* A manager with one project doesn't need a scrolling picker; a manager with
|
||||
@@ -83,12 +80,12 @@
|
||||
<h2>Add a user</h2>
|
||||
<div class="sub" id="create-sub"></div>
|
||||
<div class="urow">
|
||||
<input id="nu-username" placeholder="Username *" autocomplete="off">
|
||||
<input id="nu-username" placeholder="Username *" autocomplete="off"
|
||||
title="Must exactly match this person's Okta sign-in identity — that's how their first Okta sign-in finds this account instead of creating a second one.">
|
||||
<input id="nu-fullname" placeholder="Full name" autocomplete="off">
|
||||
<input id="nu-email" placeholder="Email" autocomplete="off">
|
||||
<select id="nu-role" title="Permissions — what this account may do"></select>
|
||||
<select id="nu-project-role" title="Job function on the project"></select>
|
||||
<input id="nu-password" type="password" placeholder="Password (min 12)" autocomplete="new-password">
|
||||
</div>
|
||||
<div id="nu-projects">
|
||||
<div class="note" id="nu-projects-label" style="margin-bottom:var(--s1)"></div>
|
||||
|
||||
@@ -183,11 +183,9 @@ function managerRow(u){
|
||||
: projRoleReadonly(u, can, why);
|
||||
|
||||
const actions = [];
|
||||
if(can && !me) actions.push('<button class="mini" onclick="resetPw(\''+uid+'\',\''+uname+'\')">Reset password</button>');
|
||||
if(can && !me) actions.push('<button class="mini" onclick="toggleActive(\''+uid+'\','+(!u.is_active)+')">'+
|
||||
(u.is_active?'Disable':'Enable')+'</button>');
|
||||
if(can && !me) actions.push('<button class="mini danger" onclick="deleteUser(\''+uid+'\',\''+uname+'\')">Delete</button>');
|
||||
if(me) actions.push('<button class="mini" disabled title="Use the Password link in the top bar to change your own">—</button>');
|
||||
if(!can && !me) actions.push('<span class="note" style="margin:0" title="'+uesc(why)+'">read-only</span>');
|
||||
|
||||
return '<tr'+(can||me ? '' : ' class="is-locked"')+'>'+
|
||||
@@ -254,19 +252,6 @@ function projAccessCell(u){
|
||||
// ── row actions ───────────────────────────────────────────────────────────────
|
||||
// Each one reloads on failure so a control can never sit there showing a value the
|
||||
// server refused.
|
||||
async function resetPw(id, username){
|
||||
// The min-12 rule was stated in the prompt label and enforced only by the
|
||||
// server round-trip; the kit's validate() answers AT the input instead.
|
||||
const pw = await wpPromptDialog({title:'Reset password',
|
||||
message:'Set a new password for "'+username+'". Their existing sessions are signed out.',
|
||||
label:'New password (min 12 characters)',
|
||||
validate:v => (v && v.length >= 12) ? '' : 'At least 12 characters.'});
|
||||
if(pw === null) return;
|
||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/password',{new_password:pw});
|
||||
if(status === 200) toast('Password reset for '+username+'. Their existing sessions are signed out.');
|
||||
else wpAlertDialog({title:'Reset failed', message:'Could not reset the password: '+apiError(status, json)});
|
||||
}
|
||||
|
||||
async function toggleActive(id, makeActive){
|
||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/active',{is_active:makeActive});
|
||||
if(status === 200) loadUsers();
|
||||
@@ -344,25 +329,27 @@ function renderCreateForm(){
|
||||
async function createUser(){
|
||||
const msg = document.getElementById('users-create-msg');
|
||||
const val = id => (document.getElementById(id)||{}).value || '';
|
||||
// The username entered here MUST match what Okta's identity claim will send for
|
||||
// this person exactly — this creates the account ahead of their first sign-in,
|
||||
// and that's how a later Okta sign-in finds this row instead of provisioning a
|
||||
// second one. See create_user()'s docstring in server/app.py.
|
||||
const username = val('nu-username').trim();
|
||||
const password = val('nu-password');
|
||||
const project_ids = [...document.querySelectorAll('#nu-project-list input[type=checkbox]:checked')]
|
||||
.map(c => c.value);
|
||||
const say = (color, text) => { msg.style.color = color; msg.textContent = text; };
|
||||
if(!username){ say('var(--red)','Username is required.'); return; }
|
||||
if(password.length < 12){ say('var(--red)','Password must be at least 12 characters.'); return; }
|
||||
if(_scope.scope !== 'all' && !project_ids.length){
|
||||
say('var(--red)','Pick at least one project — you administer users per project.'); return;
|
||||
}
|
||||
say('var(--muted)','Creating…');
|
||||
const { status, json } = await api('POST','/api/auth/users',{
|
||||
username, password, project_ids,
|
||||
username, project_ids,
|
||||
full_name: val('nu-fullname').trim(), email: val('nu-email').trim(),
|
||||
role: val('nu-role'), project_role: val('nu-project-role'),
|
||||
});
|
||||
if(status === 200){
|
||||
say('var(--green)','✓ Created '+username+'.');
|
||||
['nu-username','nu-fullname','nu-email','nu-password'].forEach(id => document.getElementById(id).value = '');
|
||||
['nu-username','nu-fullname','nu-email'].forEach(id => document.getElementById(id).value = '');
|
||||
loadUsers();
|
||||
} else {
|
||||
say('var(--red)','✕ '+apiError(status, json, 'Could not create the account'));
|
||||
|
||||
@@ -105,6 +105,18 @@
|
||||
say(bits.join(''), problem);
|
||||
}
|
||||
|
||||
// A 500 answers plain text ("Internal Server Error"), and r.json() on that
|
||||
// throws - which used to land in catch() and read as "could not reach the
|
||||
// server" while the server was answering fine (found 2026-08-23, the
|
||||
// production locations import). Read text, parse if it parses, keep status.
|
||||
function readJson(r) {
|
||||
return r.text().then(function (t) {
|
||||
var j = null;
|
||||
try { j = t ? JSON.parse(t) : null; } catch (e) { /* not JSON: a proxy or 500 page */ }
|
||||
return { ok: r.ok, status: r.status, body: j };
|
||||
});
|
||||
}
|
||||
|
||||
function importText(dryRun) {
|
||||
var text = (el(p + '-paste') || {}).value || '';
|
||||
if (!text.trim()) { say('Paste some rows or choose a CSV file first.', true); return; }
|
||||
@@ -114,7 +126,7 @@
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify({ text: text, dry_run: !!dryRun }),
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); })
|
||||
.then(readJson)
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
say('⚠ Import refused — ' + esc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);
|
||||
@@ -142,7 +154,7 @@
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify(read.payload),
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); })
|
||||
.then(readJson)
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
setAddError((res.body && res.body.detail) || ('Could not add it (HTTP ' + res.status + ')'));
|
||||
@@ -161,7 +173,7 @@
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify(patchBody),
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); })
|
||||
.then(readJson)
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
say('⚠ ' + esc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
{ section: 'Account' },
|
||||
{ action: 'wpPreferences', icon: '◷', label: 'Language & time',
|
||||
sub: 'Dates, numbers and time zone' },
|
||||
{ action: 'wpChangePassword', icon: '⚿', label: 'Password', sub: 'Change your password' },
|
||||
];
|
||||
|
||||
function esc(v) {
|
||||
|
||||
@@ -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 <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) ─────────────────────────────────────────────
|
||||
# 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
|
||||
|
||||
@@ -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 <user> --role user`, `list`, `reset-password <user>`,
|
||||
`disable <user>`, `enable <user>`. After that, admins can add users through the
|
||||
API (or you can keep using the CLI).
|
||||
Other commands: `list`, `disable <user>`, `enable <user>`. 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":"<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.
|
||||
|
||||
@@ -52,6 +52,13 @@ def run_migrations_online() -> None:
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
# Each migration commits on its own. One transaction for the WHOLE
|
||||
# run meant a crash at step N rolled back steps 1..N-1 while their
|
||||
# "Running upgrade" lines stayed on screen claiming they ran - the
|
||||
# 2026-08-21 outage's stamp-to-head repair trusted those lines and
|
||||
# left production missing two tables (found 2026-08-23 when the
|
||||
# locations import 500'd on a table that "had been created").
|
||||
transaction_per_migration=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
29
server/alembic/versions/1d60a608bb51_drop_local_password.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""drop local password (T10.4, wave 10 / D15 / D16)
|
||||
|
||||
Real deletion, not a toggle: no local password exists anymore, only Okta OIDC.
|
||||
`password_hash` was NOT NULL at the database level since the baseline schema, so
|
||||
downgrade re-adds it with server_default='' rather than leaving existing rows
|
||||
without a value — the same pattern used for the locale/timezone drop-precedent
|
||||
columns, applied in reverse.
|
||||
|
||||
Revision ID: 1d60a608bb51
|
||||
Revises: a1b8c6d4e2f9
|
||||
Create Date: 2026-09-03 00:00:00.000000
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '1d60a608bb51'
|
||||
down_revision = 'a1b8c6d4e2f9'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_column('users', 'password_hash')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column('users', sa.Column('password_hash', sa.String(length=200), nullable=False, server_default=''))
|
||||
391
server/app.py
@@ -9,24 +9,27 @@ Run (prod): gunicorn -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 serve
|
||||
Interactive docs: http://<host>/api/docs
|
||||
"""
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import timedelta, timezone
|
||||
from time import monotonic
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from authlib.integrations.base_client import OAuthError
|
||||
from fastapi import FastAPI, Depends, HTTPException, Query, Request, Response, BackgroundTasks
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import select, delete, func
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from .db import Base, engine, get_db
|
||||
from . import models, auth, notify, assets_db
|
||||
from . import models, auth, notify, assets_db, okta_auth, okta_fake
|
||||
|
||||
log = logging.getLogger("wpsuite.app")
|
||||
|
||||
# Schema management:
|
||||
# • Local dev (SQLite) auto-creates tables for a zero-config run.
|
||||
@@ -56,6 +59,22 @@ if _origins:
|
||||
allow_methods=["*"], allow_headers=["*"], expose_headers=["X-Total-Count"],
|
||||
)
|
||||
|
||||
# Authlib's Okta client needs request.session to carry the OIDC state/nonce (and,
|
||||
# below, our own post-login redirect target) across the round trip to Okta and
|
||||
# back — it raises an AssertionError without this. Bug found in T10.2 (those
|
||||
# routes never crashed in testing because every prior check mocked
|
||||
# authorize_redirect/authorize_access_token directly, bypassing Authlib's real
|
||||
# implementation); fixed here rather than reworking already-shipped T10.2 code.
|
||||
#
|
||||
# This is NOT the app's session cookie — wp_session (auth.py) still carries the
|
||||
# actual signed-in identity, unchanged. This cookie holds nothing but ephemeral,
|
||||
# per-attempt OAuth state, so it gets a short lifetime and a plain secret reuse
|
||||
# (auth.SECRET_KEY) rather than its own required config knob.
|
||||
app.add_middleware(
|
||||
SessionMiddleware, secret_key=auth.SECRET_KEY, session_cookie="wp_oauth_state",
|
||||
same_site="lax", https_only=False, max_age=600,
|
||||
)
|
||||
|
||||
|
||||
# ── Authentication gate ────────────────────────────────────────────────────────
|
||||
# Every /api/ data route requires a valid session cookie. Login, health, and the
|
||||
@@ -605,14 +624,8 @@ def health():
|
||||
|
||||
|
||||
# ── Authentication ─────────────────────────────────────────────────────────────
|
||||
class LoginIn(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class NewUserIn(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
full_name: str = ""
|
||||
email: str = ""
|
||||
role: str = auth.ROLE_PROJECT_USER # permissions role — see auth.ROLES
|
||||
@@ -634,24 +647,6 @@ class PreferencesIn(BaseModel):
|
||||
timezone: Optional[str] = None
|
||||
|
||||
|
||||
class ForgotPasswordIn(BaseModel):
|
||||
username: str = "" # username or email
|
||||
|
||||
|
||||
class ResetPasswordIn(BaseModel):
|
||||
token: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class PasswordChangeIn(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class AdminPasswordIn(BaseModel):
|
||||
new_password: str
|
||||
|
||||
|
||||
class ActiveIn(BaseModel):
|
||||
is_active: bool
|
||||
|
||||
@@ -674,160 +669,143 @@ class AutoAddIn(BaseModel):
|
||||
role: str = ""
|
||||
|
||||
|
||||
LOGIN_MAX_ATTEMPTS = int(os.getenv("AUTH_MAX_ATTEMPTS", "5"))
|
||||
LOGIN_LOCKOUT_MINUTES = int(os.getenv("AUTH_LOCKOUT_MINUTES", "15"))
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
def login(body: LoginIn, request: Request, response: Response, db: Session = Depends(get_db)):
|
||||
"""Verify credentials and, on success, set the HttpOnly session cookie.
|
||||
Throttles online password guessing: after LOGIN_MAX_ATTEMPTS consecutive
|
||||
failures an account is locked for LOGIN_LOCKOUT_MINUTES."""
|
||||
user = auth.find_user(db, body.username)
|
||||
now = models.utcnow()
|
||||
# Always run the hash comparison first — even for missing or locked accounts —
|
||||
# so response timing doesn't leak which usernames exist. verify_password
|
||||
# tolerates an empty hash.
|
||||
valid = auth.verify_password(body.password, user.password_hash if user else "")
|
||||
locked = user.locked_until if user else None
|
||||
if locked is not None and locked.tzinfo is None:
|
||||
locked = locked.replace(tzinfo=timezone.utc) # SQLite returns naive datetimes; normalize to UTC
|
||||
if locked is not None and locked > now:
|
||||
raise HTTPException(status_code=429, detail="Too many failed attempts. Try again later.")
|
||||
if not user or not valid:
|
||||
if user:
|
||||
user.failed_attempts = (user.failed_attempts or 0) + 1
|
||||
if user.failed_attempts >= LOGIN_MAX_ATTEMPTS:
|
||||
user.locked_until = now + timedelta(minutes=LOGIN_LOCKOUT_MINUTES)
|
||||
user.failed_attempts = 0
|
||||
log_event(db, user.username, "login_locked", "user", user.id, summary=user.username,
|
||||
detail={"minutes": LOGIN_LOCKOUT_MINUTES})
|
||||
db.commit()
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="Account is disabled")
|
||||
user.failed_attempts = 0
|
||||
user.locked_until = None
|
||||
user.last_login_at = now
|
||||
db.commit()
|
||||
token = auth.create_token(user)
|
||||
auth.set_session_cookie(response, request, token)
|
||||
return {"user": user.to_dict()}
|
||||
|
||||
|
||||
@app.post("/api/auth/logout")
|
||||
def logout(response: Response):
|
||||
auth.clear_session_cookie(response)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Self-service password reset (needs email switched on) ──────────────────────
|
||||
RESET_COOLDOWN_SECONDS = int(os.getenv("AUTH_RESET_COOLDOWN_SECONDS", "120"))
|
||||
# In-process throttle: one reset mail per (account, client) per cooldown. Enough to
|
||||
# stop someone using the form to spam a colleague's inbox. Per-worker and lost on
|
||||
# restart — deliberately simple; the token expiry is the real control.
|
||||
_reset_last: dict[str, float] = {}
|
||||
# ── Okta OIDC sign-in (T10.2, wave 10 / D15) ────────────────────────────────────
|
||||
# Access gating is Okta's job: only accounts assigned to this app integration in Okta
|
||||
# can complete authorize_redirect at all. No app-side group/claim check is layered on
|
||||
# top here — see okta_auth.py's docstring and wave-10.md T10.2 for why.
|
||||
|
||||
def _safe_next_path(raw: str) -> str:
|
||||
"""A same-site path only — same rule login.js's own nextTarget() enforces
|
||||
client-side. Rejects absolute/scheme URLs ('//evil.com', 'https://evil.com')
|
||||
so a crafted ?next= can't turn a real Okta sign-in into an open redirect."""
|
||||
raw = (raw or "").strip()
|
||||
if raw and raw.startswith("/") and not raw.startswith("//"):
|
||||
return raw
|
||||
return ""
|
||||
|
||||
|
||||
def _reset_throttled(request: Request, username: str) -> bool:
|
||||
now = monotonic()
|
||||
key = f"{(username or '').strip().lower()}|{request.client.host if request.client else ''}"
|
||||
prev = _reset_last.get(key)
|
||||
if prev is not None and (now - prev) < RESET_COOLDOWN_SECONDS:
|
||||
return True
|
||||
_reset_last[key] = now
|
||||
if len(_reset_last) > 5000: # bound the dict on a long-lived worker
|
||||
cutoff = now - RESET_COOLDOWN_SECONDS
|
||||
for k in [k for k, t in _reset_last.items() if t < cutoff]:
|
||||
_reset_last.pop(k, None)
|
||||
return False
|
||||
@app.get("/api/auth/okta/login")
|
||||
async def okta_login(request: Request):
|
||||
"""Send the browser to Okta's authorize endpoint. Where to land afterward
|
||||
(?next=, e.g. from a deep link an assignment email carried — X1/CR-011/CR-014)
|
||||
rides in the OAuth-state session cookie alongside Authlib's own state/nonce,
|
||||
since nothing else survives the round trip to Okta and back."""
|
||||
if not okta_auth.oauth:
|
||||
raise HTTPException(status_code=503, detail="Sign-in is temporarily unavailable. Contact IT.")
|
||||
next_path = _safe_next_path(request.query_params.get("next", ""))
|
||||
if next_path:
|
||||
request.session["post_login_redirect"] = next_path
|
||||
return await okta_auth.oauth.okta.authorize_redirect(request, okta_auth.REDIRECT_URI)
|
||||
|
||||
|
||||
def reset_body(user: "models.User", link: str, minutes: int) -> str:
|
||||
# No account detail beyond the username, and no customer data — same rule as
|
||||
# the assignment mail. The link is the only sensitive thing in here.
|
||||
who = user.full_name or user.username
|
||||
return (
|
||||
f"Hi {who},\n\n"
|
||||
f"A password reset was requested for your Work Package Suite account "
|
||||
f"({user.username}).\n\n"
|
||||
f"Set a new password:\n{link}\n\n"
|
||||
f"The link expires in {minutes} minutes and can only be used once. "
|
||||
f"If you didn't request this, you can ignore this email — your current "
|
||||
f"password still works.\n"
|
||||
)
|
||||
@app.get("/api/auth/okta/callback")
|
||||
async def okta_callback(request: Request, db: Session = Depends(get_db)):
|
||||
"""Exchange the authorization code for tokens, validate the ID token, and sign the
|
||||
person in. T10.3: matches the identity claim to a local account, or JIT-provisions
|
||||
one, then issues the same session cookie login() does today.
|
||||
|
||||
Failure paths land back on the login page with a plain-language ?error= instead
|
||||
of a raw HTTPException — this route is reached by a full-page browser navigation
|
||||
from Okta, not a fetch() call, so a JSON error body is just a broken-looking page
|
||||
to whoever is sitting at the keyboard (T10.5)."""
|
||||
if not okta_auth.oauth:
|
||||
raise HTTPException(status_code=503, detail="Sign-in is temporarily unavailable. Contact IT.")
|
||||
try:
|
||||
token = await okta_auth.oauth.okta.authorize_access_token(request)
|
||||
except OAuthError as exc:
|
||||
log.warning("Okta callback rejected: %s", exc)
|
||||
return RedirectResponse(url="/login.html?error=cancelled", status_code=303)
|
||||
claims = token.get("userinfo") or {}
|
||||
identity = (claims.get(okta_auth.IDENTITY_CLAIM) or "").strip()
|
||||
if not identity:
|
||||
log.error("Okta ID token had no %r claim — check OKTA_IDENTITY_CLAIM", okta_auth.IDENTITY_CLAIM)
|
||||
raise HTTPException(status_code=503, detail="Sign-in is temporarily unavailable. Contact IT.")
|
||||
|
||||
@app.get("/api/auth/reset-available")
|
||||
def reset_available(db: Session = Depends(get_db)):
|
||||
"""Whether the login page should offer 'Forgot password'. Self-service reset
|
||||
depends entirely on outbound email, so it's off unless email is enabled AND
|
||||
SMTP is configured — otherwise the only route is an admin reset."""
|
||||
s = notify.get_settings(db)
|
||||
return {"enabled": bool(s.get("email_enabled")) and notify.smtp_ready(s)}
|
||||
|
||||
|
||||
@app.post("/api/auth/forgot-password")
|
||||
def forgot_password(body: ForgotPasswordIn, request: Request, db: Session = Depends(get_db)):
|
||||
"""Email a reset link. Always returns the same 200 response whether or not the
|
||||
account exists — this endpoint is unauthenticated, so it must not become a
|
||||
username/email oracle. Failures are recorded in the audit log instead."""
|
||||
s = notify.get_settings(db)
|
||||
if not (s.get("email_enabled") and notify.smtp_ready(s)):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Password reset by email isn't available. Ask an administrator to reset it for you.",
|
||||
user = auth.find_user(db, identity)
|
||||
if user is None:
|
||||
# JIT provisioning (D15). Okta is the only gate on WHO can reach this route
|
||||
# at all — this app still decides what a first-time sign-in may do. A new
|
||||
# account gets the lowest-privilege role and no project membership; an admin
|
||||
# or project super user grants access afterward, same as any account created
|
||||
# by hand today (create_user() above). No password field exists at all —
|
||||
# Okta is the only credential (D15/D16, real deletion as of T10.4).
|
||||
user = models.User(
|
||||
id=gen_id("user"),
|
||||
username=identity,
|
||||
email=(claims.get("email") or "").strip(),
|
||||
full_name=(claims.get("name") or "").strip(),
|
||||
role=auth.ROLE_PROJECT_USER,
|
||||
)
|
||||
if _reset_throttled(request, body.username):
|
||||
# Same shape as the success response — no oracle, no mail bomb.
|
||||
return {"ok": True, "message": "If that account exists, a reset link is on its way."}
|
||||
user = auth.find_user(db, body.username)
|
||||
if user and user.is_active and user.email:
|
||||
base = (s.get("app_base_url") or "").rstrip("/")
|
||||
token = auth.create_reset_token(user)
|
||||
link = f"{base}/login.html?reset={token}" if base else f"/login.html?reset={token}"
|
||||
sent = notify.send_now(
|
||||
db, user.email,
|
||||
"Work Package Suite — reset your password",
|
||||
reset_body(user, link, auth.RESET_MINUTES),
|
||||
)
|
||||
log_event(db, user.username, "password_reset_requested", "user", user.id,
|
||||
summary=user.username, detail={"emailed": bool(sent)})
|
||||
db.commit()
|
||||
else:
|
||||
# Log the miss for the admin's benefit; the caller can't tell the difference.
|
||||
log_event(db, "(anonymous)", "password_reset_miss", "user", "",
|
||||
summary=(body.username or "")[:200],
|
||||
detail={"reason": "no account, inactive, or no email on file"})
|
||||
db.commit()
|
||||
return {"ok": True, "message": "If that account exists, a reset link is on its way."}
|
||||
db.add(user)
|
||||
db.flush()
|
||||
log_event(db, user.username, "user_created", "user", user.id, summary=user.username,
|
||||
detail={"role": user.role, "via": "okta_jit"})
|
||||
elif not user.is_active:
|
||||
# Deprovisioning stays local (D15's "roles stay local"): Okta letting someone
|
||||
# through does not override an account this app has disabled. Same rule
|
||||
# login() enforced today, now surfaced as a login-page banner (T10.5)
|
||||
# instead of a raw 403 body, for the reason in this route's docstring.
|
||||
return RedirectResponse(url="/login.html?error=disabled", status_code=303)
|
||||
|
||||
|
||||
@app.post("/api/auth/reset-password")
|
||||
def reset_password(body: ResetPasswordIn, db: Session = Depends(get_db)):
|
||||
"""Complete a reset using the emailed token. The token carries the user's
|
||||
token_version, and finishing a reset bumps it — so the link is single-use and
|
||||
every existing session for that account is signed out."""
|
||||
claims = auth.decode_reset_token(body.token or "")
|
||||
if not claims:
|
||||
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired. Request a new one.")
|
||||
user = db.get(models.User, claims.get("sub"))
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(status_code=400, detail="This reset link is no longer valid.")
|
||||
if (claims.get("ver", 0) or 0) != (user.token_version or 0):
|
||||
raise HTTPException(status_code=400, detail="This reset link has already been used. Request a new one.")
|
||||
problem = auth.password_problem(body.new_password, user.username, user.email)
|
||||
if problem:
|
||||
raise HTTPException(status_code=400, detail=problem)
|
||||
user.password_hash = auth.hash_password(body.new_password)
|
||||
user.token_version = (user.token_version or 0) + 1 # burns the link + all sessions
|
||||
# A completed reset also clears any login lockout — the person has proven
|
||||
# control of the mailbox, so there's nothing left to throttle.
|
||||
user.failed_attempts = 0
|
||||
user.locked_until = None
|
||||
log_event(db, user.username, "password_reset", "user", user.id, summary=user.username)
|
||||
user.last_login_at = models.utcnow()
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
db.refresh(user)
|
||||
|
||||
tok = auth.create_token(user)
|
||||
target = _safe_next_path(request.session.pop("post_login_redirect", "")) or "/index.html"
|
||||
redirect = RedirectResponse(url=target, status_code=303)
|
||||
auth.set_session_cookie(redirect, request, tok)
|
||||
return redirect
|
||||
|
||||
|
||||
# ── Fake Okta test seam (T10.7) ──────────────────────────────────────────────
|
||||
# Registered ONLY when the fake is active — checked once, at import time, same
|
||||
# timing okta_auth.oauth itself is built at. In production these two routes do
|
||||
# not exist at all, not merely refuse a request: see server/okta_fake.py's
|
||||
# docstring for why that distinction matters given D16 leaves no other way in.
|
||||
if okta_fake.is_active():
|
||||
|
||||
@app.get("/api/auth/okta/_fake_provider")
|
||||
async def okta_fake_provider(request: Request):
|
||||
"""Stands in for Okta's own sign-in screen. A plain list of the
|
||||
identities WP_OKTA_FAKE_DIRECTORY defines, so a browser check drives a
|
||||
real page through a real round trip rather than skipping it."""
|
||||
state = request.query_params.get("state", "")
|
||||
redirect_uri = request.query_params.get("redirect_uri") or "/api/auth/okta/callback"
|
||||
from fastapi.responses import HTMLResponse
|
||||
return HTMLResponse(okta_fake.picker_page(state, redirect_uri))
|
||||
|
||||
@app.get("/api/auth/okta/_fake_provider/consent")
|
||||
async def okta_fake_consent(request: Request):
|
||||
"""What clicking an identity (or Deny) on the fake picker does: hands
|
||||
back an authorization code (or an error) at okta_callback, exactly the
|
||||
shape a real Okta redirect would carry. Everything after this — the
|
||||
state check, JIT provisioning, the disabled-account and open-redirect
|
||||
guards — is the real okta_callback() above, unmodified."""
|
||||
state = request.query_params.get("state", "")
|
||||
redirect_uri = request.query_params.get("redirect_uri") or "/api/auth/okta/callback"
|
||||
if request.query_params.get("deny"):
|
||||
return RedirectResponse(
|
||||
url=f"{redirect_uri}?error=access_denied&error_description=denied+by+fake+user&state={state}",
|
||||
status_code=303)
|
||||
username = request.query_params.get("username", "")
|
||||
entry = okta_fake.directory().get(username)
|
||||
if not isinstance(entry, dict):
|
||||
return RedirectResponse(
|
||||
url=f"{redirect_uri}?error=invalid_request&error_description=unknown+fake+identity&state={state}",
|
||||
status_code=303)
|
||||
claims = {okta_auth.IDENTITY_CLAIM: username,
|
||||
"email": entry.get("email", ""), "name": entry.get("name", "")}
|
||||
code = okta_fake.new_code(claims)
|
||||
return RedirectResponse(url=f"{redirect_uri}?code={code}&state={state}", status_code=303)
|
||||
|
||||
|
||||
@app.get("/api/auth/me")
|
||||
@@ -883,22 +861,6 @@ def set_preferences(body: PreferencesIn, user: models.User = Depends(auth.get_cu
|
||||
return {"user": user.to_dict()}
|
||||
|
||||
|
||||
@app.post("/api/auth/password")
|
||||
def change_password(body: PasswordChangeIn, request: Request, response: Response, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
if not auth.verify_password(body.current_password, user.password_hash):
|
||||
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
||||
problem = auth.password_problem(body.new_password, user.username, user.email)
|
||||
if problem:
|
||||
raise HTTPException(status_code=400, detail=problem)
|
||||
user.password_hash = auth.hash_password(body.new_password)
|
||||
user.token_version = (user.token_version or 0) + 1 # invalidate all OTHER existing sessions
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
# Keep this session logged in by re-issuing a cookie carrying the new version.
|
||||
auth.set_session_cookie(response, request, auth.create_token(user))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── User administration ─────────────────────────────────────────────────────────
|
||||
# Two kinds of caller reach these routes: an app admin, who manages every account,
|
||||
# and a Project Super User, who manages the accounts on the projects they administer.
|
||||
@@ -986,9 +948,14 @@ def user_scope(user: models.User = Depends(auth.get_current_user), db: Session =
|
||||
|
||||
@app.post("/api/auth/users")
|
||||
def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
|
||||
problem = auth.password_problem(body.password, body.username, body.email)
|
||||
if problem:
|
||||
raise HTTPException(status_code=400, detail=problem)
|
||||
"""Create an account ahead of its first Okta sign-in — e.g. to put it on
|
||||
projects or hand it a role before anyone has ever signed in as them.
|
||||
|
||||
`body.username` MUST match what Okta's identity claim will send for this
|
||||
person exactly (see OKTA_IDENTITY_CLAIM, server/okta_auth.py) — auth.find_user()
|
||||
is how a later Okta sign-in locates this row (T10.3). A mismatch doesn't
|
||||
fail loudly; it silently produces a second, JIT-provisioned account instead
|
||||
of signing this person into the one just created here."""
|
||||
allowed = grantable_roles(actor)
|
||||
if body.role not in allowed:
|
||||
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(allowed)}")
|
||||
@@ -1020,7 +987,6 @@ def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manag
|
||||
username=body.username.strip(),
|
||||
email=body.email.strip(),
|
||||
full_name=body.full_name.strip(),
|
||||
password_hash=auth.hash_password(body.password),
|
||||
role=body.role,
|
||||
project_role=body.project_role.strip()[:120],
|
||||
)
|
||||
@@ -1047,24 +1013,6 @@ def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manag
|
||||
return directory_entry(db, u, actor)
|
||||
|
||||
|
||||
@app.post("/api/auth/users/{user_id}/password")
|
||||
def admin_reset_password(user_id: str, body: AdminPasswordIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
|
||||
u = load_target_user(db, user_id)
|
||||
require_see_user(db, actor, u)
|
||||
require_manage_user(db, actor, u)
|
||||
problem = auth.password_problem(body.new_password, u.username, u.email)
|
||||
if problem:
|
||||
raise HTTPException(status_code=400, detail=problem)
|
||||
u.password_hash = auth.hash_password(body.new_password)
|
||||
u.token_version = (u.token_version or 0) + 1 # revoke the user's existing sessions
|
||||
# An administrative password reset was the one user-account change that left no
|
||||
# trace; it is the most impersonation-adjacent thing on this page, so it logs.
|
||||
log_event(db, actor, "password_reset", "user", u.id, summary=u.username,
|
||||
detail={"by": "administrator"})
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/auth/users/{user_id}/active")
|
||||
def set_user_active(user_id: str, body: ActiveIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
|
||||
u = load_target_user(db, user_id)
|
||||
@@ -2388,6 +2336,24 @@ def parse_location_rows(text: str) -> tuple[list[tuple[int, list[str]]], list[di
|
||||
rejected.append({"line": i, "text": line,
|
||||
"reason": "no letters or digits to make a code from"})
|
||||
continue
|
||||
# Postgres enforces VARCHAR lengths and refuses NUL/control bytes;
|
||||
# SQLite shrugs at both - which is how ONE bad CSV line 500'd the whole
|
||||
# production import (2026-08-23, BL-027's class again) instead of coming
|
||||
# back as a rejection with its line number. Validate per row, here,
|
||||
# so every dialect answers the same way: with a reason.
|
||||
if any(any(ord(ch) < 32 for ch in p) for p in parts):
|
||||
rejected.append({"line": i, "text": line[:120],
|
||||
"reason": "contains control characters — re-save the file as plain CSV (UTF-8)"})
|
||||
continue
|
||||
long_p = next((p for p in parts if len(p) > 200), None)
|
||||
if long_p is not None:
|
||||
rejected.append({"line": i, "text": line[:120],
|
||||
"reason": "a name is longer than 200 characters (%d)" % len(long_p)})
|
||||
continue
|
||||
if any(len(location_slug(p)) > 60 for p in parts):
|
||||
rejected.append({"line": i, "text": line[:120],
|
||||
"reason": "a code would be longer than 60 characters"})
|
||||
continue
|
||||
rows.append((i, parts))
|
||||
return rows, rejected
|
||||
|
||||
@@ -2454,6 +2420,7 @@ def import_locations(project_id: str, body: LocationImportIn,
|
||||
require_project_writable(db, user, project_id, "The location list cannot be changed")
|
||||
|
||||
rows, rejected = parse_location_rows(body.text)
|
||||
read_total = len(rows) + len(rejected)
|
||||
|
||||
existing = {n.path: n for n in db.scalars(
|
||||
select(models.LocationNode).where(models.LocationNode.project_id == project_id)
|
||||
@@ -2468,6 +2435,10 @@ def import_locations(project_id: str, body: LocationImportIn,
|
||||
for line_no, parts in rows:
|
||||
segs = [location_slug(p) for p in parts]
|
||||
full = "/".join(segs)
|
||||
if len(full) > 200:
|
||||
rejected.append({"line": line_no, "text": "/".join(parts)[:120],
|
||||
"reason": "the combined path is longer than 200 characters"})
|
||||
continue
|
||||
if full in seen_in_file:
|
||||
duplicates.append({"line": line_no, "path": full, "names": parts,
|
||||
"reason": "already on line %d of this import" % seen_in_file[full]})
|
||||
@@ -2510,7 +2481,7 @@ def import_locations(project_id: str, body: LocationImportIn,
|
||||
|
||||
result = {
|
||||
"project_id": project_id, "dry_run": bool(body.dry_run),
|
||||
"read": len(rows) + len(rejected),
|
||||
"read": read_total,
|
||||
"created": created, "duplicates": duplicates,
|
||||
"reactivated": reactivated, "rejected": rejected,
|
||||
}
|
||||
@@ -3007,6 +2978,18 @@ def parse_material_rows(text: str):
|
||||
rejected.append({"line": i, "text": raw.strip()[:120],
|
||||
"reason": "more than three columns - description, unit, code is the whole shape"})
|
||||
continue
|
||||
# Same guard as parse_location_rows: reject what Postgres would refuse
|
||||
# (VARCHAR limits, control bytes) with the line number, never a 500.
|
||||
if any(any(ord(ch) < 32 for ch in p) for p in parts):
|
||||
rejected.append({"line": i, "text": raw.strip()[:120],
|
||||
"reason": "contains control characters - re-save the file as plain CSV (UTF-8)"})
|
||||
continue
|
||||
caps = ((300, "description"), (20, "unit"), (80, "code"))
|
||||
long_col = next((("%s is longer than %d characters (%d)" % (label, cap, len(p)))
|
||||
for (cap, label), p in zip(caps, parts) if len(p) > cap), None)
|
||||
if long_col:
|
||||
rejected.append({"line": i, "text": raw.strip()[:120], "reason": long_col})
|
||||
continue
|
||||
rows.append((i, parts))
|
||||
return rows, rejected
|
||||
|
||||
|
||||
101
server/auth.py
@@ -1,10 +1,11 @@
|
||||
"""Authentication for the Work Package Suite.
|
||||
|
||||
A self-contained username/password login. Passwords are stored only as bcrypt
|
||||
hashes; a successful login issues a signed JWT that rides in an HttpOnly cookie
|
||||
(`wp_session`). Because the token is signed and self-validating, there is no
|
||||
server-side session store — every request is checked by verifying the cookie's
|
||||
signature and expiry (see `auth_gate` and `get_current_user`).
|
||||
Identity is confirmed by Okta (OIDC authorization-code flow, see server/okta_auth.py
|
||||
and the routes in server/app.py); there is no local password anywhere in this app
|
||||
(D15, D16 — T10.4 removed the last of it). A successful sign-in issues a signed JWT
|
||||
that rides in an HttpOnly cookie (`wp_session`). Because the token is signed and
|
||||
self-validating, there is no server-side session store — every request is checked by
|
||||
verifying the cookie's signature and expiry (see `auth_gate` and `get_current_user`).
|
||||
|
||||
Security model:
|
||||
• The real boundary is `auth_gate` (middleware in app.py): every /api/ data
|
||||
@@ -17,7 +18,9 @@ Security model:
|
||||
warning and invalidates every session on restart) so dev still works.
|
||||
|
||||
Permissions roles (`User.role`) — distinct from a person's job function on the
|
||||
project, which lives in `User.project_role` and grants nothing:
|
||||
project, which lives in `User.project_role` and grants nothing. Decided entirely
|
||||
locally: Okta gates WHO can authenticate at all, this app decides what an
|
||||
authenticated account may do — see D15/D16.
|
||||
• admin application administrator: user administration, app settings,
|
||||
and implicit access to every project.
|
||||
• project_super_user
|
||||
@@ -30,15 +33,13 @@ project, which lives in `User.project_role` and grants nothing:
|
||||
modify a SOP after it has been completed, and delete projects.
|
||||
• project_user normal member: creates and edits work packages, authors a SOP
|
||||
up to completion. May NOT delete WPs or change a completed SOP.
|
||||
Also where Okta JIT provisioning (T10.3) lands a brand-new
|
||||
account — the lowest-privilege role, promoted locally from
|
||||
there by an admin (see manage_users.py for the bootstrap case).
|
||||
|
||||
The user-administration SCOPE of a super user is worked out in server/app.py
|
||||
(`managed_project_ids`, `manage_user_problem`), because it depends on project
|
||||
membership rows — this module only decides which roles carry the power at all.
|
||||
|
||||
Password reset: a short-lived signed token (see `create_reset_token`) is emailed
|
||||
to the account's address. It is single-use by construction — it embeds the user's
|
||||
`token_version`, which is bumped when the password changes, so a used or
|
||||
superseded link stops validating.
|
||||
"""
|
||||
import os
|
||||
import secrets
|
||||
@@ -46,7 +47,6 @@ import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy import select, func
|
||||
@@ -59,10 +59,8 @@ log = logging.getLogger("wpsuite.auth")
|
||||
|
||||
COOKIE_NAME = "wp_session"
|
||||
JWT_ALG = "HS256"
|
||||
# How long a login lasts before the user must sign in again.
|
||||
# How long a session lasts before the person must sign in again.
|
||||
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12"))
|
||||
# How long an emailed password-reset link stays valid.
|
||||
RESET_MINUTES = int(os.getenv("AUTH_RESET_MINUTES", "60"))
|
||||
|
||||
# ── permissions roles ─────────────────────────────────────────────────────────
|
||||
ROLE_ADMIN = "admin"
|
||||
@@ -121,30 +119,7 @@ def is_project_admin(user: "models.User") -> bool:
|
||||
# same question used to exist here and silently disagreed with the scoped one, which
|
||||
# locked per-project super users out of the routes they were entitled to.
|
||||
|
||||
# Password policy (shared by the API and the CLI).
|
||||
MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12"))
|
||||
_COMMON_PASSWORDS = {
|
||||
"password", "password1", "password123", "passw0rd", "12345678", "123456789",
|
||||
"1234567890", "qwerty123", "letmein123", "changeme", "admin123", "welcome123",
|
||||
"iloveyou1", "abc12345", "qwertyuiop",
|
||||
}
|
||||
|
||||
|
||||
def password_problem(pw: str, username: str = "", email: str = "") -> Optional[str]:
|
||||
"""Return a human-readable reason the password is unacceptable, or None if OK.
|
||||
Shared by the API endpoints and the CLI so the policy is enforced everywhere."""
|
||||
if len(pw) < MIN_PASSWORD_LEN:
|
||||
return f"Password must be at least {MIN_PASSWORD_LEN} characters."
|
||||
low = pw.lower()
|
||||
if username and low == username.strip().lower():
|
||||
return "Password must not be the same as the username."
|
||||
if email and low == email.strip().lower():
|
||||
return "Password must not be the same as the email."
|
||||
if low in _COMMON_PASSWORDS:
|
||||
return "That password is too common — choose something less guessable."
|
||||
return None
|
||||
|
||||
# Paths under /api that do NOT require a session (login itself, health, docs).
|
||||
# Paths under /api that do NOT require a session (the Okta routes themselves, health, docs).
|
||||
_EXEMPT_PREFIXES = ("/api/auth/",)
|
||||
_EXEMPT_EXACT = {
|
||||
"/api/health",
|
||||
@@ -184,22 +159,6 @@ def _load_secret() -> str:
|
||||
SECRET_KEY = _load_secret()
|
||||
|
||||
|
||||
# ── password hashing ──────────────────────────────────────────────────────────
|
||||
def hash_password(plain: str) -> str:
|
||||
# bcrypt operates on at most 72 bytes; longer inputs are truncated by the
|
||||
# algorithm. Encode explicitly so non-ASCII passwords hash consistently.
|
||||
return bcrypt.hashpw(plain.encode("utf-8")[:72], bcrypt.gensalt()).decode("ascii")
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
if not hashed:
|
||||
return False
|
||||
try:
|
||||
return bcrypt.checkpw(plain.encode("utf-8")[:72], hashed.encode("ascii"))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
# ── tokens ──────────────────────────────────────────────────────────────────
|
||||
def create_token(user: "models.User") -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -221,39 +180,15 @@ def decode_token(token: str) -> Optional[dict]:
|
||||
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
# A password-reset token must never be usable as a session cookie.
|
||||
# No route issues a `typ`-carrying token anymore (that was the password-reset
|
||||
# token, removed in T10.4), but a session token still must never validate as
|
||||
# one — kept as a defensive check, cheap insurance against a future token type
|
||||
# riding the same cookie.
|
||||
if claims.get("typ"):
|
||||
return None
|
||||
return claims
|
||||
|
||||
|
||||
def create_reset_token(user: "models.User") -> str:
|
||||
"""Short-lived, single-use token for an emailed password-reset link.
|
||||
|
||||
Single-use falls out of `ver`: completing a reset bumps the user's
|
||||
token_version, so the link (and any older link) no longer validates."""
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"typ": "pwreset",
|
||||
"sub": user.id,
|
||||
"ver": user.token_version or 0,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(minutes=RESET_MINUTES),
|
||||
}
|
||||
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
|
||||
|
||||
|
||||
def decode_reset_token(token: str) -> Optional[dict]:
|
||||
"""Claims for a valid, unexpired reset token, else None."""
|
||||
try:
|
||||
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
if claims.get("typ") != "pwreset":
|
||||
return None
|
||||
return claims
|
||||
|
||||
|
||||
# ── cookie helpers ────────────────────────────────────────────────────────────
|
||||
def _is_https(request: Request) -> bool:
|
||||
# Behind NGINX, TLS is terminated at the proxy and forwarded as plain HTTP,
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
"""Command-line user management for the Work Package Suite.
|
||||
|
||||
Use this to create the FIRST admin account (the /api/auth/users endpoint needs an
|
||||
existing admin, so you have to bootstrap one here), and for occasional account
|
||||
There is no local password (D15) and no local account creation from here anymore
|
||||
(D16, T10.4) — accounts are created by signing in through Okta, which JIT-
|
||||
provisions a row at the lowest-privilege role (see server/okta_auth.py,
|
||||
server/app.py's okta_callback(), wave-10.md T10.3). This tool's job is narrower
|
||||
now: change the role on an account that already exists, and do routine account
|
||||
maintenance from a shell on the server.
|
||||
|
||||
That narrower job is still how the very first admin gets named (D16): have that
|
||||
person sign in through Okta once — they land as project_user — then promote them
|
||||
from here. Promoting an existing row, rather than creating one blind, matters
|
||||
because it never has to guess the exact string Okta will send as the identity
|
||||
claim; a hand-typed username that doesn't match it exactly would just produce a
|
||||
second, orphaned account instead of the one you meant to promote.
|
||||
|
||||
Run from the PROJECT ROOT (same place you run uvicorn), so the package imports
|
||||
and .env resolve the same way the API does:
|
||||
|
||||
python -m server.manage_users create-admin alice --name "Alice Smith"
|
||||
python -m server.manage_users create bob --role user --name "Bob Jones"
|
||||
python -m server.manage_users promote alice --role admin
|
||||
python -m server.manage_users list
|
||||
python -m server.manage_users reset-password alice
|
||||
python -m server.manage_users disable bob
|
||||
python -m server.manage_users enable bob
|
||||
|
||||
If --password is omitted you'll be prompted (input is hidden). Passwords must be
|
||||
at least 8 characters.
|
||||
"""
|
||||
import argparse
|
||||
import getpass
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
@@ -26,53 +30,48 @@ from .db import SessionLocal, Base, engine
|
||||
from . import models, auth
|
||||
|
||||
|
||||
def _gen_id() -> str:
|
||||
return f"user_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
def _prompt_password(provided: str | None, username: str = "") -> str:
|
||||
pw = provided
|
||||
if not pw:
|
||||
pw = getpass.getpass("New password: ")
|
||||
confirm = getpass.getpass("Confirm password: ")
|
||||
if pw != confirm:
|
||||
sys.exit("Passwords do not match.")
|
||||
problem = auth.password_problem(pw, username)
|
||||
if problem:
|
||||
sys.exit(problem)
|
||||
return pw
|
||||
|
||||
|
||||
def cmd_create(args, role: str | None = None) -> None:
|
||||
role = role or args.role
|
||||
# 'user' is the pre-roles spelling of 'project_user' and is still accepted so the
|
||||
# documented one-liners keep working; anything else has to be a current role.
|
||||
def cmd_promote(args) -> None:
|
||||
role = args.role
|
||||
# 'user' is the pre-roles spelling of 'project_user', accepted here so a
|
||||
# documented one-liner from before this rework keeps working.
|
||||
if role == "user":
|
||||
role = auth.ROLE_PROJECT_USER
|
||||
if role not in auth.ROLES:
|
||||
sys.exit(f"role must be one of {', '.join(auth.ROLES)}")
|
||||
pw = _prompt_password(getattr(args, "password", None), args.username)
|
||||
with SessionLocal() as db:
|
||||
if auth.find_user(db, args.username):
|
||||
sys.exit(f"A user named '{args.username}' already exists.")
|
||||
u = models.User(
|
||||
id=_gen_id(),
|
||||
username=args.username.strip(),
|
||||
full_name=(args.name or "").strip(),
|
||||
email=(args.email or "").strip(),
|
||||
password_hash=auth.hash_password(pw),
|
||||
role=role,
|
||||
)
|
||||
db.add(u)
|
||||
u = auth.find_user(db, args.username)
|
||||
if not u:
|
||||
sys.exit(
|
||||
f"No user named '{args.username}'. This promotes an existing account, it "
|
||||
f"doesn't create one — they need to sign in through Okta at least once first."
|
||||
)
|
||||
old_role = u.role
|
||||
u.role = role
|
||||
# Audited the same way a role change from the web Admin Console already is
|
||||
# (server/app.py's set_user_role() -> log_event(), action "role_changed") —
|
||||
# this command changes the same field and previously left no record of who
|
||||
# ran it or what it changed (T10.10). "actor" can't name a real person here:
|
||||
# a container shell exec carries no signed-in identity to attribute it to,
|
||||
# so it's tagged as the tool itself rather than guessing. "via" mirrors JIT
|
||||
# provisioning's own tag on user_created events.
|
||||
db.add(models.AuditLog(
|
||||
id=f"ev_{uuid.uuid4().hex[:12]}",
|
||||
actor="cli:manage_users",
|
||||
action="role_changed",
|
||||
entity_type="user",
|
||||
entity_id=u.id,
|
||||
summary=u.username,
|
||||
detail={"from": old_role, "to": role, "via": "cli"},
|
||||
))
|
||||
db.commit()
|
||||
print(f"Created {role}: {u.username} (id={u.id})")
|
||||
print(f"{u.username} is now {auth.ROLE_LABELS.get(role, role)}.")
|
||||
|
||||
|
||||
def cmd_list(args) -> None:
|
||||
with SessionLocal() as db:
|
||||
rows = db.query(models.User).order_by(models.User.username).all()
|
||||
if not rows:
|
||||
print("No users yet. Create one with: create-admin <username>")
|
||||
print("No users yet. Accounts appear here once someone signs in through Okta.")
|
||||
return
|
||||
print(f"{'USERNAME':<24}{'ROLE':<20}{'ACTIVE':<8}{'NAME'}")
|
||||
for u in rows:
|
||||
@@ -80,17 +79,6 @@ def cmd_list(args) -> None:
|
||||
f"{('yes' if u.is_active else 'no'):<8}{u.full_name}")
|
||||
|
||||
|
||||
def cmd_reset_password(args) -> None:
|
||||
pw = _prompt_password(getattr(args, "password", None), args.username)
|
||||
with SessionLocal() as db:
|
||||
u = auth.find_user(db, args.username)
|
||||
if not u:
|
||||
sys.exit(f"No user named '{args.username}'.")
|
||||
u.password_hash = auth.hash_password(pw)
|
||||
db.commit()
|
||||
print(f"Password reset for {u.username}.")
|
||||
|
||||
|
||||
def _set_active(username: str, active: bool) -> None:
|
||||
with SessionLocal() as db:
|
||||
u = auth.find_user(db, username)
|
||||
@@ -108,39 +96,23 @@ def main() -> None:
|
||||
p = argparse.ArgumentParser(prog="manage_users", description="Work Package Suite user management")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
def add_create(name, help_):
|
||||
sp = sub.add_parser(name, help=help_)
|
||||
sp.add_argument("username")
|
||||
sp.add_argument("--password", help="set non-interactively (otherwise prompted)")
|
||||
sp.add_argument("--name", default="", help="full name")
|
||||
sp.add_argument("--email", default="")
|
||||
return sp
|
||||
|
||||
add_create("create-admin", "create an admin account")
|
||||
c = add_create("create", "create an account")
|
||||
c.add_argument("--role", choices=list(auth.ROLES) + ["user"], default=auth.ROLE_PROJECT_USER,
|
||||
help="permissions role ('user' is the legacy name for project_user)")
|
||||
pr = sub.add_parser("promote", help="change an existing account's role (e.g. name the first admin)")
|
||||
pr.add_argument("username")
|
||||
pr.add_argument("--role", required=True, choices=list(auth.ROLES) + ["user"],
|
||||
help="permissions role ('user' is the legacy name for project_user)")
|
||||
|
||||
sub.add_parser("list", help="list all accounts")
|
||||
|
||||
rp = sub.add_parser("reset-password", help="reset a user's password")
|
||||
rp.add_argument("username")
|
||||
rp.add_argument("--password", help="set non-interactively (otherwise prompted)")
|
||||
|
||||
dp = sub.add_parser("disable", help="disable an account (blocks login)")
|
||||
dp = sub.add_parser("disable", help="disable an account (blocks sign-in)")
|
||||
dp.add_argument("username")
|
||||
ep = sub.add_parser("enable", help="re-enable an account")
|
||||
ep.add_argument("username")
|
||||
|
||||
args = p.parse_args()
|
||||
if args.cmd == "create-admin":
|
||||
cmd_create(args, role="admin")
|
||||
elif args.cmd == "create":
|
||||
cmd_create(args)
|
||||
if args.cmd == "promote":
|
||||
cmd_promote(args)
|
||||
elif args.cmd == "list":
|
||||
cmd_list(args)
|
||||
elif args.cmd == "reset-password":
|
||||
cmd_reset_password(args)
|
||||
elif args.cmd == "disable":
|
||||
_set_active(args.username, False)
|
||||
elif args.cmd == "enable":
|
||||
|
||||
@@ -142,8 +142,10 @@ class WorkPackage(Base):
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""A login account. Passwords are never stored in the clear — only a bcrypt
|
||||
hash (see server/auth.py). `username` is what people sign in with.
|
||||
"""A login account. No password is stored here or anywhere else — identity is
|
||||
confirmed by Okta (OIDC), this app only decides what the account may do once
|
||||
Okta has vouched for it (see server/okta_auth.py, server/auth.py, D15/D16).
|
||||
`username` is what Okta's identity claim resolves to.
|
||||
|
||||
Two independent notions of "role", deliberately separate:
|
||||
• role the PERMISSIONS role — what the account may do in the app.
|
||||
@@ -159,7 +161,6 @@ class User(Base):
|
||||
username: Mapped[str] = mapped_column(String(120), unique=True, index=True)
|
||||
email: Mapped[str] = mapped_column(String(200), default="")
|
||||
full_name: Mapped[str] = mapped_column(String(200), default="")
|
||||
password_hash: Mapped[str] = mapped_column(String(200), default="")
|
||||
role: Mapped[str] = mapped_column(String(20), default="project_user") # permissions role
|
||||
# Job function on the project — free text, offered from a suggested list.
|
||||
project_role: Mapped[str] = mapped_column(String(120), default="")
|
||||
|
||||
109
server/okta_auth.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Okta OIDC client configuration — T10.1, wave 10 (D15).
|
||||
|
||||
This module owns the conversation with Okta and nothing else: it does not issue this
|
||||
app's own session cookie, does not touch the database, and does not decide who may sign
|
||||
in. `server/auth.py` keeps doing all of that, unchanged — a session is still a signed JWT
|
||||
in an HttpOnly cookie, roles are still local, `get_current_user` still re-reads the
|
||||
account on every request. Only how a person's identity gets confirmed changes: an Okta
|
||||
authorization-code flow, in place of the local username/password check.
|
||||
|
||||
Access gating is Okta's job, not this module's. Only accounts assigned to this app
|
||||
integration in Okta ever complete the flow at all, so there is no required-group or
|
||||
claim check layered on top here — see D15 and wave-10.md for why that is a deliberate
|
||||
difference from D13's design, not an oversight.
|
||||
|
||||
Unconfigured is a first-class state, same discipline as the LDAPS module this replaces:
|
||||
with any of the four settings below missing, `is_configured()` is False and `describe()`
|
||||
says so in the startup log, rather than the app discovering it later at the login button.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
log = logging.getLogger("wpsuite.okta")
|
||||
|
||||
try:
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
HAVE_AUTHLIB = True
|
||||
except ImportError: # pragma: no cover
|
||||
HAVE_AUTHLIB = False
|
||||
OAuth = None # type: ignore[assignment]
|
||||
|
||||
# ── configuration ─────────────────────────────────────────────────────────────
|
||||
# The Okta *authorization server* issuer, e.g. https://primecontrols.okta.com/oauth2/default
|
||||
# or a custom authorization server URL. Authlib discovers the rest (authorize/token/
|
||||
# jwks endpoints) from `<issuer>/.well-known/openid-configuration` — nothing below is
|
||||
# hand-entered except this base URL, the client credentials, and our own callback.
|
||||
ISSUER = os.getenv("OKTA_ISSUER", "")
|
||||
CLIENT_ID = os.getenv("OKTA_CLIENT_ID", "")
|
||||
CLIENT_SECRET = os.getenv("OKTA_CLIENT_SECRET", "")
|
||||
# Must exactly match a Sign-in redirect URI registered on the Okta app integration.
|
||||
# e.g. https://wp.controls.dev/api/auth/okta/callback
|
||||
REDIRECT_URI = os.getenv("OKTA_REDIRECT_URI", "")
|
||||
|
||||
# Standard OIDC identity scopes only. No group/role scopes: T10.2's note above explains
|
||||
# why access gating and permissions both stay out of the token.
|
||||
SCOPES = "openid profile email"
|
||||
|
||||
# Which claim in the ID token carries this person's AD sAMAccountName equivalent, for
|
||||
# matching against the local `users` table (T10.3). Not yet confirmed by security —
|
||||
# `preferred_username` is Okta's usual default for an AD-imported user, used here as a
|
||||
# documented placeholder, NOT a verified answer. Override via env once security replies
|
||||
# so the real value can drop in without a code change.
|
||||
IDENTITY_CLAIM = os.getenv("OKTA_IDENTITY_CLAIM", "preferred_username")
|
||||
|
||||
|
||||
def is_configured() -> bool:
|
||||
"""Whether an OIDC flow could even be attempted. Deliberately does not touch the
|
||||
network — that would be a `selftest()`, added when T10.2 needs one."""
|
||||
return bool(HAVE_AUTHLIB and ISSUER and CLIENT_ID and CLIENT_SECRET and REDIRECT_URI)
|
||||
|
||||
|
||||
def describe() -> str:
|
||||
"""One line for the startup log, matching the LDAPS module's discipline: an
|
||||
unconfigured deploy must be visible in `docker compose logs api`, not discovered at
|
||||
the login button."""
|
||||
from . import okta_fake
|
||||
if okta_fake.is_active():
|
||||
return (f"*** FAKE OKTA PROVIDER ACTIVE — identities come from "
|
||||
f"{okta_fake.ENV_VAR}, NOT from Okta. Tests only. ***")
|
||||
if not HAVE_AUTHLIB:
|
||||
return "Okta auth DISABLED — authlib is not installed. No one can sign in."
|
||||
missing = [name for name, val in (
|
||||
("OKTA_ISSUER", ISSUER), ("OKTA_CLIENT_ID", CLIENT_ID),
|
||||
("OKTA_CLIENT_SECRET", CLIENT_SECRET), ("OKTA_REDIRECT_URI", REDIRECT_URI),
|
||||
) if not val]
|
||||
if missing:
|
||||
return f"Okta auth DISABLED — missing {', '.join(missing)}. No one can sign in."
|
||||
return (f"Okta auth enabled — issuer {ISSUER}, redirect {REDIRECT_URI}, "
|
||||
f"identity claim {IDENTITY_CLAIM!r}")
|
||||
|
||||
|
||||
def _build_oauth():
|
||||
"""Register the Okta client. Returns None when unconfigured so the caller (T10.2's
|
||||
routes) can fail loudly instead of Authlib raising deep inside a request.
|
||||
|
||||
Checked before `is_configured()`: the fake (T10.7) needs none of the four real
|
||||
Okta settings, and must win whenever it is legitimately active so a test run
|
||||
never has to also fill in placeholder OKTA_ISSUER/CLIENT_ID/etc. `is_active()`
|
||||
already refuses outside a SQLite-backed test database — see okta_fake.py."""
|
||||
from . import okta_fake
|
||||
if okta_fake.is_active():
|
||||
log.warning("*** FAKE OKTA PROVIDER ACTIVE (%s) — tests only ***", okta_fake.ENV_VAR)
|
||||
return okta_fake.build()
|
||||
if not is_configured():
|
||||
return None
|
||||
oauth = OAuth()
|
||||
oauth.register(
|
||||
name="okta",
|
||||
client_id=CLIENT_ID,
|
||||
client_secret=CLIENT_SECRET,
|
||||
server_metadata_url=f"{ISSUER.rstrip('/')}/.well-known/openid-configuration",
|
||||
client_kwargs={"scope": SCOPES},
|
||||
)
|
||||
return oauth
|
||||
|
||||
|
||||
# Built once at import time, same as `SECRET_KEY` in auth.py — a missing/bad config is a
|
||||
# deploy problem to catch at startup via `describe()`, not a per-request surprise.
|
||||
oauth = _build_oauth()
|
||||
190
server/okta_fake.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""A fake Okta, for tests only — T10.7.
|
||||
|
||||
`server/okta_auth.py` normally hands the browser off to a real Okta authorize
|
||||
endpoint and exchanges the code with Okta's token endpoint over the network
|
||||
(Authlib discovers both from `<issuer>/.well-known/openid-configuration`). Tests
|
||||
cannot reach any of that: there is no live Okta tenant in CI, and the browser
|
||||
checks launch the app as a SUBPROCESS (`start_server` in tests/browser_check.py),
|
||||
so a monkeypatch in the test process would never reach the code doing the
|
||||
authenticating. The seam has to be configurable from the ENVIRONMENT — same
|
||||
discipline as `server/ldap_fake.py`, D13/T10.7's LDAP predecessor.
|
||||
|
||||
Set `WP_OKTA_FAKE_DIRECTORY` to a JSON object and this module stands in for the
|
||||
whole round trip — the authorize redirect, a stand-in "sign in at Okta" screen,
|
||||
and the token exchange — with no network call anywhere:
|
||||
|
||||
{"root": {"email": "root@example.test", "name": "Root Person"}}
|
||||
|
||||
The key is the identity value a real Okta ID token would carry in whichever
|
||||
claim `OKTA_IDENTITY_CLAIM` names (default `preferred_username`) — the fake
|
||||
reads `okta_auth.IDENTITY_CLAIM` at request time, so it exercises whatever claim
|
||||
name is actually configured rather than a hard-coded one.
|
||||
|
||||
WHAT THIS DOES AND DOES NOT REPLACE
|
||||
|
||||
Only the OAuth-protocol plumbing that talks to Okta over the network is faked —
|
||||
`authorize_redirect` and `authorize_access_token`, both owned by Authlib, a
|
||||
third-party library, not this app's security logic. Everything this app itself
|
||||
decides stays real and untouched in `app.py`'s `okta_login()`/`okta_callback()`:
|
||||
the `?next=` open-redirect guard (`_safe_next_path`), the disabled-account
|
||||
check, JIT provisioning, and which claim carries identity. A fake run exercises
|
||||
the actual code for all of that, not a re-implementation of it — the same
|
||||
boundary `ldap_fake.py` drew around the anonymous-bind guard.
|
||||
|
||||
THE PRODUCTION GUARD IS THE POINT OF THIS FILE.
|
||||
|
||||
An environment variable that lets anyone "sign in" as any identity by visiting a
|
||||
picker page is exactly the kind of thing that must never be reachable outside a
|
||||
test process — D16 leaves no local password fallback and no break-glass, so a
|
||||
fake provider silently active in production would be a total authentication
|
||||
bypass with a friendlier UI than most. `is_active()` refuses whenever a real
|
||||
database is configured, using the same test `auth._load_secret` and
|
||||
`ldap_fake.is_active()` already use: a non-SQLite `DATABASE_URL` means
|
||||
production, full stop. `okta_auth.describe()` also shouts when the fake is
|
||||
live, and `app.py` registers the picker/consent routes only when the fake is
|
||||
active at import time — in production they do not exist, not merely refuse.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from html import escape
|
||||
from typing import Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
log = logging.getLogger("wpsuite.okta.fake")
|
||||
|
||||
ENV_VAR = "WP_OKTA_FAKE_DIRECTORY"
|
||||
|
||||
# One-time authorization codes, in-process only. The login and the callback that
|
||||
# redeems the code both happen inside the SAME uvicorn process within one test
|
||||
# run, so this needs no more durability than that — the server restart every
|
||||
# check does between runs clears it for free. Not a cache: entries are popped on
|
||||
# first use (below) and expire on their own otherwise.
|
||||
_CODE_TTL_SECONDS = 120
|
||||
_PENDING_CODES: dict = {}
|
||||
|
||||
|
||||
def _raw() -> str:
|
||||
return os.getenv(ENV_VAR, "").strip()
|
||||
|
||||
|
||||
def is_active() -> bool:
|
||||
"""Whether the fake should answer. False in anything resembling production."""
|
||||
if not _raw():
|
||||
return False
|
||||
# Imported lazily: server.db reads DATABASE_URL at import, and this module is
|
||||
# imported from okta_auth (and app.py), which must stay importable on its own.
|
||||
from .db import DATABASE_URL
|
||||
if not str(DATABASE_URL).startswith("sqlite"):
|
||||
log.error(
|
||||
"%s is set but a non-SQLite DATABASE_URL is configured. REFUSING to use "
|
||||
"the fake Okta provider — this looks like production, and D16 leaves no "
|
||||
"other way in, so honouring it would be an authentication bypass. "
|
||||
"Unset %s.", ENV_VAR, ENV_VAR)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def directory() -> dict:
|
||||
try:
|
||||
data = json.loads(_raw())
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("top level must be an object")
|
||||
return data
|
||||
except Exception as exc: # noqa: BLE001 — a malformed fake must not look auth-shaped
|
||||
log.error("%s is not valid JSON (%s); the fake directory is empty", ENV_VAR, exc)
|
||||
return {}
|
||||
|
||||
|
||||
def new_code(claims: dict) -> str:
|
||||
code = secrets.token_urlsafe(24)
|
||||
_PENDING_CODES[code] = {"claims": claims, "expires": time.time() + _CODE_TTL_SECONDS}
|
||||
return code
|
||||
|
||||
|
||||
def consume_code(code: str) -> Optional[dict]:
|
||||
"""Pop and return the claims for a code, or None if unknown/expired/reused.
|
||||
Popping makes the code single-use, matching a real authorization code."""
|
||||
entry = _PENDING_CODES.pop(code, None)
|
||||
if not entry or entry["expires"] < time.time():
|
||||
return None
|
||||
return entry["claims"]
|
||||
|
||||
|
||||
def picker_page(state: str, redirect_uri: str) -> str:
|
||||
"""The fake's stand-in for Okta's own sign-in screen — a plain list of the
|
||||
identities `WP_OKTA_FAKE_DIRECTORY` defines, so a browser check can click
|
||||
through a real page rather than skip the round trip with a minted cookie.
|
||||
Deliberately plain: nothing here is styled to resemble a real Okta page."""
|
||||
from . import okta_auth
|
||||
rows = []
|
||||
for username in directory():
|
||||
href = (f"/api/auth/okta/_fake_provider/consent?state={quote(state)}"
|
||||
f"&redirect_uri={quote(redirect_uri, safe='')}&username={quote(username)}")
|
||||
rows.append(
|
||||
f'<li><a id="okta-fake-identity-{escape(username)}" href="{escape(href, quote=True)}">'
|
||||
f'Continue as {escape(username)}</a></li>')
|
||||
deny_href = (f"/api/auth/okta/_fake_provider/consent?state={quote(state)}"
|
||||
f"&redirect_uri={quote(redirect_uri, safe='')}&deny=1")
|
||||
deny_href = escape(deny_href, quote=True)
|
||||
return (
|
||||
"<!doctype html><title>FAKE Okta — tests only</title>"
|
||||
"<h1>*** FAKE OKTA PROVIDER — TESTS ONLY ***</h1>"
|
||||
f"<p>Identity claim in use: <code>{escape(okta_auth.IDENTITY_CLAIM)}</code></p>"
|
||||
"<ul>" + "".join(rows) + "</ul>"
|
||||
f'<p><a id="okta-fake-deny" href="{deny_href}">Deny access</a></p>'
|
||||
)
|
||||
|
||||
|
||||
class _FakeOktaClient:
|
||||
"""Stands in for Authlib's `oauth.okta` — the same two methods `app.py`
|
||||
calls, the same async signatures, zero network calls."""
|
||||
|
||||
async def authorize_redirect(self, request, redirect_uri):
|
||||
state = secrets.token_urlsafe(24)
|
||||
# The only session write this fake makes. authorize_access_token below is
|
||||
# the only read — mirrors exactly what real Authlib does with `state`,
|
||||
# which is what T10.2's missing-SessionMiddleware bug was about: this
|
||||
# round trip is a genuine test of the same plumbing.
|
||||
request.session["_okta_fake_state"] = state
|
||||
target = redirect_uri or "/api/auth/okta/callback"
|
||||
url = (f"/api/auth/okta/_fake_provider?state={quote(state)}"
|
||||
f"&redirect_uri={quote(target, safe='')}")
|
||||
return RedirectResponse(url=url, status_code=302)
|
||||
|
||||
async def authorize_access_token(self, request):
|
||||
from authlib.integrations.base_client import OAuthError
|
||||
expected = request.session.pop("_okta_fake_state", None)
|
||||
given = request.query_params.get("state", "")
|
||||
if not expected or given != expected:
|
||||
raise OAuthError(
|
||||
error="invalid_state",
|
||||
description="fake Okta: state did not match the session (T10.7 seam)")
|
||||
err = request.query_params.get("error")
|
||||
if err:
|
||||
raise OAuthError(
|
||||
error=err,
|
||||
description=request.query_params.get("error_description", "denied"))
|
||||
code = request.query_params.get("code", "")
|
||||
claims = consume_code(code)
|
||||
if claims is None:
|
||||
raise OAuthError(error="invalid_grant",
|
||||
description="fake Okta: unknown or expired code")
|
||||
return {"userinfo": claims}
|
||||
|
||||
|
||||
class FakeOAuth:
|
||||
"""Stands in for Authlib's `OAuth()` registry. The real one exposes each
|
||||
registered client as an attribute by name; `app.py` only ever touches
|
||||
`.okta`, so that is the only attribute this needs."""
|
||||
|
||||
def __init__(self):
|
||||
self.okta = _FakeOktaClient()
|
||||
|
||||
|
||||
def build() -> "FakeOAuth":
|
||||
return FakeOAuth()
|
||||
@@ -17,6 +17,9 @@ pymssql==2.3.13 # read-only lookups against the Micron asset DB (SQL
|
||||
# MICRON_DB_URL to mssql+pyodbc://…?driver=ODBC+Driver+18+for+SQL+Server
|
||||
pydantic==2.13.4
|
||||
python-dotenv==1.2.2
|
||||
bcrypt==5.0.0 # password hashing
|
||||
PyJWT==2.13.0 # signed session tokens
|
||||
starlette==1.3.1 # pinned transitive (cookie / CORS handling — security-relevant)
|
||||
Authlib==1.7.2 # Okta OIDC authorization-code flow (T10.1, wave 10 / D15)
|
||||
httpx==0.28.1 # Authlib's OIDC client needs an HTTP client; explicit, not transitive
|
||||
itsdangerous==2.2.0 # signs the OAuth-state cookie SessionMiddleware sets — required by
|
||||
# Authlib's authorize_redirect/authorize_access_token, not optional
|
||||
|
||||
@@ -9,15 +9,17 @@ and to have data to inspect.
|
||||
|
||||
Every /api/ route except /api/health requires a session, so this signs in first and
|
||||
keeps the session cookie for the rest of the run — the same way server/smoketest.py
|
||||
does, reusing its opener rather than growing a second implementation of it.
|
||||
Credentials come from the environment so the password never has to appear in a
|
||||
command line or shell history:
|
||||
does, reusing its opener AND its session-minting (not a second implementation).
|
||||
|
||||
There is no local password anymore (D15/D16, T10.4) — see smoketest.py's own
|
||||
AUTHENTICATION section for why and what that means: this needs to run where it can
|
||||
read the SAME AUTH_SECRET_KEY and reach the SAME database as the server under test,
|
||||
and the account must already exist (this seeds a project, not a user).
|
||||
|
||||
export WP_SEED_USER=<admin-account> # or WP_SMOKE_USER, which is reused
|
||||
export WP_SEED_PASSWORD='…' # or WP_SMOKE_PASSWORD
|
||||
|
||||
…or pass --user / --password. Use an admin account: seeding creates a project, and
|
||||
--clean deletes one, which needs Project Admin on it.
|
||||
…or pass --user. Use an admin account: seeding creates a project, and --clean
|
||||
deletes one, which needs Project Admin on it.
|
||||
|
||||
USAGE
|
||||
python3 server/seed_demo.py https://wp-suite.company.local --insecure
|
||||
@@ -48,16 +50,19 @@ import urllib.error
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
# So `from server import auth, models` / `from server.db import SessionLocal` also
|
||||
# resolve (needed to mint a session — see AUTHENTICATION above).
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# The session handling is smoketest.py's, imported rather than copied: one cookie
|
||||
# jar implementation, one login flow, one place to fix. Importing is safe — that
|
||||
# module does its work under `if __name__ == "__main__"`.
|
||||
from smoketest import build_opener # noqa: E402
|
||||
# jar implementation, one session-minting flow, one place to fix. Importing is
|
||||
# safe — that module does its work under `if __name__ == "__main__"`.
|
||||
from smoketest import build_opener, seed_session_cookie # noqa: E402
|
||||
|
||||
BASE = ""
|
||||
CTX = None
|
||||
# Carries the cookie jar holding the session issued by /api/auth/login. This
|
||||
# script used to call urllib.request.urlopen() directly, which has no cookie
|
||||
# Carries the cookie jar holding the minted session (see AUTHENTICATION above).
|
||||
# This script used to call urllib.request.urlopen() directly, which has no cookie
|
||||
# support, so the session was dropped and every data route answered 401 (S13).
|
||||
OPENER = None
|
||||
DEMO_NUMBER = "DEMO-001" # project number prefix used to find/clean demo data
|
||||
@@ -116,29 +121,22 @@ def main():
|
||||
ap.add_argument("--insecure", action="store_true", help="skip TLS verification")
|
||||
ap.add_argument("--clean", action="store_true", help="delete existing DEMO-* projects and exit")
|
||||
ap.add_argument("--user", default=os.getenv("WP_SEED_USER", "") or os.getenv("WP_SMOKE_USER", ""),
|
||||
help="account to sign in as (default: $WP_SEED_USER, then $WP_SMOKE_USER). "
|
||||
"Use an admin account.")
|
||||
ap.add_argument("--password",
|
||||
default=os.getenv("WP_SEED_PASSWORD", "") or os.getenv("WP_SMOKE_PASSWORD", ""),
|
||||
help="its password (default: $WP_SEED_PASSWORD, then $WP_SMOKE_PASSWORD — "
|
||||
"preferred, so it stays out of shell history)")
|
||||
help="existing account to sign in as (default: $WP_SEED_USER, then "
|
||||
"$WP_SMOKE_USER). Use an admin account.")
|
||||
args = ap.parse_args()
|
||||
BASE = args.base_url.rstrip("/")
|
||||
if args.insecure:
|
||||
CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE
|
||||
OPENER = build_opener(CTX)
|
||||
|
||||
if not args.user or not args.password:
|
||||
missing = " and ".join(n for n, v in (("WP_SEED_USER", args.user),
|
||||
("WP_SEED_PASSWORD", args.password)) if not v)
|
||||
if not args.user:
|
||||
return abort(
|
||||
f"no credentials — {missing} not set.",
|
||||
"no account — $WP_SEED_USER not set.",
|
||||
" Every /api/ route except /api/health needs a session, so there is nothing\n"
|
||||
" this can seed without one. Set them and re-run:\n\n"
|
||||
" export WP_SEED_USER=<admin-account>\n"
|
||||
" export WP_SEED_PASSWORD='…'\n\n"
|
||||
" Or pass --user/--password. WP_SMOKE_USER / WP_SMOKE_PASSWORD are accepted\n"
|
||||
" too, so one set of credentials serves this and smoketest.py.")
|
||||
" this can seed without one. Set it and re-run:\n\n"
|
||||
" export WP_SEED_USER=<admin-account>\n\n"
|
||||
" Or pass --user. WP_SMOKE_USER is accepted too, so one account name serves\n"
|
||||
" this and smoketest.py.")
|
||||
|
||||
# health gate
|
||||
try:
|
||||
@@ -148,18 +146,25 @@ def main():
|
||||
if st != 200:
|
||||
print(f"ABORT: /api/health returned {st}"); return 1
|
||||
|
||||
# Sign in. The cookie the response sets is held by OPENER's jar and rides every
|
||||
# request after this one.
|
||||
st, body = call("POST", "/api/auth/login",
|
||||
{"username": args.user, "password": args.password})
|
||||
if st != 200:
|
||||
detail = body.get("detail") if isinstance(body, dict) else body
|
||||
hint = (" The account may be locked: the API locks an account for a while after a\n"
|
||||
" few consecutive failures, so retrying with the wrong password makes this\n"
|
||||
" worse. Check the password, then wait out the lockout window."
|
||||
if st in (401, 403, 423, 429) else
|
||||
" Unexpected status from the login endpoint — check the API logs.")
|
||||
return abort(f"could not sign in as '{args.user}' (HTTP {st}): {detail}", hint)
|
||||
# "Sign in" — mint a session directly (see AUTHENTICATION above) and seed it
|
||||
# into OPENER's jar, so it rides every request after this one.
|
||||
try:
|
||||
from server import auth as srv_auth
|
||||
from server.db import SessionLocal
|
||||
except ImportError as e:
|
||||
return abort(f"cannot import the server package to mint a session: {e}",
|
||||
" This needs to run where server/ is importable and AUTH_SECRET_KEY /\n"
|
||||
" DATABASE_URL match the target server's — see AUTHENTICATION above.")
|
||||
with SessionLocal() as db:
|
||||
user = srv_auth.find_user(db, args.user)
|
||||
if not user:
|
||||
return abort(f"no account named '{args.user}'.",
|
||||
" This signs in as an existing account, it doesn't create one — sign in\n"
|
||||
" through Okta once first, or create it from the admin console.")
|
||||
if not user.is_active:
|
||||
return abort(f"'{args.user}' is disabled.", "")
|
||||
token = srv_auth.create_token(user)
|
||||
seed_session_cookie(token, BASE)
|
||||
logged_in = True
|
||||
print(f"Signed in as {args.user}.")
|
||||
|
||||
|
||||
@@ -6,16 +6,23 @@ NGINX → FastAPI → PostgreSQL all work and that the Python logic (the AWP
|
||||
release gate, metrics, cascade delete) behaves. Stdlib only — no pip, no jq.
|
||||
|
||||
AUTHENTICATION
|
||||
Every /api/ route except /api/health requires a session (auth_gate in
|
||||
server/app.py), so the script signs in first and keeps the session cookie for
|
||||
the rest of the run. Credentials come from the environment by preference, so a
|
||||
password never has to appear in a command line or shell history:
|
||||
There is no local password anymore (D15/D16, T10.4) — identity is Okta's job,
|
||||
and Okta requires a real browser to complete, which this stdlib script cannot
|
||||
do. So instead of signing in over HTTP the way the front end does, this script
|
||||
mints a session the same way server/app.py's okta_callback() does after Okta
|
||||
hands back an identity: auth.create_token() for an existing account, seeded
|
||||
straight into the cookie jar. That means it needs 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 locally against your dev DB. It can no
|
||||
longer sign in to an arbitrary remote URL from an unrelated workstation; if
|
||||
the target is remote, run it on that host or inside that container instead.
|
||||
|
||||
export WP_SMOKE_USER=smoketest
|
||||
export WP_SMOKE_PASSWORD='…'
|
||||
python3 server/smoketest.py https://wp-suite.company.local
|
||||
|
||||
…or pass --user / --password explicitly.
|
||||
…or pass --user explicitly. The account must already exist — sign it in
|
||||
through Okta once first (or create it from the admin console) if it doesn't;
|
||||
this script promotes no one and provisions nothing.
|
||||
|
||||
Use an ADMIN account. The script creates a project and deletes it again at the
|
||||
end, and deleting one takes Project Admin on that project (require_project_admin);
|
||||
@@ -24,26 +31,29 @@ AUTHENTICATION
|
||||
discover it in the cleanup step.
|
||||
|
||||
USAGE
|
||||
# Against the deployed site (through the NGINX proxy):
|
||||
python3 server/smoketest.py https://wp-suite.company.local
|
||||
|
||||
# Self-signed / internal TLS cert? skip verification:
|
||||
python3 server/smoketest.py https://wp-suite.company.local --insecure
|
||||
|
||||
# From inside the api container (hits FastAPI directly):
|
||||
docker compose exec -e WP_SMOKE_USER -e WP_SMOKE_PASSWORD api \
|
||||
# From inside the api container (has AUTH_SECRET_KEY and DATABASE_URL; hits
|
||||
# FastAPI directly):
|
||||
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=smoketest
|
||||
python3 server/smoketest.py http://localhost:8000
|
||||
|
||||
# Self-signed / internal TLS cert on the HTTP side? skip verification:
|
||||
python3 server/smoketest.py https://wp-suite.company.local --insecure
|
||||
|
||||
# Leave the demo project in the database so you can open it in the UI:
|
||||
python3 server/smoketest.py https://wp-suite.company.local --keep
|
||||
python3 server/smoketest.py http://localhost:8000 --keep
|
||||
|
||||
The base URL is the SITE root (no /api). Default: http://localhost:8000
|
||||
|
||||
Exit codes: 0 = all checks passed · 1 = one or more checks failed · 2 = the run
|
||||
could not start (unreachable host, missing or rejected credentials). 2 is kept
|
||||
distinct on purpose: "I could not test this" is not the same answer as "this is
|
||||
broken", and conflating them is what made an unauthenticated version of this
|
||||
script report a wall of failures against a perfectly healthy stack.
|
||||
could not start (unreachable host, missing credentials, or no account by that
|
||||
username). 2 is kept distinct on purpose: "I could not test this" is not the
|
||||
same answer as "this is broken", and conflating them is what made an
|
||||
unauthenticated version of this script report a wall of failures against a
|
||||
perfectly healthy stack.
|
||||
"""
|
||||
import argparse
|
||||
import http.cookiejar
|
||||
@@ -53,6 +63,12 @@ import ssl
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# So `from server import auth, models` / `from server.db import SessionLocal` resolve
|
||||
# when this file is run directly (`python3 server/smoketest.py`) rather than as
|
||||
# `python -m server.smoketest` — same reasoning as the sys.path lines in tests/*.py.
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# ── tiny colored reporter ─────────────────────────────────────────────────────
|
||||
_PASS, _FAIL = [], []
|
||||
@@ -66,19 +82,40 @@ def check(name, cond, detail=""):
|
||||
|
||||
BASE = ""
|
||||
CTX = None
|
||||
# One opener for the whole run, carrying the cookie jar that holds the session
|
||||
# issued by /api/auth/login. urlopen() has no cookie support, which is why the
|
||||
# session used to be dropped on the floor and every data route answered 401.
|
||||
# One opener for the whole run, carrying the cookie jar that holds the session.
|
||||
# urlopen() has no cookie support, which is why the session used to be dropped on
|
||||
# the floor and every data route answered 401.
|
||||
OPENER = None
|
||||
COOKIE_JAR = None
|
||||
|
||||
|
||||
def build_opener(ctx=None):
|
||||
handlers = [urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())]
|
||||
global COOKIE_JAR
|
||||
COOKIE_JAR = http.cookiejar.CookieJar()
|
||||
handlers = [urllib.request.HTTPCookieProcessor(COOKIE_JAR)]
|
||||
if ctx is not None:
|
||||
handlers.append(urllib.request.HTTPSHandler(context=ctx))
|
||||
return urllib.request.build_opener(*handlers)
|
||||
|
||||
|
||||
def seed_session_cookie(token: str, base: str) -> None:
|
||||
"""Put a minted session into the jar directly, the same shape a Set-Cookie
|
||||
response from the old /api/auth/login would have produced — so the logout
|
||||
check below (which relies on the jar honoring logout()'s Set-Cookie that
|
||||
expires it) keeps working unchanged. `base` is explicit rather than read off
|
||||
this module's own BASE global, so seed_demo.py (which imports this function
|
||||
but has its own BASE) seeds the cookie for the host it's actually targeting."""
|
||||
host = urlparse(base).hostname or "localhost"
|
||||
COOKIE_JAR.set_cookie(http.cookiejar.Cookie(
|
||||
version=0, name="wp_session", value=token,
|
||||
port=None, port_specified=False,
|
||||
domain=host, domain_specified=True, domain_initial_dot=False,
|
||||
path="/", path_specified=True,
|
||||
secure=False, expires=None, discard=True,
|
||||
comment=None, comment_url=None, rest={"HttpOnly": None},
|
||||
))
|
||||
|
||||
|
||||
def call(method, path, body=None):
|
||||
"""Returns (status_code, parsed_body). Never raises on HTTP status."""
|
||||
url = BASE + path
|
||||
@@ -116,10 +153,7 @@ def main():
|
||||
ap.add_argument("--insecure", action="store_true", help="skip TLS verification")
|
||||
ap.add_argument("--keep", action="store_true", help="keep the demo project (don't delete)")
|
||||
ap.add_argument("--user", default=os.getenv("WP_SMOKE_USER", ""),
|
||||
help="account to sign in as (default: $WP_SMOKE_USER). Use an admin account.")
|
||||
ap.add_argument("--password", default=os.getenv("WP_SMOKE_PASSWORD", ""),
|
||||
help="its password (default: $WP_SMOKE_PASSWORD — preferred, "
|
||||
"so it stays out of shell history)")
|
||||
help="existing account to sign in as (default: $WP_SMOKE_USER). Use an admin account.")
|
||||
args = ap.parse_args()
|
||||
BASE = args.base_url.rstrip("/")
|
||||
if args.insecure:
|
||||
@@ -128,17 +162,14 @@ def main():
|
||||
|
||||
print(f"\nWork Package Suite — API smoke test\nTarget: {BASE}\n")
|
||||
|
||||
# Refuse to start without credentials rather than running headlong into 401s.
|
||||
if not args.user or not args.password:
|
||||
missing = " and ".join(
|
||||
n for n, v in (("WP_SMOKE_USER", args.user), ("WP_SMOKE_PASSWORD", args.password)) if not v)
|
||||
# Refuse to start without a username rather than running headlong into 401s.
|
||||
if not args.user:
|
||||
return abort(
|
||||
f"no credentials — {missing} not set.",
|
||||
"no account — $WP_SMOKE_USER not set.",
|
||||
" Every /api/ route except /api/health needs a session, so there is nothing\n"
|
||||
" meaningful to test without one. Set them and re-run:\n\n"
|
||||
" export WP_SMOKE_USER=<admin-account>\n"
|
||||
" export WP_SMOKE_PASSWORD='…'\n\n"
|
||||
" Or pass --user/--password. Use an admin account: the run creates a project\n"
|
||||
" meaningful to test without one. Set it and re-run:\n\n"
|
||||
" export WP_SMOKE_USER=<admin-account>\n\n"
|
||||
" Or pass --user. Use an admin account: the run creates a project\n"
|
||||
" and deletes it again, and the delete needs Project Admin on it.")
|
||||
|
||||
project_id = None
|
||||
@@ -157,21 +188,29 @@ def main():
|
||||
check("health endpoint returns ok", st == 200 and isinstance(body, dict) and body.get("ok") is True,
|
||||
f"status={st} body={body}")
|
||||
|
||||
# 2) Sign in. The cookie the response sets is held by OPENER's jar and rides
|
||||
# every request after this one.
|
||||
st, body = call("POST", "/api/auth/login",
|
||||
{"username": args.user, "password": args.password})
|
||||
if st != 200:
|
||||
detail = body.get("detail") if isinstance(body, dict) else body
|
||||
hint = (" The account may be locked: the API locks an account for a while after\n"
|
||||
" a few consecutive failures (AUTH_MAX_ATTEMPTS / AUTH_LOCKOUT_MINUTES),\n"
|
||||
" so re-running with the wrong password makes this worse, not better.\n"
|
||||
" Check the password, then wait out the lockout window."
|
||||
if st in (401, 403, 423, 429) else
|
||||
" Unexpected status from the login endpoint — check the API logs.")
|
||||
return abort(f"could not sign in as '{args.user}' (HTTP {st}): {detail}", hint)
|
||||
# 2) "Sign in" — mint a session directly (see AUTHENTICATION above) and seed
|
||||
# it into OPENER's jar, so it rides every request after this one exactly the
|
||||
# way a real Set-Cookie response would have.
|
||||
try:
|
||||
from server import auth as srv_auth
|
||||
from server.db import SessionLocal
|
||||
except ImportError as e:
|
||||
return abort(f"cannot import the server package to mint a session: {e}",
|
||||
" This script now needs to run where server/ is importable and\n"
|
||||
" AUTH_SECRET_KEY / DATABASE_URL match the target server's — see\n"
|
||||
" AUTHENTICATION above.")
|
||||
with SessionLocal() as db:
|
||||
user = srv_auth.find_user(db, args.user)
|
||||
if not user:
|
||||
return abort(f"no account named '{args.user}'.",
|
||||
" This script signs in as an existing account, it doesn't create one —\n"
|
||||
" sign in through Okta once first, or create it from the admin console.")
|
||||
if not user.is_active:
|
||||
return abort(f"'{args.user}' is disabled.", "")
|
||||
token = srv_auth.create_token(user)
|
||||
seed_session_cookie(token, BASE)
|
||||
logged_in = True
|
||||
check("login issues a session", st == 200)
|
||||
check("session cookie seeded", bool(token))
|
||||
|
||||
# 3) Prove the session actually travels — this is the check whose absence let
|
||||
# an unauthenticated version of this script look like a broken stack.
|
||||
|
||||
@@ -26,6 +26,7 @@ browser found, or the server would not start). 2 is distinct on purpose: "I coul
|
||||
not test this" is not the same answer as "this is broken".
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -39,7 +40,6 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import cdp # noqa: E402
|
||||
|
||||
PW = "CorrectHorseBattery9"
|
||||
_PASS, _FAIL = [], []
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ def seed(db_path):
|
||||
def mk(username, role):
|
||||
db.add(models.User(id="user_" + username, username=username,
|
||||
email=f"{username}@example.test", full_name=username.title(),
|
||||
password_hash=auth.hash_password(PW), role=role))
|
||||
role=role))
|
||||
|
||||
mk("root", auth.ROLE_ADMIN)
|
||||
mk("sue", auth.ROLE_PROJECT_SUPER) # super user on Job A
|
||||
@@ -150,10 +150,29 @@ def seed(db_path):
|
||||
for u in db.query(models.User).all()}
|
||||
|
||||
|
||||
def start_server(port, db_path):
|
||||
# T10.7. The app authenticates through Okta, which no test can reach, and
|
||||
# start_server launches it as a SUBPROCESS — so a monkeypatch here would never
|
||||
# reach the code doing the authenticating. server/okta_fake.py reads this
|
||||
# instead, and refuses to work against a non-SQLite database.
|
||||
#
|
||||
# Almost no check ever signs in (seed() mints tokens with auth.create_token and
|
||||
# sets the cookie directly), so this matters only where the sign-in ROUND TRIP
|
||||
# is driven — url_state_check's deep-link case. It is set for every server here
|
||||
# anyway so that a test which starts signing in later does not fail
|
||||
# mysteriously — the same reasoning the LDAP predecessor (D13/T10.7) used.
|
||||
FAKE_DIRECTORY = json.dumps({
|
||||
u: {"email": f"{u}@example.test", "name": u.title()}
|
||||
for u in ("root", "sue", "pat", "mix", "bob", "sam", "legacy", "new")
|
||||
})
|
||||
|
||||
|
||||
def start_server(port, db_path, extra_env=None):
|
||||
env = dict(os.environ)
|
||||
env["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/")
|
||||
env.setdefault("AUTH_SECRET_KEY", "browser-check-secret-not-for-production")
|
||||
env["WP_OKTA_FAKE_DIRECTORY"] = FAKE_DIRECTORY
|
||||
if extra_env:
|
||||
env.update(extra_env)
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "server.app:app", "--host", "127.0.0.1",
|
||||
"--port", str(port), "--log-level", "warning"],
|
||||
@@ -436,7 +455,10 @@ def main():
|
||||
finally:
|
||||
if args.keep_server:
|
||||
print(f"\n --keep-server: still up at {base}, database at {db_path}")
|
||||
print(" Sign in as root / " + PW)
|
||||
# No local password exists (D15/D16) — there's nothing to type into a login
|
||||
# form. Set the session cookie directly, the same way this script's own
|
||||
# fixture does, from the browser console on that origin:
|
||||
print(f" document.cookie = 'wp_session={tok['root']}; path=/'")
|
||||
else:
|
||||
if server:
|
||||
# Wait for it to actually exit before deleting the database out from
|
||||
|
||||
@@ -8,9 +8,17 @@ They now go through `wp-dialog.js` — the T7.9 kit extracted as a shared,
|
||||
self-injecting component (guarded so the creator's inline copy still wins on
|
||||
its own page).
|
||||
|
||||
Static half greps the counts; browser half drives the password-reset prompt on
|
||||
the users console with natives poisoned, and proves validate() answers AT the
|
||||
input while the server round-trip completes end to end.
|
||||
Static half greps the counts; browser half drives the users console's dialogs
|
||||
with natives poisoned and proves they still work end to end.
|
||||
|
||||
Used to drive this via the password-reset prompt specifically, because it was
|
||||
the one place on this page exercising wp-dialog.js's PROMPT variant (text input
|
||||
+ client-side validate()) rather than its confirm variant. T10.4 (D15/D16)
|
||||
removed admin password reset entirely — there is no password to reset anymore
|
||||
— so that coverage moved with it. The prompt-with-validate() pattern itself is
|
||||
still exercised, just not on this page: see creator_dialogs_check.py for
|
||||
wp-creation-app.js's own wpPromptDialog() call sites. If users.html ever grows
|
||||
a new prompt-style dialog, it belongs back in this file.
|
||||
|
||||
Boots its own throwaway SQLite + uvicorn + headless browser; run it alone.
|
||||
Exit 0 all passed, 1 a failure, 2 could not run.
|
||||
@@ -91,31 +99,6 @@ def main():
|
||||
chk("the console booted with a user table",
|
||||
page.eval("!!document.querySelector('table')"))
|
||||
|
||||
page.eval("void resetPw('user_pat','pat')")
|
||||
time.sleep(0.4)
|
||||
chk("the reset prompt is the kit's modal, open, focused at the input",
|
||||
page.eval("(() => { const o=document.getElementById('wp-dlg-overlay');"
|
||||
" return !!o && o.classList.contains('open')"
|
||||
" && document.activeElement.id==='wp-dlg-input'; })()"))
|
||||
page.eval("document.getElementById('wp-dlg-input').value='short';"
|
||||
"document.getElementById('wp-dlg-ok').click()")
|
||||
chk("a short password is refused AT the input - dialog stays, error says why",
|
||||
page.eval("(() => { const o=document.getElementById('wp-dlg-overlay');"
|
||||
" return o.classList.contains('open')"
|
||||
" && /12 characters/.test(document.getElementById('wp-dlg-err').textContent); })()"))
|
||||
page.eval("document.getElementById('wp-dlg-input').value='CorrectHorseBattery10';"
|
||||
"document.getElementById('wp-dlg-ok').click()")
|
||||
time.sleep(1.2)
|
||||
chk("a good answer closes the dialog and the server accepts it",
|
||||
page.eval("!document.getElementById('wp-dlg-overlay').classList.contains('open')"))
|
||||
chk("...announced through the kit's toast (role=status)",
|
||||
page.eval("(() => { const t=document.getElementById('toast');"
|
||||
" return !!t && t.getAttribute('role')==='status'"
|
||||
" && /Password reset for pat/.test(t.textContent); })()"))
|
||||
st, _ = api(base, "/api/auth/login", "x", "POST",
|
||||
{"username": "pat", "password": "CorrectHorseBattery10"})
|
||||
chk("...and the new password actually works", st == 200, st)
|
||||
|
||||
print("\n3. destroy needs a real yes")
|
||||
page.eval("void deleteUser('user_bob','bob')")
|
||||
time.sleep(0.4)
|
||||
|
||||
@@ -31,7 +31,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import cdp # noqa: E402
|
||||
from browser_check import seed, start_server, chk, _PASS, _FAIL, _c, PW # noqa: E402
|
||||
from browser_check import seed, start_server, chk, _PASS, _FAIL, _c # noqa: E402
|
||||
|
||||
STUB = """
|
||||
window.__dialogs = [];
|
||||
@@ -55,8 +55,7 @@ def seed_empty(db_path):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
with SessionLocal() as db:
|
||||
db.add(models.User(id="user_new", username="new", email="new@example.test",
|
||||
full_name="New Starter", password_hash=auth.hash_password(PW),
|
||||
role=auth.ROLE_ADMIN))
|
||||
full_name="New Starter", role=auth.ROLE_ADMIN))
|
||||
db.commit()
|
||||
return {u.username: auth.create_token(u) for u in db.query(models.User).all()}
|
||||
|
||||
|
||||
@@ -323,6 +323,13 @@ def run(page, base, tok):
|
||||
{"text": "Probe Building One,Probe Level 1,Probe Sector B"})
|
||||
chk("the import reports it as reactivated, not created or duplicate",
|
||||
len(again["body"]["reactivated"]) == 1 and not again["body"]["created"], again["body"])
|
||||
# The 2026-08-23 production 500, pinned (locations side): Postgres-refused
|
||||
# values reject by line, on every dialect, never crash the request.
|
||||
hz = api(page, "POST", "/api/projects/projA/locations/import",
|
||||
{"text": "Probe Building One," + "Y" * 220 + ",S1", "dry_run": True})
|
||||
chk("an over-long name is a line rejection, not a 500",
|
||||
hz["status"] == 200 and hz["body"]["rejected"]
|
||||
and "200 characters" in hz["body"]["rejected"][0]["reason"], hz["body"])
|
||||
same = [n for n in api(page, "GET",
|
||||
"/api/projects/projA/locations?include_inactive=true")["body"]["nodes"]
|
||||
if n["path"] == sec["path"]]
|
||||
|
||||
@@ -105,6 +105,20 @@ def main():
|
||||
_, listing = api(base, "/api/projects/projA/materials", root)
|
||||
chk("...and nothing was written", listing["items"] == [])
|
||||
|
||||
# The 2026-08-23 production 500, pinned: what Postgres refuses (VARCHAR
|
||||
# overflow, control bytes) must come back as a per-line rejection - on
|
||||
# EVERY dialect - never crash the request.
|
||||
code, rep = api(base, "/api/projects/projA/materials/import", root, "POST",
|
||||
{"text": "Sample " + "x" * 300 + ",EA", "dry_run": True})
|
||||
chk("an over-long description is a line rejection, not a 500",
|
||||
code == 200 and rep["rejected"] and "300 characters" in rep["rejected"][0]["reason"]
|
||||
and not rep["created"], ascii_(rep))
|
||||
code, rep = api(base, "/api/projects/projA/materials/import", root, "POST",
|
||||
{"text": "Sample widget\u0000,EA", "dry_run": True})
|
||||
chk("a control byte is a line rejection, not a 500",
|
||||
code == 200 and rep["rejected"]
|
||||
and "control characters" in rep["rejected"][0]["reason"], ascii_(rep))
|
||||
|
||||
code, rep = api(base, "/api/projects/projA/materials/import", root, "POST",
|
||||
{"text": text, "dry_run": False})
|
||||
_, listing = api(base, "/api/projects/projA/materials", root)
|
||||
|
||||
327
tests/okta_auth_check.py
Normal file
@@ -0,0 +1,327 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Does Okta sign-in hold its guarantees? — D15/D16 / T10.7.
|
||||
|
||||
The Okta equivalent of tests/ldap_auth_check.py (D13's predecessor). Covers the
|
||||
things that would still let the app *look* fine while quietly failing:
|
||||
|
||||
1. The fake provider must be impossible to select against a real database.
|
||||
2. A one-time authorization code cannot be redeemed twice (replay).
|
||||
3. An unsolicited hit on the callback (no real login ever started) is refused,
|
||||
not a 500 — and creates no account.
|
||||
4. A denied ("Cancel") consent is refused cleanly (?error=cancelled) and
|
||||
creates no account.
|
||||
5. An unknown fake identity at consent is refused and creates no account.
|
||||
6. A correct sign-in works, and ?next= carries through to the real target —
|
||||
but only when it is a same-site path; an off-site next= is ignored.
|
||||
7. A disabled local account is refused even though Okta itself approved it —
|
||||
deprovisioning stays local (D15).
|
||||
8. An unrecognized identity is JIT-provisioned at the lowest role.
|
||||
9. An existing admin signs in and is STILL an admin, with their locally-set
|
||||
name intact — Okta never overwrites what this app already knows.
|
||||
10. OKTA_IDENTITY_CLAIM is genuinely configurable: sign-in still works with a
|
||||
non-default claim name, proving T10.3's "no hard-coded claim" promise.
|
||||
|
||||
Self-contained: throwaway SQLite + its own uvicorn. No browser, no live Okta —
|
||||
server/okta_fake.py stands in for the provider. Two layers, like its LDAP
|
||||
predecessor: guards that need no server (direct calls into okta_fake), then
|
||||
sign-in checks against a real running app.
|
||||
Exit 0 all passed, 1 a failure, 2 could not run.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from http.cookiejar import CookieJar
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from cdp import free_port # noqa: E402
|
||||
|
||||
_PASS, _FAIL = [], []
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def chk(label, ok, detail=""):
|
||||
(_PASS if ok else _FAIL).append(label)
|
||||
print(f" {'PASS' if ok else 'FAIL'} {label}" + ("" if ok else f" {detail}"))
|
||||
|
||||
|
||||
def users_in(db_path):
|
||||
"""Read the users table straight out of the given file. Deliberately NOT
|
||||
via server.db.SessionLocal — that engine binds from DATABASE_URL at import,
|
||||
so setting the env var later keeps reading whichever file came first. The
|
||||
LDAP predecessor lost two assertions to exactly this before it was noticed."""
|
||||
import sqlite3
|
||||
con = sqlite3.connect(db_path)
|
||||
try:
|
||||
try:
|
||||
return {r[0]: (r[1], r[2], bool(r[3]))
|
||||
for r in con.execute(
|
||||
"select username, role, full_name, is_active from users")}
|
||||
except sqlite3.OperationalError:
|
||||
return {}
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def start(port, db_path, fake, identity_claim=None):
|
||||
env = dict(os.environ)
|
||||
env["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/")
|
||||
env["AUTH_SECRET_KEY"] = "okta-auth-check-not-for-production"
|
||||
env["WP_OKTA_FAKE_DIRECTORY"] = json.dumps(fake)
|
||||
if identity_claim:
|
||||
env["OKTA_IDENTITY_CLAIM"] = identity_claim
|
||||
else:
|
||||
env.pop("OKTA_IDENTITY_CLAIM", None)
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "server.app:app", "--host", "127.0.0.1",
|
||||
"--port", str(port), "--log-level", "warning"],
|
||||
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=ROOT)
|
||||
for _ in range(160):
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout=1):
|
||||
return proc
|
||||
except Exception:
|
||||
if proc.poll() is not None:
|
||||
return None
|
||||
time.sleep(0.25)
|
||||
proc.kill()
|
||||
return None
|
||||
|
||||
|
||||
def opener():
|
||||
return urllib.request.build_opener(
|
||||
urllib.request.HTTPCookieProcessor(CookieJar()))
|
||||
|
||||
|
||||
def fetch(op, url):
|
||||
"""GET, auto-following redirects (urllib's default) — matches what a real
|
||||
browser does across the login -> fake-provider -> callback -> target chain.
|
||||
Returns (final_url, status, body)."""
|
||||
try:
|
||||
with op.open(url, timeout=10) as r:
|
||||
return r.geturl(), r.status, r.read().decode("utf-8", "replace")
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.geturl(), e.code, e.read().decode("utf-8", "replace")
|
||||
|
||||
|
||||
def sign_in(base, op, username, next_path=None):
|
||||
"""Drive the real round trip: /api/auth/okta/login -> the fake provider's
|
||||
picker page -> consent as `username` -> okta_callback(). Every hop except
|
||||
the fake provider itself is the app's own unmodified code."""
|
||||
login_url = base + "/api/auth/okta/login"
|
||||
if next_path:
|
||||
login_url += "?next=" + quote(next_path, safe="")
|
||||
final_url, status, body = fetch(op, login_url)
|
||||
if "_fake_provider" not in final_url:
|
||||
return final_url, status, body # never reached the fake at all
|
||||
m = re.search(
|
||||
r'id="okta-fake-identity-%s" href="([^"]+)"' % re.escape(username), body)
|
||||
if not m:
|
||||
return final_url, status, body # identity not offered
|
||||
consent_url = base + m.group(1).replace("&", "&")
|
||||
return fetch(op, consent_url)
|
||||
|
||||
|
||||
def deny(base, op):
|
||||
_, _, body = fetch(op, base + "/api/auth/okta/login")
|
||||
m = re.search(r'id="okta-fake-deny" href="([^"]+)"', body)
|
||||
if not m:
|
||||
return None, None, body
|
||||
return fetch(op, base + m.group(1).replace("&", "&"))
|
||||
|
||||
|
||||
def consent_as(base, op, username, state_override=None):
|
||||
"""Reach the consent endpoint directly with an arbitrary `username` (which
|
||||
need not be one the picker actually offered) and, optionally, a `state`
|
||||
that does not match the one the login step stashed in the session — so
|
||||
'unknown identity' and 'tampered state' can be tested as the server's own
|
||||
refusal, not merely as absence from the picker's list."""
|
||||
_, _, body = fetch(op, base + "/api/auth/okta/login")
|
||||
m = re.search(r'id="okta-fake-deny" href="([^"]+)"', body)
|
||||
if not m:
|
||||
return None, None, body
|
||||
href = m.group(1).replace("&", "&").replace("deny=1", "username=" + quote(username))
|
||||
if state_override is not None:
|
||||
href = re.sub(r"state=[^&]*", "state=" + quote(state_override), href)
|
||||
return fetch(op, base + href)
|
||||
|
||||
|
||||
def main():
|
||||
print("1. the guards that do not need a server")
|
||||
|
||||
os.environ["DATABASE_URL"] = "sqlite:///./_oktacheck_unit.db"
|
||||
os.environ["WP_OKTA_FAKE_DIRECTORY"] = json.dumps({"root": {"email": "r@x.test", "name": "R"}})
|
||||
from server import okta_fake
|
||||
|
||||
chk("the fake is active against a SQLite database", okta_fake.is_active())
|
||||
|
||||
out = subprocess.run(
|
||||
[sys.executable, "-c",
|
||||
"from server import okta_fake; print(okta_fake.is_active())"],
|
||||
cwd=ROOT, capture_output=True, text=True,
|
||||
env={**os.environ,
|
||||
"DATABASE_URL": "postgresql+psycopg://u:p@localhost:5432/db",
|
||||
"AUTH_SECRET_KEY": "x",
|
||||
"WP_OKTA_FAKE_DIRECTORY": json.dumps({"root": {"email": "r@x.test"}})})
|
||||
chk("the fake provider REFUSES to work against a non-SQLite database",
|
||||
out.stdout.strip() == "False", out.stdout.strip() or out.stderr[-200:])
|
||||
|
||||
code = okta_fake.new_code({"preferred_username": "root"})
|
||||
first = okta_fake.consume_code(code)
|
||||
second = okta_fake.consume_code(code)
|
||||
chk("a fresh code redeems once", first == {"preferred_username": "root"}, first)
|
||||
chk("...and a REPLAYED code is refused the second time", second is None, second)
|
||||
|
||||
print("\n2. sign-in, against a server")
|
||||
db_fd, db_path = tempfile.mkstemp(suffix=".db"); os.close(db_fd)
|
||||
port = free_port()
|
||||
fake = {"root": {"email": "root@example.test", "name": "Root Person"},
|
||||
"newperson": {"email": "newperson@example.test", "name": "New Person"}}
|
||||
server = start(port, db_path, fake)
|
||||
if server is None:
|
||||
print("the test server would not start.")
|
||||
return 2
|
||||
base = f"http://127.0.0.1:{port}"
|
||||
try:
|
||||
# Seed one pre-existing admin and one pre-existing but disabled account,
|
||||
# the same way manage_users.py / the admin console would have left them.
|
||||
import sqlalchemy as _sa
|
||||
from server.db import Base
|
||||
from server import models # noqa: F401
|
||||
eng = _sa.create_engine("sqlite:///" + db_path.replace("\\", "/"))
|
||||
Base.metadata.create_all(bind=eng)
|
||||
with eng.begin() as con:
|
||||
con.execute(_sa.text(
|
||||
"insert into users (id,username,email,full_name,role,is_active,"
|
||||
"failed_attempts,token_version,project_role,locale,timezone,"
|
||||
"auto_add_projects,auto_add_role,created_at,updated_at) values "
|
||||
"('user_root','root','','Set By Hand','admin',1,0,0,'','','',0,'',"
|
||||
"datetime('now'),datetime('now'))"))
|
||||
con.execute(_sa.text(
|
||||
"insert into users (id,username,email,full_name,role,is_active,"
|
||||
"failed_attempts,token_version,project_role,locale,timezone,"
|
||||
"auto_add_projects,auto_add_role,created_at,updated_at) values "
|
||||
"('user_shelved','shelved','','Shelved Person','project_user',0,0,0,"
|
||||
"'','','',0,'',datetime('now'),datetime('now'))"))
|
||||
eng.dispose()
|
||||
fake["shelved"] = {"email": "shelved@example.test", "name": "Shelved Person"}
|
||||
# Restart so the running process picks up the augmented directory.
|
||||
server.kill(); server.wait(timeout=10)
|
||||
server = start(port, db_path, fake)
|
||||
if server is None:
|
||||
print("the test server would not restart with the augmented directory.")
|
||||
return 2
|
||||
|
||||
final_url, status, _ = sign_in(base, opener(), "root")
|
||||
chk("a seeded identity signs in", status == 200 and urlparse(final_url).path == "/index.html",
|
||||
(final_url, status))
|
||||
chk("...and the existing admin is STILL an admin",
|
||||
users_in(db_path).get("root", ("", "", None))[0] == "admin", users_in(db_path))
|
||||
chk("...with their locally-set name untouched by Okta",
|
||||
users_in(db_path).get("root", (None, None))[1] == "Set By Hand", users_in(db_path))
|
||||
|
||||
before = set(users_in(db_path))
|
||||
final_url, status, _ = sign_in(base, opener(), "newperson")
|
||||
chk("an unrecognized identity is JIT-provisioned", status == 200, (final_url, status))
|
||||
chk("...at the lowest role", users_in(db_path).get("newperson", ("", "", None))[0]
|
||||
== "project_user", users_in(db_path).get("newperson"))
|
||||
chk("...and only one new row appeared",
|
||||
set(users_in(db_path)) - before == {"newperson"}, set(users_in(db_path)) - before)
|
||||
|
||||
final_url, status, _ = sign_in(base, opener(), "shelved")
|
||||
chk("a disabled local account is refused despite Okta approving it",
|
||||
"error=disabled" in final_url, final_url)
|
||||
|
||||
before = set(users_in(db_path))
|
||||
final_url, status, _ = consent_as(base, opener(), "nobody-such-identity")
|
||||
# okta_callback()'s `except OAuthError` is deliberately generic (T10.5:
|
||||
# a plain-language ?error= for whatever Authlib/the provider rejected,
|
||||
# not a code-by-code breakdown) — so this lands on the same ?error=
|
||||
# cancelled as every other refusal, not a distinct "invalid_request".
|
||||
# The thing actually under test is the SERVER-side refusal, verified by
|
||||
# checking no account got created — not the display string.
|
||||
chk("consenting as an identity the fake never offered is refused BY THE SERVER"
|
||||
" (not just absent from the picker)",
|
||||
"login.html" in final_url and "error=cancelled" in final_url, final_url)
|
||||
chk("...and creates nothing", set(users_in(db_path)) == before,
|
||||
set(users_in(db_path)) - before)
|
||||
|
||||
before = set(users_in(db_path))
|
||||
final_url, status, _ = consent_as(base, opener(), "root", state_override="tampered-state")
|
||||
chk("a consent hit whose state does not match the session is refused",
|
||||
"login.html" in final_url and "error=cancelled" in final_url, final_url)
|
||||
chk("...and creates nothing", set(users_in(db_path)) == before,
|
||||
set(users_in(db_path)) - before)
|
||||
|
||||
before = set(users_in(db_path))
|
||||
final_url, status, _ = deny(base, opener())
|
||||
chk("denying consent lands back on login with a plain message",
|
||||
"login.html" in final_url and "error=cancelled" in final_url, final_url)
|
||||
chk("...and creates nothing", set(users_in(db_path)) == before,
|
||||
set(users_in(db_path)) - before)
|
||||
|
||||
cold = opener()
|
||||
final_url, status, _ = fetch(
|
||||
cold, base + "/api/auth/okta/callback?code=forged&state=forged")
|
||||
chk("an unsolicited hit on the callback (no login ever started) is refused, not a 500",
|
||||
status == 200 and "login.html" in final_url and "error=cancelled" in final_url,
|
||||
(final_url, status))
|
||||
|
||||
final_url, status, _ = sign_in(base, opener(), "root", next_path="/wp-creation-index.html?wp=x")
|
||||
chk("a same-site ?next= survives the round trip",
|
||||
urlparse(final_url).path == "/wp-creation-index.html"
|
||||
and "wp=x" in urlparse(final_url).query, final_url)
|
||||
|
||||
final_url, status, _ = sign_in(base, opener(), "root", next_path="https://evil.example.com/phish")
|
||||
chk("an off-site ?next= is ignored, not honoured",
|
||||
urlparse(final_url).path == "/index.html", final_url)
|
||||
finally:
|
||||
server.kill()
|
||||
try:
|
||||
server.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
|
||||
print("\n3. the identity claim name is genuinely configurable (T10.3)")
|
||||
db_fd2, db2 = tempfile.mkstemp(suffix=".db"); os.close(db_fd2)
|
||||
port2 = free_port()
|
||||
server2 = start(port2, db2, {"root": {"email": "root@example.test", "name": "Root"}},
|
||||
identity_claim="upn")
|
||||
if server2 is None:
|
||||
print("the identity-claim test server would not start.")
|
||||
return 2
|
||||
try:
|
||||
base2 = f"http://127.0.0.1:{port2}"
|
||||
final_url, status, _ = sign_in(base2, opener(), "root")
|
||||
chk("sign-in works with a non-default OKTA_IDENTITY_CLAIM (upn)",
|
||||
status == 200 and urlparse(final_url).path == "/index.html", (final_url, status))
|
||||
chk("...and JIT-provisioned the account under that identity",
|
||||
users_in(db2).get("root", ("", "", None))[0] == "project_user", users_in(db2))
|
||||
finally:
|
||||
server2.kill()
|
||||
try:
|
||||
server2.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
|
||||
print("\n" + "-" * 54)
|
||||
print(f"{len(_PASS)}/{len(_PASS) + len(_FAIL)} checks passed.")
|
||||
for f in _FAIL:
|
||||
print(" - " + f)
|
||||
return 1 if _FAIL else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"could not run: {type(exc).__name__}: {exc}")
|
||||
sys.exit(2)
|
||||
@@ -33,7 +33,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import cdp # noqa: E402
|
||||
from browser_check import seed, start_server, chk, _PASS, _FAIL, _c, PW # noqa: E402
|
||||
from browser_check import seed, start_server, chk, _PASS, _FAIL, _c # noqa: E402
|
||||
|
||||
READY = "!!document.querySelector('#pipeline-strip .pipe-cell, #pipeline-strip .pipe-empty, " \
|
||||
"#pipeline-strip .pipe-error')"
|
||||
|
||||
@@ -47,7 +47,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import cdp # noqa: E402
|
||||
from browser_check import seed, start_server, PW # noqa: E402,F401
|
||||
from browser_check import seed, start_server # noqa: E402
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
HTML = os.path.join(ROOT, "html")
|
||||
|
||||
@@ -7,7 +7,13 @@ the suite, so no work package had an address. This checks the promise those emai
|
||||
will rest on.
|
||||
|
||||
1. a URL identifying a work package opens that work package
|
||||
2. the same URL works for a SIGNED-OUT user, via login, landing on the target
|
||||
2. the same URL works for a SIGNED-OUT user, via the real Okta sign-in round
|
||||
trip (T10.7's fake provider stands in for Okta itself — see
|
||||
server/okta_fake.py — but the app's own login.html, the redirect to
|
||||
/api/auth/okta/login, the state round trip through SessionMiddleware, and
|
||||
okta_callback()'s handling of ?next= are all real, unmodified code).
|
||||
Landing on the requested target, not the home page, doubles as setup for
|
||||
scenarios 3-6 below.
|
||||
3. refresh preserves project, package, tab and view
|
||||
4. Back and Forward move through states without a reload or a broken view
|
||||
5. the URL survives being copied to a second browsing context
|
||||
@@ -27,7 +33,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import cdp # noqa: E402
|
||||
from browser_check import seed, start_server, chk, _PASS, _FAIL, _c, PW # noqa: E402
|
||||
from browser_check import seed, start_server, chk, _PASS, _FAIL, _c # noqa: E402
|
||||
|
||||
|
||||
def settle(page, seconds=1.4):
|
||||
@@ -168,7 +174,7 @@ def main():
|
||||
finally:
|
||||
page2.close()
|
||||
|
||||
print("\n2. the same URL works for a signed-out user, via login")
|
||||
print("\n2. the same URL works for a signed-out user, via the real Okta round trip")
|
||||
page.clear_cookies()
|
||||
page.goto(deep)
|
||||
settle(page, 1.6)
|
||||
@@ -177,19 +183,44 @@ def main():
|
||||
nxt = page.eval("new URLSearchParams(location.search).get('next')||''")
|
||||
chk("...carrying the requested target, package id and all",
|
||||
"wp-creation-index.html" in nxt and "wp=wpA1" in nxt, "next=%r" % nxt)
|
||||
page.eval("document.getElementById('username').value=%r" % "root")
|
||||
page.eval("document.getElementById('password').value=%r" % PW)
|
||||
page.eval("document.querySelector('form').requestSubmit"
|
||||
"? document.querySelector('form').requestSubmit()"
|
||||
": document.querySelector('form').submit()")
|
||||
for _ in range(40):
|
||||
if "wp-creation-index.html" in page.eval("location.href"):
|
||||
|
||||
# Drive the actual button, not a shortcut to it — its href already
|
||||
# carries ?next= (login.js's safeNext()); this is the same click a
|
||||
# person makes.
|
||||
signin_href = page.eval(
|
||||
"(document.getElementById('okta-signin')||{}).getAttribute('href')||''")
|
||||
chk("the sign-in link itself carries ?next=", "next=" in signin_href, signin_href)
|
||||
page.goto(base + signin_href)
|
||||
for _ in range(30):
|
||||
if "_fake_provider" in page.eval("location.href"):
|
||||
break
|
||||
time.sleep(0.3)
|
||||
settle(page, 1.2)
|
||||
chk("the app hands off to the (fake) Okta provider",
|
||||
"_fake_provider" in page.eval("location.href"), page.eval("location.href"))
|
||||
|
||||
# The fake provider's own picker page — a real page, not a shortcut.
|
||||
# See server/okta_fake.py: only the network-touching Authlib calls are
|
||||
# faked, not app.py's own login/callback/JIT/guard code.
|
||||
identity_href = page.eval(
|
||||
"(document.getElementById('okta-fake-identity-root')||{}).getAttribute('href')||''")
|
||||
chk("the fake provider offers the seeded 'root' identity", bool(identity_href),
|
||||
page.eval("document.body.innerHTML"))
|
||||
page.goto(base + identity_href)
|
||||
for _ in range(30):
|
||||
href = page.eval("location.href")
|
||||
if "login.html" not in href and "_fake_provider" not in href:
|
||||
break
|
||||
time.sleep(0.3)
|
||||
settle(page, 1.0)
|
||||
# NOT `"wp-creation-index.html" in location.href` alone — that string
|
||||
# is in the ?next= parameter too, so this would pass while still
|
||||
# sitting on login.html with the sign-in rejected (the same mistake
|
||||
# D13/T10.7's LDAP predecessor caught and fixed here). Assert we
|
||||
# actually LEFT the login page.
|
||||
href = page.eval("location.href")
|
||||
chk("signing in continues to the requested page, not the home page",
|
||||
"wp-creation-index.html" in page.eval("location.href"),
|
||||
page.eval("location.href"))
|
||||
"login.html" not in href and "wp-creation-index.html" in href
|
||||
and "wp=wpA1" in href, href)
|
||||
for _ in range(30):
|
||||
if page.eval("!!window.wpCreatorReady"):
|
||||
break
|
||||
|
||||