Compare commits
37 Commits
79787b3e9f
...
feat/waves
| Author | SHA1 | Date | |
|---|---|---|---|
| 2842ec996c | |||
| b2a083ac7c | |||
| 0b5ab59518 | |||
| a6c3fbfe50 | |||
| 2de76d52e6 | |||
| 8e863ae7d0 | |||
| 6cde6e3f60 | |||
| 358469531c | |||
| 850b78972b | |||
| 0652fa732d | |||
| 75ac930d0c | |||
| 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 | |||
| 8f280d4bd1 |
29
CLAUDE.md
@@ -70,31 +70,6 @@ Adding a raw hex value to a page stylesheet is a defect regardless of what the t
|
|||||||
for. Four parallel token systems is what produced S5, and the `.field-hint` comment at
|
for. Four parallel token systems is what produced S5, and the `.field-hint` comment at
|
||||||
`work-package-suite-styles.css:336` is the bug that resulted. Do not recreate it.
|
`work-package-suite-styles.css:336` is the bug that resulted. Do not recreate it.
|
||||||
|
|
||||||
## The authentication rules
|
|
||||||
|
|
||||||
Sign-in is an LDAPS bind against the domain (`D13`, `docs/waves/decisions-2026-08-21.md`).
|
|
||||||
Four things about it are load-bearing and look like tidying-up if you do not know why:
|
|
||||||
|
|
||||||
- **The empty-password guard in `ldap_auth.verify` runs before `bind()`.** An LDAP
|
|
||||||
simple bind with an empty password is an *anonymous* bind and it SUCCEEDS. Remove
|
|
||||||
that check and a blank password authenticates as any username submitted. It looks
|
|
||||||
redundant because `login()` checks too. Both stay.
|
|
||||||
- **`validate=ssl.CERT_REQUIRED` with an explicit CA file.** Never `CERT_NONE`, never
|
|
||||||
the system trust store (which trusts five other self-signed CAs on this estate).
|
|
||||||
`CERT_NONE` still encrypts, so it fails silently - what it loses is the ability to
|
|
||||||
tell a real DC from someone harvesting domain passwords.
|
|
||||||
- **`AUTH_MAX_ATTEMPTS` is 2, and that is arithmetic, not taste.** Failures are real
|
|
||||||
domain binds counting against the AD lockout policy (5 here), and 2 workers double
|
|
||||||
it: 2 x 2 = 4 < 5. Raising it, or adding a worker, makes `/api/auth/login` a way to
|
|
||||||
lock colleagues out of Windows.
|
|
||||||
- **Connect to `prime.local`, never a DC name or an IP.** Every DC certificate carries
|
|
||||||
the domain name in its SAN; an IP fails hostname validation, and the only way to
|
|
||||||
force it is to disable the check above.
|
|
||||||
|
|
||||||
There is **no break-glass account** - a misconfiguration locks out everyone including
|
|
||||||
admins. And roles are LOCAL: the directory supplies identity, this app supplies
|
|
||||||
authorization. Never read a role from AD.
|
|
||||||
|
|
||||||
## Accessibility is in scope
|
## Accessibility is in scope
|
||||||
|
|
||||||
Approved Aug 14, 2026 (C1). Any component you rebuild ships accessible or it is not done:
|
Approved Aug 14, 2026 (C1). Any component you rebuild ships accessible or it is not done:
|
||||||
@@ -115,12 +90,10 @@ A task is not done because the code is written. Every task file lists its own do
|
|||||||
checks. In addition, for any task touching the frontend:
|
checks. In addition, for any task touching the frontend:
|
||||||
|
|
||||||
1. Run the app locally: `uvicorn server.app:app` against a throwaway SQLite database.
|
1. Run the app locally: `uvicorn server.app:app` against a throwaway SQLite database.
|
||||||
Signing in needs a domain credential now (D13) — a local run reaches `prime.local`
|
|
||||||
from the host with no extra configuration. A container needs the `outbound` network.
|
|
||||||
2. Exercise the affected flow at **390px** and at **1440px**. Field View at 390px is the
|
2. Exercise the affected flow at **390px** and at **1440px**. Field View at 390px is the
|
||||||
gloved-hands surface and is where the worst rendering was found.
|
gloved-hands surface and is where the worst rendering was found.
|
||||||
3. Capture before and after screenshots into the PR.
|
3. Capture before and after screenshots into the PR.
|
||||||
4. Run the existing smoke test. It signs in, and so does `server/seed_demo.py` (S13, fixed at T1.6 - this line said otherwise until Aug 20 2026, a stale record). Since D13 both need a **domain** credential, and `WP_SMOKE_PASSWORD` is now a real Windows password - never put one on a command line.
|
4. Run the existing smoke test. It signs in, and so does `server/seed_demo.py` (S13, fixed at T1.6 - this line said otherwise until Aug 20 2026, a stale record).
|
||||||
|
|
||||||
If a done-when check cannot be verified, do not mark the task complete. Say which check
|
If a done-when check cannot be verified, do not mark the task complete. Say which check
|
||||||
failed and why.
|
failed and why.
|
||||||
|
|||||||
@@ -1,154 +1,103 @@
|
|||||||
# Deploy: Work Package Suite — domain sign-in (D13)
|
# Deploy: Work Package Suite — login portal update
|
||||||
|
|
||||||
Instructions for the **Portainer admin** to take domain authentication live.
|
Instructions for the **Portainer admin** to take the new secure login portal live.
|
||||||
No prior context needed.
|
No prior context needed.
|
||||||
|
|
||||||
**Repo:** `Project-SDE-WP-Suite` (primegit) — changes are merged to **`main`**.
|
**Repo:** `Project-SDE-WP-Suite` (primegit) — changes are merged to **`main`**.
|
||||||
|
|
||||||
> **This document replaced an earlier one.** Until Aug 24 2026 it described taking a
|
**What changed:** the app now has a username/password login. Going live needs:
|
||||||
> **username/password login portal** live: bcrypt hashes, an `AUTH_SECRET_KEY`, and a
|
1. one new environment variable,
|
||||||
> first admin created with `manage_users create-admin <user> --password …`. All of
|
2. a **rebuild** of the stack (not just a restart), and
|
||||||
> that is gone. The app no longer stores a password of any kind, `create-admin` no
|
3. creating the first admin account.
|
||||||
> longer exists, and following the old steps will fail at the first command. The
|
|
||||||
> superseded design is recorded in `docs/waves/decisions-2026-08-21.md` (D13).
|
|
||||||
|
|
||||||
**What changed:** signing in is now an **LDAPS bind against `prime.local`**. People
|
|
||||||
use their **Windows password**. The suite stores no credential, there is no password
|
|
||||||
reset, and accounts create themselves on first sign-in.
|
|
||||||
|
|
||||||
Going live needs:
|
|
||||||
|
|
||||||
1. two environment variables,
|
|
||||||
2. a **rebuild** of the stack (not just a restart),
|
|
||||||
3. one network check, and
|
|
||||||
4. promoting the first admin.
|
|
||||||
|
|
||||||
> **Why a rebuild (not a restart):** both the **nginx/webserver** and **api** images
|
> **Why a rebuild (not a restart):** both the **nginx/webserver** and **api** images
|
||||||
> bake the code in at build time (`COPY html/` and `COPY server/`). A plain restart
|
> bake the code in at build time (`COPY html/` and `COPY server/` in their
|
||||||
> will **not** pick up the new code — the images must be **rebuilt** from latest `main`.
|
> Dockerfiles). A plain restart will **not** pick up the new code — the images must
|
||||||
|
> be **rebuilt** from the latest `main`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚠ Read this before you start
|
## 1. Add an environment variable to the stack
|
||||||
|
|
||||||
**There is no break-glass account.** If the directory is unreachable, the CA bundle
|
In the stack's **Environment variables** section, add:
|
||||||
path is wrong, or the required group is misconfigured, **nobody can sign in —
|
|
||||||
including you.** That was a deliberate decision, not an oversight. Recovery is to fix
|
|
||||||
the configuration and restart; there is no local password to fall back on.
|
|
||||||
|
|
||||||
So: do step 3 before you tell anyone the deploy is done.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Add environment variables to the stack
|
|
||||||
|
|
||||||
In the stack's **Environment variables** section:
|
|
||||||
|
|
||||||
| Name | Value | Notes |
|
| Name | Value | Notes |
|
||||||
|------|-------|-------|
|
|------|-------|-------|
|
||||||
| `AUTH_SECRET_KEY` | a long random string | **Required.** Unchanged — still signs the session cookies. Keep the existing value; changing it signs everyone out. |
|
| `AUTH_SECRET_KEY` | a long random string | **Required.** Signs the login session cookies. |
|
||||||
| `LDAP_REQUIRED_GROUP` | `CN=Prime Employees,OU=Prime Distribution and Security Groups,DC=prime,DC=local` | AD group required to sign in. A full DN is best — it skips a directory lookup. Nested groups count. Leave empty to allow any domain account. |
|
| `AUTH_SESSION_HOURS` | `12` | *Optional.* Hours a login lasts before re-auth (defaults to 12). |
|
||||||
| `AUTH_SESSION_HOURS` | `12` | *Optional.* Unchanged. |
|
|
||||||
|
|
||||||
You do **not** need to set `LDAP_HOST`, `LDAP_DOMAIN` or `LDAP_CA_FILE`. Their
|
Generate the secret on the host with:
|
||||||
defaults are correct for this estate, and the CA bundle ships inside the image.
|
|
||||||
|
|
||||||
**Do not point `LDAP_HOST` at a domain controller's name or at an IP address.** It is
|
```bash
|
||||||
set to `prime.local` on purpose: every DC's certificate carries that name in its SAN,
|
openssl rand -base64 48
|
||||||
so the domain name both validates and load-balances across all six DCs. An IP fails
|
```
|
||||||
certificate validation outright, and the only way to force it through is to switch
|
|
||||||
validation off — which would let anyone on the network intercept **domain passwords**.
|
|
||||||
|
|
||||||
`AUTH_RESET_MINUTES` and `AUTH_RESET_COOLDOWN_SECONDS` can be deleted if present.
|
> If `AUTH_SECRET_KEY` is **not** set, the app still starts but falls back to a random
|
||||||
They configured the password-reset email, which no longer exists.
|
> per-process key — logins then reset on every restart and break across the 2 gunicorn
|
||||||
|
> workers. It must be set to a fixed value.
|
||||||
|
|
||||||
|
The existing database variables (`POSTGRES_*`) are unchanged.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Pull latest `main`, rebuild, and redeploy
|
## 2. Pull latest `main`, rebuild, and redeploy
|
||||||
|
|
||||||
- Pull the latest commit on `main` and redeploy **with image rebuild enabled**.
|
- Pull the latest commit on `main` and redeploy the stack **with image rebuild enabled**
|
||||||
- The new Python dependency (`ldap3`) is in `requirements.txt` and installs during
|
(e.g. "Re-pull and redeploy" / force rebuild). This rebuilds both the `webserver` and
|
||||||
the rebuild.
|
`api` images.
|
||||||
- A database migration drops the `users.password_hash` column. It runs automatically
|
- New Python dependencies (`bcrypt`, `PyJWT`) are in `requirements.txt` and install
|
||||||
at container start. **Every account, role and project membership is preserved** —
|
automatically during the rebuild.
|
||||||
it removes one column, not any rows.
|
- The `users` table is created automatically on API startup — **no DB migration needed.**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Verify BEFORE announcing it
|
## 3. Verify the containers
|
||||||
|
|
||||||
**a. Did the API start at all?**
|
- Confirm `wp_api` and the webserver container are both **running**.
|
||||||
|
- If `wp_api` fails to start, check its **Logs**. (A missing `AUTH_SECRET_KEY` only logs a
|
||||||
```bash
|
warning — it won't crash — but please confirm it's set.)
|
||||||
docker compose logs api | grep -i "LDAP auth"
|
|
||||||
```
|
|
||||||
|
|
||||||
You want:
|
|
||||||
|
|
||||||
```
|
|
||||||
LDAP auth enabled — ldaps://prime.local:636, domain prime.local, CA /app/server/certs/prime-ca-chain.pem, required group: CN=Prime Employees,…
|
|
||||||
```
|
|
||||||
|
|
||||||
If it says `LDAP auth DISABLED`, stop — nobody will be able to sign in. The message
|
|
||||||
names the reason.
|
|
||||||
|
|
||||||
**b. Can the container actually reach a domain controller?** This opens a TLS session
|
|
||||||
and validates the certificate **without binding**, so it touches no account and
|
|
||||||
cannot contribute to any lockout:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec api openssl s_client -connect prime.local:636 -CAfile /app/server/certs/prime-ca-chain.pem </dev/null 2>&1 | grep "Verify return"
|
|
||||||
```
|
|
||||||
|
|
||||||
Want `Verify return code: 0 (ok)`. If you get a connection error, the `api` container
|
|
||||||
is missing the `outbound` network — `internal` has no default gateway and blocks the
|
|
||||||
LAN as well as the internet. If you get `62 (hostname mismatch)`, something is
|
|
||||||
pointing at an IP instead of `prime.local`.
|
|
||||||
|
|
||||||
**c. Sign in.** Use your own Windows username and password.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Promote the first admin
|
## 4. Create the first admin account
|
||||||
|
|
||||||
Roles are stored locally and are not read from AD, so someone has to be made an admin
|
The login system needs one admin user in the production (Postgres) database. Open the
|
||||||
once. Sign in first — that creates your account — then:
|
**`wp_api`** container's **Console** (`/bin/sh`) and run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec api python -m server.manage_users promote <your-sAMAccountName>
|
python -m server.manage_users create-admin <username> --name "<Full Name>"
|
||||||
```
|
```
|
||||||
|
|
||||||
It asks for **your** domain username and password, binds to confirm who you are, and
|
It prompts for a password (minimum 8 characters) and prints `Created admin: <username>`.
|
||||||
prints `<user>: project_user -> admin`.
|
|
||||||
|
|
||||||
Other commands: `list` (needs no credential), `demote`, `disable`, `enable`.
|
Non-interactive alternative:
|
||||||
`create-admin`, `create` and `reset-password` no longer exist.
|
|
||||||
|
|
||||||
After that, admins manage everyone else from the in-app **Admin → User
|
```bash
|
||||||
administration** page. No further shell access needed.
|
python -m server.manage_users create-admin <username> --name "<Full Name>" --password "<password>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Other CLI commands (run the same way): `list`, `create <user> --role user`,
|
||||||
|
`reset-password <user>`, `disable <user>`, `enable <user>`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What people will notice
|
## 5. Confirm it works
|
||||||
|
|
||||||
- They sign in with their **Windows password**, not an app password.
|
1. Load the site's normal URL — it should redirect to a **login page**.
|
||||||
- **"Forgot password?"** now goes to `https://primecontrols.okta.com/`. The app cannot
|
2. Sign in with the admin account from step 4.
|
||||||
reset a password it does not hold.
|
3. That admin can then add all other users from the in-app **Admin → User
|
||||||
- The **Change password** item is gone from the top-right menu.
|
administration** page (top-right **Admin** link), so no further shell access is needed.
|
||||||
- Anyone in the required group can sign in **without being added first** — their
|
|
||||||
account is created automatically. They will see **no projects** until an admin
|
---
|
||||||
grants access, which is intentional. New accounts appear in the Admin console and
|
|
||||||
each one is recorded in the audit log.
|
|
||||||
- Two wrong passwords and the app stops trying for a while. That is deliberate: every
|
|
||||||
failed attempt is a real domain bind and counts against the **AD lockout policy**,
|
|
||||||
so the app stops well short of locking anyone out of Windows.
|
|
||||||
|
|
||||||
## Reference — what's in this release
|
## Reference — what's in this release
|
||||||
|
|
||||||
- `server/ldap_auth.py` — the LDAPS client: bind, nested-group check, certificate validation.
|
- `server/auth.py` — bcrypt password hashing, JWT session cookie, the request gate.
|
||||||
- `server/app.py` — `login()` binds instead of comparing a hash; password endpoints removed.
|
- `server/app.py` — `/api/auth/*` endpoints + middleware that refuses every `/api` data
|
||||||
- `server/auth.py` — sessions and roles only; no hashing, no reset tokens.
|
route without a valid session.
|
||||||
- `server/certs/prime-ca-chain.pem` — the CA bundle that validates the DC certificate.
|
- `server/manage_users.py` — the CLI used in step 4.
|
||||||
- `server/manage_users.py` — `promote` / `demote`, each requiring a domain bind.
|
- `html/login.html`, `html/auth-guard.js` — login page and per-page guard.
|
||||||
- `html/login.html`, `login.js` — one view; "Forgot password?" points at Okta.
|
- `html/admin.html` / `admin.js` — Admin Console gated on the admin role, with the user
|
||||||
- Migration `b7e4f1a20c93` — drops `users.password_hash`.
|
administration UI.
|
||||||
|
- Sessions are stateless: a signed JWT in an **HttpOnly, SameSite=Lax** cookie, marked
|
||||||
|
**Secure** automatically when served over HTTPS (via `X-Forwarded-Proto` from nginx).
|
||||||
|
|||||||
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.
|
||||||
211
DEPLOYMENT.md
@@ -52,34 +52,53 @@ POSTGRES_DB=wpsuite
|
|||||||
POSTGRES_USER=wpsuite
|
POSTGRES_USER=wpsuite
|
||||||
POSTGRES_PASSWORD=<strong-random-password>
|
POSTGRES_PASSWORD=<strong-random-password>
|
||||||
|
|
||||||
# REQUIRED — signs login session cookies. If unset, `docker compose up` errors
|
# REQUIRED — signs login session cookies, AFTER Okta has confirmed who someone
|
||||||
# out and the API refuses to start. Generate once and keep it stable:
|
# is. If unset, `docker compose up` errors out and the API refuses to start.
|
||||||
|
# Generate once and keep it stable:
|
||||||
# openssl rand -base64 48
|
# openssl rand -base64 48
|
||||||
AUTH_SECRET_KEY=<strong-random-secret>
|
AUTH_SECRET_KEY=<strong-random-secret>
|
||||||
|
|
||||||
|
# OPTIONAL — D18 (2026-09-23): a session slides on activity (AUTH_IDLE_MINUTES,
|
||||||
|
# default 30) capped by a hard ceiling from original sign-in regardless of
|
||||||
|
# activity (AUTH_SESSION_HOURS, default 8). Both are proposed defaults, not
|
||||||
|
# confirmed against this tenant's Okta SSO session policy — if Okta's own
|
||||||
|
# session outlives either one, re-auth here is likely a fast silent redirect
|
||||||
|
# rather than a real login screen. Full explanation in server/.env.example.
|
||||||
|
# AUTH_IDLE_MINUTES=30
|
||||||
|
# AUTH_SESSION_HOURS=8
|
||||||
|
|
||||||
|
# REQUIRED (in spirit — see the note below) — Okta OIDC is the only sign-in
|
||||||
|
# path (D15/D16). There is no local password anywhere in this app to fall back
|
||||||
|
# to, so without these nobody can sign in at all. Get them from the Okta app
|
||||||
|
# integration (sign-in method OIDC - Authorization Code, Web Application):
|
||||||
|
#
|
||||||
|
# OKTA_ISSUER the authorization server, e.g.
|
||||||
|
# https://your-org.okta.com/oauth2/default
|
||||||
|
# OKTA_CLIENT_ID \_ from the app integration
|
||||||
|
# OKTA_CLIENT_SECRET /
|
||||||
|
# OKTA_REDIRECT_URI must exactly match a "Sign-in redirect URI"
|
||||||
|
# registered on the app integration, e.g.
|
||||||
|
# https://wp-suite.company.local/api/auth/okta/callback
|
||||||
|
#
|
||||||
|
# Full explanation, and the optional OKTA_IDENTITY_CLAIM override, in
|
||||||
|
# server/.env.example. Not enforced at startup the way AUTH_SECRET_KEY is —
|
||||||
|
# the API starts without these, it just refuses every sign-in and says so in
|
||||||
|
# `docker compose logs api` (server/okta_auth.py's describe()).
|
||||||
|
OKTA_ISSUER=<https://your-org.okta.com/oauth2/default>
|
||||||
|
OKTA_CLIENT_ID=<from the Okta app integration>
|
||||||
|
OKTA_CLIENT_SECRET=<from the Okta app integration>
|
||||||
|
OKTA_REDIRECT_URI=<https://your-hostname/api/auth/okta/callback>
|
||||||
|
|
||||||
# Encrypts database backups at rest (AES-256). Set this BEFORE the DB holds
|
# Encrypts database backups at rest (AES-256). Set this BEFORE the DB holds
|
||||||
# customer IP. Keep the passphrase OFF this host — losing it makes dumps
|
# customer IP. Keep the passphrase OFF this host — losing it makes dumps
|
||||||
# unrecoverable: openssl rand -base64 32
|
# unrecoverable: openssl rand -base64 32
|
||||||
BACKUP_ENC_PASSPHRASE=<strong-random-passphrase>
|
BACKUP_ENC_PASSPHRASE=<strong-random-passphrase>
|
||||||
|
|
||||||
# OPTIONAL — SMTP password for WP-assignment email + password-reset links. Email
|
# OPTIONAL — SMTP password for WP-assignment email. Email is OFF by default and
|
||||||
# is OFF by default and enabled from the Admin console; the host/port/from-address
|
# enabled from the Admin console; the host/port/from-address are configured
|
||||||
# are configured there, but the password is only ever read from this variable
|
# there, but the password is only ever read from this variable (never stored
|
||||||
# (never stored in the DB or shown in the UI). Leave unset until you have SMTP
|
# in the DB or shown in the UI). Leave unset until you have SMTP details.
|
||||||
# details.
|
|
||||||
# SMTP_PASSWORD=<smtp-app-password>
|
# SMTP_PASSWORD=<smtp-app-password>
|
||||||
|
|
||||||
# OPTIONAL — the AD group required to sign in (D13). A group NAME or a full DN;
|
|
||||||
# nested groups count. Empty means any domain account may sign in. This is the
|
|
||||||
# initial value; the live one is set in the Admin console.
|
|
||||||
# LDAP_REQUIRED_GROUP=CN=Prime Employees,OU=Prime Distribution and Security Groups,DC=prime,DC=local
|
|
||||||
#
|
|
||||||
# OPTIONAL — the rest of the directory settings. The defaults are correct for this
|
|
||||||
# estate and you should not normally set them. NEVER point LDAP_HOST at a DC name
|
|
||||||
# or an IP: see § Domain authentication below.
|
|
||||||
# LDAP_DOMAIN=prime.local
|
|
||||||
# LDAP_HOST=prime.local
|
|
||||||
# LDAP_CA_FILE=/app/server/certs/prime-ca-chain.pem
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The API builds its own DB connection string from the `POSTGRES_*`
|
The API builds its own DB connection string from the `POSTGRES_*`
|
||||||
@@ -94,6 +113,7 @@ Generate a strong password with `openssl rand -base64 32`.
|
|||||||
> **Portainer note:** for a Git-based stack these go in the stack's
|
> **Portainer note:** for a Git-based stack these go in the stack's
|
||||||
> **Environment variables** section (Portainer doesn't read a local `.env`).
|
> **Environment variables** section (Portainer doesn't read a local `.env`).
|
||||||
> Set `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` / `AUTH_SECRET_KEY` /
|
> Set `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` / `AUTH_SECRET_KEY` /
|
||||||
|
> `OKTA_ISSUER` / `OKTA_CLIENT_ID` / `OKTA_CLIENT_SECRET` / `OKTA_REDIRECT_URI` /
|
||||||
> `BACKUP_ENC_PASSPHRASE` (and `SMTP_PASSWORD`, if you enable email) there.
|
> `BACKUP_ENC_PASSPHRASE` (and `SMTP_PASSWORD`, if you enable email) there.
|
||||||
|
|
||||||
These are the only credentials in the system, and they never appear in the
|
These are the only credentials in the system, and they never appear in the
|
||||||
@@ -158,25 +178,34 @@ project → SOP → Work Package → the AWP issue gate → status → metrics
|
|||||||
archive round trip → cascade cleanup → sign-out). Stdlib only — no pip/jq.
|
archive round trip → cascade cleanup → sign-out). Stdlib only — no pip/jq.
|
||||||
|
|
||||||
It **signs in first**, because every `/api/` route except `/api/health` requires a
|
It **signs in first**, because every `/api/` route except `/api/health` requires a
|
||||||
session. Credentials come from the environment so a password stays out of shell
|
session — but there is no local password to sign in with (D15/D16), and Okta
|
||||||
history, and the account must be an **admin**: the run creates a project and deletes
|
requires a real browser to complete, which this script cannot do. So instead
|
||||||
it again, and archiving or deleting one takes Project Admin on it. The script checks
|
of an HTTP login, it mints a session directly the same way `okta_callback()`
|
||||||
the signed-in role up front and warns if it is too low rather than letting you find
|
does after Okta hands back an identity, which means **it has to run somewhere
|
||||||
out in the cleanup step.
|
that can read the same `AUTH_SECRET_KEY` and reach the same database as the
|
||||||
|
server under test** — inside the `api` container, or local dev against your
|
||||||
|
own DB. It can no longer sign in to an arbitrary remote URL from an unrelated
|
||||||
|
workstation the way the old password-based version could.
|
||||||
|
|
||||||
|
The account named by `WP_SMOKE_USER` must **already exist** — sign it in
|
||||||
|
through Okta once first, or pre-create it from the User Directory — and must
|
||||||
|
be an **admin**: the run creates a project and deletes it again, and deleting
|
||||||
|
one takes Project Admin on it. The script checks the signed-in role up front
|
||||||
|
and warns if it is too low rather than letting you find out in the cleanup
|
||||||
|
step.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export WP_SMOKE_USER=<admin-account>
|
# From inside the api container — has AUTH_SECRET_KEY and DATABASE_URL, and
|
||||||
export WP_SMOKE_PASSWORD='…'
|
# hits FastAPI directly. This is the normal way to run it in production:
|
||||||
|
docker compose exec -e WP_SMOKE_USER api \
|
||||||
# Through the proxy (use --insecure for a self-signed internal cert):
|
|
||||||
python3 server/smoketest.py https://wp-suite.company.local --insecure
|
|
||||||
|
|
||||||
# Or from inside the api container (hits FastAPI directly). Pass the vars through:
|
|
||||||
docker compose exec -e WP_SMOKE_USER -e WP_SMOKE_PASSWORD api \
|
|
||||||
python /app/server/smoketest.py http://localhost:8000
|
python /app/server/smoketest.py http://localhost:8000
|
||||||
|
|
||||||
|
# Local dev, against the app you're running yourself:
|
||||||
|
export AUTH_SECRET_KEY=... DATABASE_URL=... WP_SMOKE_USER=<admin-account>
|
||||||
|
python3 server/smoketest.py http://localhost:8000
|
||||||
|
|
||||||
# Add --keep to leave a demo project in the DB so you can open it in the UI.
|
# Add --keep to leave a demo project in the DB so you can open it in the UI.
|
||||||
# --user / --password override the environment if you'd rather be explicit.
|
# --user overrides $WP_SMOKE_USER if you'd rather be explicit.
|
||||||
```
|
```
|
||||||
|
|
||||||
Exit codes: **0** all checks passed · **1** one or more checks failed · **2** the run
|
Exit codes: **0** all checks passed · **1** one or more checks failed · **2** the run
|
||||||
@@ -264,7 +293,7 @@ users on the same project see the same server-stored SOP and Work Packages.
|
|||||||
| `sops` | project SOP baselines | `project_id` → projects, `name`, `number`, `complete`, `data` (full SOP JSON) |
|
| `sops` | project SOP baselines | `project_id` → projects, `name`, `number`, `complete`, `data` (full SOP JSON) |
|
||||||
| `work_packages` | individual IWPs | `project_id` → projects, `sop_id` → sops, `parent_id` (split instances), `number`, `subject`, `type`, `status`, `assignee_id` (owner), `issued_at`, `archived_at`, `data` (full WP JSON) |
|
| `work_packages` | individual IWPs | `project_id` → projects, `sop_id` → sops, `parent_id` (split instances), `number`, `subject`, `type`, `status`, `assignee_id` (owner), `issued_at`, `archived_at`, `data` (full WP JSON) |
|
||||||
| `comments` | feedback from any page | `source`, `sop_id`, `wp_id`, `step`, `author`, `text`, `extra` |
|
| `comments` | feedback from any page | `source`, `sop_id`, `wp_id`, `step`, `author`, `text`, `extra` |
|
||||||
| `users` | login accounts (no password — D13) | `username` (sAMAccountName), `role`, `full_name`, `email`, `is_active`, `auto_add_projects` + `auto_add_role` (default membership on new projects), login-lockout + `token_version` fields |
|
| `users` | login accounts | `username` (matched against Okta's identity claim — no password column; D15/D16), `role`, `full_name`, `email`, `is_active`, `auto_add_projects` + `auto_add_role` (default membership on new projects), `token_version` |
|
||||||
| `project_members` | per-project access control | `user_id` → users, `project_id` → projects |
|
| `project_members` | per-project access control | `user_id` → users, `project_id` → projects |
|
||||||
| `audit_log` | append-only activity trail | `actor`, `action`, `entity_type`, `entity_id`, `project_id`, `summary`, `detail` |
|
| `audit_log` | append-only activity trail | `actor`, `action`, `entity_type`, `entity_id`, `project_id`, `summary`, `detail` |
|
||||||
| `notifications` | in-app record + email outbox | `user_id`, `kind`, `wp_id`, `subject`, `status` (pending / sent / failed / skipped) |
|
| `notifications` | in-app record + email outbox | `user_id`, `kind`, `wp_id`, `subject`, `status` (pending / sent / failed / skipped) |
|
||||||
@@ -282,8 +311,9 @@ Work Packages `GET/POST /api/wps`, `GET/DELETE /api/wps/{id}`,
|
|||||||
`POST /api/wps/{id}/issue`, `POST /api/wps/{id}/status`, `POST /api/wps/{id}/archive`,
|
`POST /api/wps/{id}/issue`, `POST /api/wps/{id}/status`, `POST /api/wps/{id}/archive`,
|
||||||
`GET /api/wps/metrics` ·
|
`GET /api/wps/metrics` ·
|
||||||
Comments `POST /api/comments` (and `/api/feedback`), `GET /api/comments` ·
|
Comments `POST /api/comments` (and `/api/feedback`), `GET /api/comments` ·
|
||||||
Auth `POST /api/auth/login` / `logout`, `GET /api/auth/me`, admin user management
|
Auth `GET /api/auth/okta/login` / `okta/callback` (the Okta sign-in round trip),
|
||||||
under `/api/auth/users` (including `POST /api/auth/users/{id}/auto-add`) ·
|
`POST /api/auth/logout`, `GET /api/auth/me`, admin user management under
|
||||||
|
`/api/auth/users` (including `POST /api/auth/users/{id}/auto-add`) ·
|
||||||
Admin-only `GET/PUT /api/settings`,
|
Admin-only `GET/PUT /api/settings`,
|
||||||
`POST /api/settings/test-email`, `GET /api/notifications`,
|
`POST /api/settings/test-email`, `GET /api/notifications`,
|
||||||
`GET /api/projects/{id}/members`.
|
`GET /api/projects/{id}/members`.
|
||||||
@@ -357,96 +387,31 @@ TLS / From address and flips the master toggle.
|
|||||||
package contents — so customer IP stays behind the login.
|
package contents — so customer IP stays behind the login.
|
||||||
- Use the card's **Send test email** button to confirm SMTP before enabling.
|
- Use the card's **Send test email** button to confirm SMTP before enabling.
|
||||||
|
|
||||||
### Password reset — there isn't one
|
### Sign-in and admin bootstrap (Okta)
|
||||||
|
|
||||||
D13 removed local passwords entirely. **Turning email on no longer affects sign-in.**
|
There is no local password anywhere in this app — no "Forgot password," no
|
||||||
The login page's "Forgot password?" links to `https://primecontrols.okta.com/`, which
|
reset link, nothing email-related to sign-in (D15/D16). `SMTP_PASSWORD` above
|
||||||
is the only self-service route; the app cannot reset a credential it does not hold.
|
is purely for WP-assignment notification emails.
|
||||||
|
|
||||||
Email still carries WP-assignment notifications and the critical-reopen mail.
|
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
|
||||||
## Domain authentication (D13)
|
ordinary `project_user`, auto-provisioned), then get promoted from a shell:
|
||||||
|
|
||||||
Sign-in is an **LDAPS simple bind** as `<sAMAccountName>@prime.local`. There is no
|
|
||||||
password in the database and **no break-glass account**. If the domain is
|
|
||||||
unreachable, `LDAP_CA_FILE` is wrong, or the required group is misconfigured,
|
|
||||||
**nobody can sign in, including admins.**
|
|
||||||
|
|
||||||
**First thing to check on any sign-in problem** — the API logs one line at startup
|
|
||||||
saying whether LDAP is configured, and `/api/health` stays unauthenticated so the
|
|
||||||
stack is diagnosable while nobody can log in:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose logs api | grep -i "LDAP auth"
|
docker compose exec api python -m server.manage_users promote alice --role admin
|
||||||
# LDAP auth enabled — ldaps://prime.local:636, domain prime.local, …
|
|
||||||
# LDAP auth DISABLED — CA bundle not found at '…'. No one can sign in.
|
|
||||||
curl https://wp-suite.company.local/api/health # → {"ok": true}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then prove the certificate path, without binding — this touches no account and so
|
This is deliberate, not an oversight: a hand-typed username at account-creation
|
||||||
cannot contribute to a lockout:
|
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.
|
||||||
|
|
||||||
```bash
|
**No break-glass path.** If Okta is unreachable or misconfigured, the app is
|
||||||
docker compose exec api openssl s_client -connect prime.local:636 -CAfile /app/server/certs/prime-ca-chain.pem </dev/null 2>&1 | grep "Verify return"
|
unreachable for everyone, including admins, until Okta is restored — the same
|
||||||
# want: Verify return code: 0 (ok)
|
posture the abandoned LDAPS design took, carried forward deliberately (D16).
|
||||||
```
|
|
||||||
|
|
||||||
### Three things that are not obvious
|
|
||||||
|
|
||||||
**Connect to the domain name, never a DC or an IP.** Every DC's certificate carries
|
|
||||||
`prime.local` in its SAN, so the domain name both passes hostname validation and
|
|
||||||
round-robins across all six DCs published in `_ldap._tcp.prime.local`. An IP gives
|
|
||||||
`Verify return code: 62 (hostname mismatch)` because there is no IP SAN — and the
|
|
||||||
only way to force it through is to disable validation. Do not. Domain passwords
|
|
||||||
cross this link, and an unvalidated one can be terminated by anyone on the network
|
|
||||||
who then harvests them.
|
|
||||||
|
|
||||||
**The CA bundle is not a certificate issued to this app.** The API is the TLS
|
|
||||||
*client*; clients verify, they do not present. `server/certs/prime-ca-chain.pem`
|
|
||||||
contains `PRIME CONTROLS ROOT CA` (valid to 2051) and `PRIME CONTROLS ISSUING CA 1`
|
|
||||||
(2036) — public certificates with no private key. There is nothing to request from
|
|
||||||
IT, no CSR and no enrollment. Rebuild it from any domain-joined machine with:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Get-ChildItem Cert:\LocalMachine\Root, Cert:\LocalMachine\CA |
|
|
||||||
Where-Object { $_.Thumbprint -in
|
|
||||||
'C371E91C430A12051029527C443B1EF683675CF3', # PRIME CONTROLS ROOT CA
|
|
||||||
'4F7506105228C73DF64181ACA20AD9783437EC8B' } # PRIME CONTROLS ISSUING CA 1
|
|
||||||
```
|
|
||||||
|
|
||||||
exporting each as Base-64 and concatenating them into one file.
|
|
||||||
|
|
||||||
**The `outbound` network is required.** `internal` has no default gateway, which
|
|
||||||
blocks the LAN and the VPN as well as the internet, so the `api` container cannot
|
|
||||||
reach `prime.local:636` without it. Its comment used to say it was optional if you
|
|
||||||
were not using the Micron asset picker; detaching it now breaks every sign-in.
|
|
||||||
|
|
||||||
### Accounts
|
|
||||||
|
|
||||||
Accounts are **created on first successful sign-in**, at `project_user` with **no
|
|
||||||
project access** — the person signs in and sees nothing until an admin grants it.
|
|
||||||
Roles are local and never read from AD, so an existing admin keeps admin.
|
|
||||||
|
|
||||||
The first admin is bootstrapped in two steps: sign in once, then
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec api python -m server.manage_users promote <sAMAccountName>
|
|
||||||
```
|
|
||||||
|
|
||||||
which prompts for *your* domain credential. `list`, `demote`, `disable` and `enable`
|
|
||||||
are the other commands; `create-admin` and `create` no longer exist.
|
|
||||||
|
|
||||||
### The lockout arithmetic
|
|
||||||
|
|
||||||
`AUTH_MAX_ATTEMPTS` defaults to **2**, and that is a safety limit rather than a
|
|
||||||
preference. Failures are now domain binds, so they count against the **AD account
|
|
||||||
lockout policy** (5 on this estate). The throttle is per-process and the API runs 2
|
|
||||||
gunicorn workers, so a local limit of N allows up to 2N binds to reach a DC: 2 × 2 = 4,
|
|
||||||
one under the threshold. **Raising this, or adding a worker, means redoing that
|
|
||||||
arithmetic** — otherwise `/api/auth/login` becomes a way for anyone, unauthenticated,
|
|
||||||
to lock a colleague out of Windows.
|
|
||||||
|
|
||||||
## Permissions roles
|
## Permissions roles
|
||||||
|
|
||||||
@@ -458,7 +423,7 @@ which no longer manages accounts.
|
|||||||
| Role | May do |
|
| Role | May do |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `admin` | User administration everywhere, app settings, and every project |
|
| `admin` | User administration everywhere, app settings, and every project |
|
||||||
| `project_super_user` | Everything `project_admin` may do, **plus user administration on the projects they hold the role on**: create accounts, reset passwords, set permissions, grant project access |
|
| `project_super_user` | Everything `project_admin` may do, **plus user administration on the projects they hold the role on**: pre-create accounts by username, set permissions, grant project access |
|
||||||
| `project_admin` | On assigned projects: delete work packages, change a **completed** SOP, delete the project |
|
| `project_admin` | On assigned projects: delete work packages, change a **completed** SOP, delete the project |
|
||||||
| `project_user` | Create/edit work packages, author a SOP up to completion; may archive a WP but not delete one |
|
| `project_user` | Create/edit work packages, author a SOP up to completion; may archive a WP but not delete one |
|
||||||
|
|
||||||
@@ -475,9 +440,9 @@ Its limits are what make it safe to hand out, and all of them are server-side
|
|||||||
* **Scope comes from projects, not the job title.** A super user administers the users
|
* **Scope comes from projects, not the job title.** A super user administers the users
|
||||||
of the projects they hold the role on — via their account role, or via
|
of the projects they hold the role on — via their account role, or via
|
||||||
`ProjectMember.role` for a super user on one job only. No projects, no authority.
|
`ProjectMember.role` for a super user on one job only. No projects, no authority.
|
||||||
* **Account changes need EXCLUSIVE scope.** Resetting a password, disabling, renaming,
|
* **Account changes need EXCLUSIVE scope.** Disabling, renaming, changing
|
||||||
changing permissions or deleting are global acts, so they are refused when the
|
permissions or deleting are global acts, so they are refused when the target
|
||||||
target is also on a project the caller does not administer. The directory shows
|
is also on a project the caller does not administer. The directory shows
|
||||||
those rows read-only with the reason. An app admin has to make the change.
|
those rows read-only with the reason. An app admin has to make the change.
|
||||||
* **No admin or super-user targets, and none granted.** A super user may hand out
|
* **No admin or super-user targets, and none granted.** A super user may hand out
|
||||||
`project_admin` / `project_user` only, and may not touch an admin's or another
|
`project_admin` / `project_user` only, and may not touch an admin's or another
|
||||||
|
|||||||
@@ -89,11 +89,6 @@ be built as written, or cannot be built once, until something else lands.
|
|||||||
| 7 | The creator | `docs/waves/wave-7.md` | `B7` `A1` `CR-015` `A2` `A6` `CR-014` `CR-007` `B6` `S1`(creator) `F6` `D1` `D2` `D3` `D4` `D5` `D8` `D9` `D10` |
|
| 7 | The creator | `docs/waves/wave-7.md` | `B7` `A1` `CR-015` `A2` `A6` `CR-014` `CR-007` `B6` `S1`(creator) `F6` `D1` `D2` `D3` `D4` `D5` `D8` `D9` `D10` |
|
||||||
| 8 | Kitting and material | `docs/waves/wave-8.md` | `CR-009` `CR-010` `CR-011` `CR-012` `CR-013` `D6` `D10` |
|
| 8 | Kitting and material | `docs/waves/wave-8.md` | `CR-009` `CR-010` `CR-011` `CR-012` `CR-013` `D6` `D10` |
|
||||||
| 9 | Verification and cleanup | `docs/waves/wave-9.md` | `CR-008` `CR-017` `S6` `S7` `C1` `C2` `C4` `D7` |
|
| 9 | Verification and cleanup | `docs/waves/wave-9.md` | `CR-008` `CR-017` `S6` `S7` `C1` `C2` `C4` `D7` |
|
||||||
| 10 | Domain authentication over LDAPS | `docs/waves/wave-10.md` | `D13` |
|
|
||||||
|
|
||||||
Wave 10 was added on August 21, 2026 and is not part of the original nine-wave sequence. It
|
|
||||||
is new scope (`docs/waves/decisions-2026-08-21.md`), not a reinterpretation of anything
|
|
||||||
above, and it depends only on wave 9 being merged rather than on any particular item in it.
|
|
||||||
|
|
||||||
**Waves 1 through 4 produce almost no field-visible change.** That is deliberate and it is
|
**Waves 1 through 4 produce almost no field-visible change.** That is deliberate and it is
|
||||||
roughly the first third of the effort. It is called out here because the Micron team is
|
roughly the first third of the effort. It is called out here because the Micron team is
|
||||||
|
|||||||
@@ -27,26 +27,32 @@ services:
|
|||||||
POSTGRES_HOST: db
|
POSTGRES_HOST: db
|
||||||
# Optional full-URL override (must be URL-encoded if used).
|
# Optional full-URL override (must be URL-encoded if used).
|
||||||
DATABASE_URL: ${DATABASE_URL:-}
|
DATABASE_URL: ${DATABASE_URL:-}
|
||||||
# Signs login session cookies. REQUIRED — compose fails fast if it's unset,
|
# Signs login session cookies, AFTER Okta has confirmed who someone is.
|
||||||
# and the API refuses to start in production without it (see server/auth.py).
|
# REQUIRED — compose fails fast if it's unset, and the API refuses to
|
||||||
|
# start in production without it (see server/auth.py).
|
||||||
AUTH_SECRET_KEY: ${AUTH_SECRET_KEY:?set AUTH_SECRET_KEY in .env (see server/.env.example)}
|
AUTH_SECRET_KEY: ${AUTH_SECRET_KEY:?set AUTH_SECRET_KEY in .env (see server/.env.example)}
|
||||||
AUTH_SESSION_HOURS: ${AUTH_SESSION_HOURS:-12}
|
AUTH_SESSION_HOURS: ${AUTH_SESSION_HOURS:-12}
|
||||||
|
# Okta OIDC — the only sign-in path (D15/D16). Not marked required the
|
||||||
|
# way AUTH_SECRET_KEY is: the API starts without these, it just refuses
|
||||||
|
# every sign-in and says so in the startup log (server/okta_auth.py
|
||||||
|
# describe()). See server/.env.example for what each one is and how to
|
||||||
|
# get it from the Okta app integration.
|
||||||
|
OKTA_ISSUER: ${OKTA_ISSUER:-}
|
||||||
|
OKTA_CLIENT_ID: ${OKTA_CLIENT_ID:-}
|
||||||
|
OKTA_CLIENT_SECRET: ${OKTA_CLIENT_SECRET:-}
|
||||||
|
OKTA_REDIRECT_URI: ${OKTA_REDIRECT_URI:-}
|
||||||
|
# NOT ${OKTA_IDENTITY_CLAIM:-} — server/okta_auth.py's own default only
|
||||||
|
# applies when the env var is UNSET, and compose setting it to an empty
|
||||||
|
# string here is not the same thing as leaving it unset. An empty value
|
||||||
|
# would make the API look for a claim literally named "", which is
|
||||||
|
# never present, so EVERY sign-in would 503. Mirror the same default
|
||||||
|
# here instead, so an operator who leaves .env's copy commented out gets
|
||||||
|
# the real default, not a broken one.
|
||||||
|
OKTA_IDENTITY_CLAIM: ${OKTA_IDENTITY_CLAIM:-preferred_username}
|
||||||
# Optional — SMTP password for WP-assignment emails. Email is off by
|
# Optional — SMTP password for WP-assignment emails. Email is off by
|
||||||
# default and enabled from the Admin console; this is the only email
|
# default and enabled from the Admin console; this is the only email
|
||||||
# secret and it is never stored in the DB. Leave unset until configured.
|
# secret and it is never stored in the DB. Leave unset until configured.
|
||||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||||
# D13 — domain authentication. REQUIRED: the suite stores no passwords and
|
|
||||||
# has no local fallback, so a wrong value here means nobody can sign in.
|
|
||||||
# Connect to the DOMAIN NAME, never a DC or an IP (SAN + DNS round-robin
|
|
||||||
# across six DCs). See server/ldap_auth.py and DEPLOYMENT.md.
|
|
||||||
LDAP_DOMAIN: ${LDAP_DOMAIN:-prime.local}
|
|
||||||
LDAP_HOST: ${LDAP_HOST:-prime.local}
|
|
||||||
# Trust anchor for the DC certificate — public CA certs, baked into the image
|
|
||||||
# at server/certs/. Override only to point at a mounted bundle.
|
|
||||||
LDAP_CA_FILE: ${LDAP_CA_FILE:-/app/server/certs/prime-ca-chain.pem}
|
|
||||||
# AD group required to sign in. Empty = any domain account. This is the
|
|
||||||
# initial value; the live one is set in the Admin console (T10.5).
|
|
||||||
LDAP_REQUIRED_GROUP: ${LDAP_REQUIRED_GROUP:-}
|
|
||||||
# Optional — read-only SQL Server connection to the Micron asset catalog,
|
# Optional — read-only SQL Server connection to the Micron asset catalog,
|
||||||
# which backs the asset picker in the work package creator. Leave unset and
|
# which backs the asset picker in the work package creator. Leave unset and
|
||||||
# the picker cleanly falls back to manual entry (see server/assets_db.py).
|
# the picker cleanly falls back to manual entry (see server/assets_db.py).
|
||||||
@@ -58,15 +64,10 @@ services:
|
|||||||
condition: service_healthy # waits for postgres to accept connections
|
condition: service_healthy # waits for postgres to accept connections
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
# Reaching the Micron database — and, since D13, the domain controllers —
|
# Reaching the Micron database means leaving this compose project, and
|
||||||
# means leaving this compose project, and `internal` is deliberately
|
# `internal` is deliberately egress-free. `outbound` is attached to the api
|
||||||
# egress-free. `outbound` is attached to the api container ONLY; the database
|
# container ONLY — the database and backup containers stay sealed. Detach it
|
||||||
# and backup containers stay sealed.
|
# again if you are not using the Micron asset picker.
|
||||||
#
|
|
||||||
# DO NOT DETACH THIS. It used to be optional ("detach it if you are not using
|
|
||||||
# the Micron asset picker"), but authentication now needs a route to
|
|
||||||
# prime.local:636. Without it every sign-in fails and there is no local
|
|
||||||
# password fallback to fall back to.
|
|
||||||
- outbound
|
- outbound
|
||||||
|
|
||||||
db:
|
db:
|
||||||
@@ -130,7 +131,7 @@ networks:
|
|||||||
# An ordinary bridge network, i.e. one that HAS a default gateway. `internal`
|
# An ordinary bridge network, i.e. one that HAS a default gateway. `internal`
|
||||||
# above removes the gateway entirely, which blocks not just the internet but
|
# above removes the gateway entirely, which blocks not just the internet but
|
||||||
# the LAN and the VPN too — so the api container needs this second network to
|
# the LAN and the VPN too — so the api container needs this second network to
|
||||||
# reach the domain controllers (LDAPS, D13) and the Micron asset database.
|
# reach the Micron asset database. Attached to `api` alone: `db` and `backup`
|
||||||
# Attached to `api` alone: `db` and `backup` remain on `internal` only and
|
# remain on `internal` only and still have no way off the host.
|
||||||
# still have no way off the host. Required — see the note on the api service.
|
# Detach it from api if you are not using the Micron asset picker.
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
Before Width: | Height: | Size: 296 KiB After Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 260 KiB |
|
Before Width: | Height: | Size: 340 KiB After Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 299 KiB After Width: | Height: | Size: 125 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: 66 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 34 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 |
@@ -283,7 +283,9 @@ python tests/triage_check.py # A6 - the sidebar answers the stand-up
|
|||||||
python tests/qa_gate_check.py # CR-014/D2/D9/D10 - QA gate + capture sink 41 checks
|
python tests/qa_gate_check.py # CR-014/D2/D9/D10 - QA gate + capture sink 41 checks
|
||||||
python tests/files_check.py # CR-007/D8 - drawings upload + real offline 36 checks
|
python tests/files_check.py # CR-007/D8 - drawings upload + real offline 36 checks
|
||||||
python tests/sticky_bar_check.py # B6 - save reachable on every wizard step 12 checks
|
python tests/sticky_bar_check.py # B6 - save reachable on every wizard step 12 checks
|
||||||
python tests/usage_check.py # D5 - one analytics core, admin report 15 checks
|
# tests/usage_check.py (D5) removed at T11.6 - it tested the per-browser
|
||||||
|
# analytics core and admin report, both retired in favour of CR-019's
|
||||||
|
# server-side Activity & usage card (see docs/waves/decisions-2026-09-17.md).
|
||||||
python tests/creator_dialogs_check.py # S1 creator - 0 natives, errors at fields 20 checks
|
python tests/creator_dialogs_check.py # S1 creator - 0 natives, errors at fields 20 checks
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -292,7 +294,7 @@ Wave 8 adds these:
|
|||||||
```bash
|
```bash
|
||||||
python tests/kitting_check.py # CR-009/010/012 - statuses, owner, delivery 26 checks
|
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/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
|
python tests/mreq_check.py # CR-013 - lightweight request, end to end 19 checks
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -555,71 +555,110 @@ deliberately deferred.
|
|||||||
- **Suggested wave or follow-up:** next housekeeping pass, with the check
|
- **Suggested wave or follow-up:** next housekeeping pass, with the check
|
||||||
widened so it cannot recur.
|
widened so it cannot recur.
|
||||||
|
|
||||||
### BL-026 — CLOSED 2026-08-21 (removed; nothing referenced it)
|
### BL-026 — No version stamp: "is live current?" cannot be answered from the app
|
||||||
|
|
||||||
- **Found during:** `T10.3` (D13), stripping the password code paths
|
- **Found during:** the 2026-08-21 outage triage (the question that started it)
|
||||||
- **Where:** `server/notify.py`, `send_now()`
|
- **Where:** `Dockerfile` / build, `server/app.py` `/api/health`, admin console
|
||||||
- **What:** `send_now` sends one message immediately, outside the outbox queue. Its
|
- **What:** the app carries no record of what code it is running. `/api/health`
|
||||||
only caller was `forgot_password`, because a reset link must not sit in a queue.
|
returns `{"ok": true}` and nothing identifies the deployed commit, so
|
||||||
`T10.3` deleted that endpoint, so the function now has no callers anywhere in
|
answering "is the live site on the latest code?" took fingerprinting
|
||||||
`server/` or `tests/` — verified by grep, not assumed.
|
(probing for files/routes that only exist after certain merges) in the
|
||||||
- **Resolution:** deleted. Raised as a judgement call between "remove it" and "keep
|
middle of an outage. The fix: bake the git SHA into the image at build time
|
||||||
it as the documented immediate-send path"; answered on Aug 21 — remove it. Nothing
|
(`ARG GIT_SHA`), return it from `/api/health`
|
||||||
in `server/` or `tests/` referenced it, and its docstring explained itself entirely
|
(`{"ok": true, "version": "<sha>"}`), and show it on the admin console's
|
||||||
in terms of password resets, which no longer exist. Keeping an unused sender that
|
diagnostics card. Then currency is one glance against `git log -1`.
|
||||||
bypasses the outbox is a liability, not an asset: the next person to need immediate
|
- **Why not now:** new scope — needs its own item id per the working rules
|
||||||
mail should write it against the requirement they actually have.
|
(D13 is the natural next), and it touches the image build, which deserves a
|
||||||
- **Note:** `send_email` (the raw SMTP call it wrapped) is untouched and still used by
|
deploy alongside someone with host access.
|
||||||
the outbox.
|
- **Suggested wave or follow-up:** next housekeeping pass; ~1 task including a
|
||||||
|
probe check that /api/health carries a version field.
|
||||||
|
|
||||||
### BL-027 — Okta exists on this estate; OIDC is a live alternative to the LDAPS bind
|
### BL-027 — Migrations are rehearsed on SQLite only; production is Postgres
|
||||||
|
|
||||||
- **Found during:** `T10.6` (D13), repointing "Forgot password?" at
|
- **Found during:** the 2026-08-21 production outage (D6's `material_items`
|
||||||
`https://primecontrols.okta.com/`
|
migration crash-looped the api container)
|
||||||
- **Where:** authentication as a whole — `server/ldap_auth.py`, `server/app.py` `login()`
|
- **Where:** `DEPLOYMENT.md` (the update/deploy steps), `tests/`
|
||||||
- **What:** D13 chose an LDAPS simple bind, decided before it was known that the company
|
- **What:** the migration chain is verified end-to-end on scratch SQLite, but
|
||||||
runs an Okta tenant. Okta presumably federates to `prime.local` (which is why the
|
production runs Postgres, and the dialects disagree exactly where it hurts:
|
||||||
Windows password is still the one that binds), but its existence means an OIDC
|
`server_default=sa.text('1')` on a Boolean passed every SQLite rehearsal and
|
||||||
authorization-code flow is available in principle. That would be strictly better on
|
was refused by Postgres at deploy (DatatypeMismatch), taking the API down
|
||||||
three counts the LDAPS design cannot match: this app would never see a password at all,
|
until the table was created by hand. The hotfix (64eac0c) fixed that one
|
||||||
MFA would come for free, and the domain-lockout hazard that forced
|
instance and pinned the Boolean-default class in `materials_check`; the
|
||||||
`AUTH_MAX_ATTEMPTS` down to 2 would disappear entirely, because failed attempts would
|
CLASS of dialect drift is still unguarded. Two cheap layers: (1) a runbook
|
||||||
land on Okta rather than on a bind this endpoint makes.
|
step — render `alembic upgrade --sql` for the postgresql dialect and read it
|
||||||
- **Why not now:** D13 was decided and reaffirmed, T10.1–T10.4 are built and verified
|
before restarting (offline, needs no live DB; this render would have shown
|
||||||
against the live domain, and swapping the mechanism mid-wave is exactly the reordering
|
`DEFAULT 1` on a boolean); (2) better, a probe that renders every migration
|
||||||
`CLAUDE.md` forbids. Recording it is not the same as reopening it.
|
for the postgresql dialect on each run and fails on anything the dialect
|
||||||
- **Suggested wave or follow-up:** its own item and its own decision, with Nick and
|
rejects or on known-bad patterns.
|
||||||
whoever administers the Okta tenant. Not a widening of D13.
|
- **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 — `assets_check` fails on any machine that has `MICRON_DB_URL` set
|
### BL-028 — `okta_auth.describe()` is never called
|
||||||
|
|
||||||
- **Found during:** `T10.7` (D13), running the full suite
|
- **Found during:** T10.9 (writing the deploy runbook's live-verification step)
|
||||||
- **Where:** `tests/assets_check.py`, the "no `MICRON_DB_URL`" case
|
- **Where:** `server/okta_auth.py` (`describe()`), `server/app.py` (no caller anywhere)
|
||||||
- **What:** the check asserts `/api/assets` answers `configured:false` when the catalog
|
- **What:** `describe()` exists specifically to shout `"*** FAKE OKTA PROVIDER
|
||||||
is not configured, but `start_server` passes the ambient environment through. On a
|
ACTIVE..."` or report missing config at a glance, and both
|
||||||
developer machine whose `.env` sets `MICRON_DB_URL` — which is the normal state for
|
`server/.env.example` and `server/README.md` tell an operator to "check the
|
||||||
anyone who has ever used the asset picker — the API is genuinely configured, returns
|
startup log line" for it. Nothing prints it. `app.py` never imports or calls
|
||||||
real Micron tags, and three checks fail. Nothing is wrong with the app; the test's
|
`describe()` at process start, so that log line does not exist and an operator
|
||||||
premise is violated by the environment it runs in.
|
following the docs will not find it.
|
||||||
- **Fix:** pop `MICRON_DB_URL` from the env for that server, exactly as `start_server`
|
- **Why not now:** a runbook is documentation, not server code; wiring a
|
||||||
now pops `LDAP_REQUIRED_GROUP` for the same reason (`T10.7`).
|
startup log call is a real (if small) change to `app.py` and wants its own
|
||||||
- **Why not now:** it is not this wave's defect and the fix belongs with whoever owns
|
diff and its own verification, not a rider on T10.9.
|
||||||
the asset picker's tests. Recorded so the failure is not mistaken for D13 fallout.
|
- **Suggested wave or follow-up:** next housekeeping pass. One call
|
||||||
- **Suggested wave or follow-up:** next housekeeping pass.
|
(`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 — `generalinfo_check` flags a pre-existing `rgba()` in the creator stylesheet
|
### BL-029 — `users.failed_attempts` / `users.locked_until` are vestigial
|
||||||
|
|
||||||
- **Found during:** `T10.7` (D13), running the full suite
|
- **Found during:** T10.6
|
||||||
- **Where:** `html/wp-creation-styles.css:929` — `box-shadow:0 8px 24px rgba(20,30,50,.18)`
|
- **Where:** `server/models.py` (`User.failed_attempts`, `User.locked_until`)
|
||||||
- **What:** `generalinfo_check`'s token-rule check reports "no colour literal was added
|
- **What:** both columns exist to support local-password lockout, which T10.4
|
||||||
to the creator's stylesheet" and fails on `rgba(`. The literal predates this wave —
|
removed. They are still reset to `0`/`None` on every Okta sign-in but nothing
|
||||||
last touched by `8efe624` (F6) — and `git diff main...HEAD` shows the file untouched
|
increments them anymore — dead columns, not a bug, but schema drift from the
|
||||||
by the LDAPS branch.
|
D15 cutover.
|
||||||
- **The real question is which is wrong.** `C4`'s recorded exception allows rgba
|
- **Why not now:** T10.6 is documentation scope; dropping columns is a migration
|
||||||
**alphas** as opacity recipes, which is arguably what a shadow is; if so the check is
|
and belongs with the rest of the local-password cleanup, not folded into a
|
||||||
too strict and should match a colour literal rather than the `rgba(` token. If not,
|
docs task.
|
||||||
the shadow needs a token. Either way it is a one-line change plus a decision, and
|
- **Suggested wave or follow-up:** next housekeeping pass, alongside any other
|
||||||
the decision is not this wave's to make.
|
post-cutover schema tidy-up.
|
||||||
- **Why not now:** drive-by fixes to the token system are what `CLAUDE.md` forbids, and
|
|
||||||
this one needs the C4 exception interpreted rather than guessed.
|
### BL-031 — A raw `rgba()` shadow survives in the creator's stylesheet
|
||||||
- **Suggested wave or follow-up:** next housekeeping pass, with `C4` re-read first.
|
|
||||||
|
- **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.
|
||||||
|
|||||||
@@ -1,210 +0,0 @@
|
|||||||
# Decisions — August 21, 2026
|
|
||||||
|
|
||||||
One item, and it is the largest single change to the auth model since the login portal
|
|
||||||
shipped. Like the August 18 and August 20 sets it is a **new item** with its own `D` id,
|
|
||||||
not a reinterpretation of an existing one. `D1`–`D12` are taken; this is `D13`.
|
|
||||||
|
|
||||||
Raised by Cody Schaefer on Aug 21 2026 while asking how the suite handles HTTPS. Nothing in
|
|
||||||
`IMPLEMENTATION.md`, in `CR`/`F`/`S`/`A`/`B`/`C`, or in `docs/waves/backlog.md` covers
|
|
||||||
authentication against the domain — so this is new scope, and it gets a new ID rather than
|
|
||||||
being folded into the login-portal work that produced `server/auth.py`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D13 — Authentication moves to the domain over LDAPS
|
|
||||||
|
|
||||||
- **Amends:** the authentication model shipped in `DEPLOY-login-portal.md` (bcrypt hashes in
|
|
||||||
`users.password_hash`, verified in-process). That document describes what is being
|
|
||||||
replaced, not what is wrong — it was correct for a suite with no directory behind it.
|
|
||||||
- **Surface:** `server/` (`auth.py`, `app.py`, `models.py`, `manage_users.py`, `notify.py`,
|
|
||||||
a new `ldap_auth.py`, a new migration), `html/` (`login.html`, `login.js`, `admin.js`,
|
|
||||||
`users.js`), `requirements.txt`, `docker-compose.yml`, `Dockerfile`, deployment docs.
|
|
||||||
- **Wave:** 10 (`docs/waves/wave-10.md`). Depends on wave 9 merged, which it is.
|
|
||||||
|
|
||||||
### The decision
|
|
||||||
|
|
||||||
The suite stops storing passwords. A sign-in becomes a **simple bind to
|
|
||||||
`ldaps://prime.local:636`** as `<username>@prime.local` using the password the person typed.
|
|
||||||
A successful bind is the authentication. `users.password_hash` is dropped from the schema.
|
|
||||||
|
|
||||||
Four parts, all four required for the item to be done:
|
|
||||||
|
|
||||||
1. **LDAPS bind replaces local password verification.** `password_hash` is removed from the
|
|
||||||
model and from the database by migration. No password is stored, hashed or otherwise.
|
|
||||||
2. **Accounts are provisioned just-in-time.** A successful bind for a username with no
|
|
||||||
`users` row creates one, at the default role, with `full_name`/`email` read from the
|
|
||||||
directory.
|
|
||||||
3. **A required group gates login.** An AD group is configured; a bind that succeeds but
|
|
||||||
whose account is not in that group is refused. Membership is evaluated including nested
|
|
||||||
groups.
|
|
||||||
4. **Existing accounts keep their roles, and roles stay local.** An existing `admin` stays
|
|
||||||
an admin on first directory login. Granting admin to an existing account continues to
|
|
||||||
work from the Admin console. The directory supplies *identity*; this app supplies
|
|
||||||
*authorization*.
|
|
||||||
|
|
||||||
### Why LDAPS and not the certificate already in play
|
|
||||||
|
|
||||||
Recorded because the question was asked directly and the answer is not obvious.
|
|
||||||
|
|
||||||
The site's serving certificate is a **Let's Encrypt** cert (`CN=wp.controls.dev`, issued by
|
|
||||||
`Let's Encrypt YE2`, expiring 2026-11-08) held by an **OpenResty** instance at
|
|
||||||
`192.168.3.56` that is not part of this repo. It is a public domain-validated certificate.
|
|
||||||
It attests that whoever presented it controls DNS for `wp.controls.dev`; it carries no user
|
|
||||||
identity and no relationship to `prime.local`. There is no configuration that turns it into
|
|
||||||
a domain credential, so cert-based auth was never available "for free".
|
|
||||||
|
|
||||||
Client-certificate auth (mTLS) was considered and rejected for this wave: TLS terminates two
|
|
||||||
hops upstream at OpenResty, so the API never sees the handshake, and doing it in-app would
|
|
||||||
mean bypassing the proxy and losing the CSP/HSTS headers and static serving with it.
|
|
||||||
|
|
||||||
### What was verified before writing this (Aug 21 2026)
|
|
||||||
|
|
||||||
| Fact | Value |
|
|
||||||
|---|---|
|
|
||||||
| LDAPS reachable | `192.168.3.37:636` open, TLS 1.3, `TLS_AES_256_GCM_SHA384` |
|
|
||||||
| DC cert issuer | `CN=PRIME CONTROLS ISSUING CA 1, DC=prime, DC=local` |
|
|
||||||
| Root of that chain | `CN=PRIME CONTROLS ROOT CA` (self-signed, expires 2051-09-09) |
|
|
||||||
| Issuing CA expiry | 2036-09-09 |
|
|
||||||
| DC cert SAN | `DR-DC10Core.prime.local`, `prime.local`, `PRIME` |
|
|
||||||
| DCs published in `_ldap._tcp.prime.local` | six — `nla-dc10`, `lew-dc20`, `dr-dc30-core`, `lew-dc40`, `SABINEDC`, `dr-dc10core` |
|
|
||||||
| Chain validates against root+issuing bundle | yes — `Verify return code: 0 (ok)` |
|
|
||||||
|
|
||||||
Two consequences of that table, both binding on the build:
|
|
||||||
|
|
||||||
- **Connect to `prime.local`, not to a DC name or an IP.** Every DC's certificate carries
|
|
||||||
`prime.local` in its SAN, so the domain name both passes hostname validation and
|
|
||||||
round-robins across all six DCs. Verified: `prime.local` gives `0 (ok)`; the raw IP
|
|
||||||
`192.168.3.37` gives `62 (hostname mismatch)`, because there is no IP SAN.
|
|
||||||
- **The trust anchor is a CA certificate, not a certificate issued to this app.** The API is
|
|
||||||
the TLS *client*; clients present nothing. It needs `PRIME CONTROLS ROOT CA` plus
|
|
||||||
`PRIME CONTROLS ISSUING CA 1` as a PEM bundle, which is public information. No CSR, no
|
|
||||||
enrollment, no private key, nothing to request from IT.
|
|
||||||
|
|
||||||
### Non-negotiables
|
|
||||||
|
|
||||||
These are the ways this change goes wrong, and each has a done-when check in wave 10.
|
|
||||||
|
|
||||||
- **An empty password must be rejected before `bind()` is called.** In LDAP a simple bind
|
|
||||||
with an empty password is an *anonymous* bind and it **succeeds**. Without an explicit
|
|
||||||
guard, a blank password authenticates as any username submitted. This is the single
|
|
||||||
highest-severity failure mode in the item and it gets its own test.
|
|
||||||
- **`validate=ssl.CERT_REQUIRED` with an explicit CA file.** Not `CERT_NONE`, and not the
|
|
||||||
system trust store. `CERT_NONE` still encrypts, so it fails silently — what it loses is
|
|
||||||
the ability to distinguish the real DC from an attacker who terminates the TLS session,
|
|
||||||
harvests the domain password and relays the bind onward. Since domain credentials now
|
|
||||||
cross that channel, a compromise escalates from "this app" to Windows, mail and file
|
|
||||||
shares. The system store is refused separately because it currently trusts five other
|
|
||||||
self-signed CAs (`prime-DR-CAPRIME-CA`, `prime-DR-CA_PRIME-CA`, `prime-DR-DC20-CA`,
|
|
||||||
`PRIME CONTROLS ISSUING CA 2`, and a stray `L55401TDKLY3.prime.local` machine cert in
|
|
||||||
Trusted Root).
|
|
||||||
- **The app's lockout must trip below the domain's.** `LOGIN_MAX_ATTEMPTS` currently writes
|
|
||||||
to the local `users` row. Once failures are binds, they count against the **AD** lockout
|
|
||||||
policy, so an unauthenticated caller hammering `/api/auth/login` can lock real domain
|
|
||||||
accounts out of Windows. The local throttle must stop calling the DC before the domain
|
|
||||||
threshold is reached.
|
|
||||||
- **Never leak which usernames exist.** `login()` today equalises response timing on purpose
|
|
||||||
so a caller cannot enumerate accounts. Directory error 49 sub-codes (`52e` bad password,
|
|
||||||
`532` password expired, `533` disabled, `775` locked) are useful in the log and must not
|
|
||||||
reach the response body.
|
|
||||||
|
|
||||||
### Answered August 21, 2026 — both were raised as open and both were decided
|
|
||||||
|
|
||||||
**Break-glass: none. LDAPS is the only way in.** Asked and reaffirmed after the lockout risk
|
|
||||||
was stated. There is no emergency local account, no env-var bypass, and no CLI-minted
|
|
||||||
session. The consequence is explicit and belongs in the runbook rather than being discovered:
|
|
||||||
**if the domain is unreachable, or `LDAP_CA_FILE` is wrong, or the required group is
|
|
||||||
misconfigured, nobody can sign in — including admins — and no amount of shell access fixes
|
|
||||||
it except correcting the configuration and restarting.** `T10.5`'s validate-on-save guard is
|
|
||||||
therefore not a nicety; with no fallback it is the only thing standing between a typo in the
|
|
||||||
group field and a total outage.
|
|
||||||
|
|
||||||
Three things follow, and they are done-when checks in wave 10 rather than advice:
|
|
||||||
|
|
||||||
- The startup log must state whether LDAP is configured and reachable, so a broken deploy is
|
|
||||||
visible in `docker compose logs api` and not only at the login box.
|
|
||||||
- `/api/health` stays exempt from auth (it already is) so the outage is diagnosable.
|
|
||||||
- The group setting cannot be saved without proving the saving admin is a member.
|
|
||||||
|
|
||||||
**Identity: bind on `sAMAccountName`, match on `sAMAccountName` *or* `mail`.** A simple bind
|
|
||||||
can only carry one identifier, and AD accepts the UPN form — so the bind is
|
|
||||||
`sAMAccountName@prime.local` and that is what the login box takes. Matching an existing local
|
|
||||||
row is a separate question, and it uses **both**: after a successful bind the directory's
|
|
||||||
`sAMAccountName` and `mail` are both read, and `auth.find_user` is extended to match a local
|
|
||||||
row on either, case-insensitively. That is what keeps an existing admin's role whether their
|
|
||||||
hand-typed username was `c.schaefer` or `c.schaefer@prime-controls.com`.
|
|
||||||
|
|
||||||
Two consequences worth knowing:
|
|
||||||
|
|
||||||
- The mail domain (`prime-controls.com`) is not the AD domain (`prime.local`), so `mail` is
|
|
||||||
never a valid bind string. It is a matching key only.
|
|
||||||
- If someone types an address at the login box, the local part is used as the
|
|
||||||
`sAMAccountName` — **one** bind attempt, never several, because each failed bind counts
|
|
||||||
against the domain lockout policy. That assumes the mail local part equals the
|
|
||||||
`sAMAccountName`. Where it does not, the person must type their short logon name; this is
|
|
||||||
logged when it happens and documented in `T10.8`.
|
|
||||||
- The production `users` table should still be compared against AD before this deploys. A
|
|
||||||
row matching on neither key gets a *second*, JIT-provisioned account at the default role
|
|
||||||
rather than keeping its admin. Matching on two keys narrows that risk; it does not remove
|
|
||||||
it.
|
|
||||||
|
|
||||||
### Explicitly out of scope
|
|
||||||
|
|
||||||
- mTLS / client-certificate authentication (see above).
|
|
||||||
- Kerberos / SPNEGO single sign-on. It is the better long-term answer for domain-joined
|
|
||||||
desktops and needs a keytab, an SPN and browser trust configuration; it is not this item.
|
|
||||||
- Group-to-role mapping (e.g. an AD group that confers `project_admin`). Criterion 4 keeps
|
|
||||||
authorization local on purpose. Worth its own item later; logged in `backlog.md`.
|
|
||||||
- Replacing the Let's Encrypt certificate or changing anything on the OpenResty host.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D14 — The CLI authenticates against the domain, and stops creating accounts
|
|
||||||
|
|
||||||
- **Amends:** `D13` criterion 4, which said role granting keeps working *from the Admin
|
|
||||||
console*. It said nothing about `manage_users.py`, which had no authentication of any
|
|
||||||
kind. Requiring one is a new requirement, so it gets its own id rather than widening
|
|
||||||
criterion 4.
|
|
||||||
- **Surface:** `server/manage_users.py`
|
|
||||||
- **Task:** `T10.9`
|
|
||||||
|
|
||||||
### The decision
|
|
||||||
|
|
||||||
1. **`create-admin` and `create` are removed.** `D13` provisions accounts on first
|
|
||||||
successful sign-in, so creating them by hand is redundant. Removing them also closes a
|
|
||||||
class of problem: every row now originates from a bind, so a username cannot be typed
|
|
||||||
in wrong and end up orphaned from the directory identity it was meant to match. That
|
|
||||||
risk now applies only to rows the old CLI already created.
|
|
||||||
2. **`promote` and `demote` replace them.** The directory supplies identity; this app
|
|
||||||
supplies authorization, and this is where authorization is assigned from a shell.
|
|
||||||
3. **Every state-changing command requires a domain bind.** Prompted, via `getpass`.
|
|
||||||
There is deliberately no `--password` flag: that would put a live domain password into
|
|
||||||
shell history and into `ps` output for every other user on the box.
|
|
||||||
4. **`list` needs no credential**, so an outage stays diagnosable.
|
|
||||||
|
|
||||||
### Bootstrapping the first admin, which changed shape
|
|
||||||
|
|
||||||
Two steps, in order: **sign in once** (which provisions the account at `project_user`),
|
|
||||||
then **`promote <sAMAccountName>`**. Before D14 the first admin was created with a
|
|
||||||
password; there is no password now, and no account to create.
|
|
||||||
|
|
||||||
### What this is worth, stated plainly
|
|
||||||
|
|
||||||
Anyone with a shell on the api container can still write to the `users` table directly
|
|
||||||
with `psql` or `sqlite3`. So the bind is **defence in depth and, mostly,
|
|
||||||
ACCOUNTABILITY** — not a security boundary. Before D14 every role change made from a
|
|
||||||
shell was invisible in `AuditLog` while the same change through the console was recorded;
|
|
||||||
now both are recorded and both name a person. Any-domain-user was accepted as sufficient
|
|
||||||
(no privileged group exists on this estate, and machine access is already restricted to a
|
|
||||||
few people), which was decided knowing the above.
|
|
||||||
|
|
||||||
### Two deliberate divergences from the API
|
|
||||||
|
|
||||||
- **The bind does NOT apply the login group gate.** If a mistyped required group locks
|
|
||||||
everyone out of the console, this tool has to still work — otherwise the only route to
|
|
||||||
fixing the lockout is the thing the lockout prevents.
|
|
||||||
- **Changing your OWN role is permitted here.** `set_user_role` in `app.py` forbids it to
|
|
||||||
stop an admin locking themselves out of the console. Here it is the entire bootstrap
|
|
||||||
path, so it is allowed and recorded with `{"self": true}` in the audit detail.
|
|
||||||
|
|
||||||
The last-admin guard is kept, matching `set_user_role`: an app with no admin cannot be
|
|
||||||
administered, and there is no password login left to recover through.
|
|
||||||
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`.
|
||||||
340
docs/waves/decisions-2026-09-17.md
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
# Decisions — September 17, 2026
|
||||||
|
|
||||||
|
Three items. Like `D11`, `D15`, `D16` and `D17`, these are new scope raised after
|
||||||
|
R2 closed out (see `docs/reference/completion.md`, T9.7), not a reopening of
|
||||||
|
anything already decided there.
|
||||||
|
|
||||||
|
`CR-019` and `CR-020` get `CR` ids because they are field/product-facing feature
|
||||||
|
requests — the same kind of thing `CR-001`-`CR-018` were — not internal
|
||||||
|
engineering calls made mid-build. `D18` gets a `D` id because it is exactly that:
|
||||||
|
an internal security/architecture call, the same category as `D15` (Okta vs.
|
||||||
|
LDAPS), not a feature a user asked for.
|
||||||
|
|
||||||
|
Requested/raised by Matt Mabrey, 2026-09-17.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CR-019 — Usage and activity metrics (admin console)
|
||||||
|
|
||||||
|
- **Area:** Admin Console / Monitoring
|
||||||
|
- **Priority:** proposed High — the current admin console has no reliable way to
|
||||||
|
answer "who is using this and how much," which is the same visibility gap the
|
||||||
|
original UX review found in `B4` (numbers that look authoritative but are not).
|
||||||
|
- **Source:** Matt Mabrey, 2026-09-17.
|
||||||
|
|
||||||
|
### Why this is not already built
|
||||||
|
|
||||||
|
`D5`/`T7.10` already shipped a usage-analytics feature — `html/wp-usage.js`, with
|
||||||
|
a report at `admin.js:666-699` — but it reads `localStorage`, which is scoped to
|
||||||
|
one browser. The report's own empty state says exactly this: *"No usage recorded
|
||||||
|
in this browser yet."* It cannot show who across the team is active or what they
|
||||||
|
use, because each person's activity exists only on their own machine. This is the
|
||||||
|
same class of defect `B4` named for the pipeline strip — a number that looks
|
||||||
|
authoritative but is not — just never generalized to this feature. `CR-019` is
|
||||||
|
therefore new server-side work, not a UI addition on top of what exists.
|
||||||
|
|
||||||
|
Two things already in the schema are relevant and reused rather than duplicated:
|
||||||
|
`User.last_login_at` (one timestamp, no history) and `AuditLog` (append-only,
|
||||||
|
already records business mutations — WP created, status changed, role changed —
|
||||||
|
per user, with a timestamp). Neither captures navigation or feature-open events,
|
||||||
|
which is the actual gap.
|
||||||
|
|
||||||
|
### Intent
|
||||||
|
|
||||||
|
Give admins a real, server-backed picture of who is using the suite, what parts
|
||||||
|
of it they use, and how active they are — replacing the per-browser report as
|
||||||
|
the thing anyone actually looks at.
|
||||||
|
|
||||||
|
### Decisions, made 2026-09-17
|
||||||
|
|
||||||
|
1. **Retention: indefinite.** No automatic purge of usage-event data. (Separate
|
||||||
|
from `AuditLog`'s own retention, which this does not change.)
|
||||||
|
2. **Scope: suite-wide, filterable.** Not per-project by default; the console
|
||||||
|
provides filters (date range, project, user, tool/page) rather than scoping
|
||||||
|
the data itself.
|
||||||
|
3. **Export: raw view shows real identities; export supports sanitization.**
|
||||||
|
The admin console's own tables and the CSV export both default to real
|
||||||
|
usernames — this is an internal audit tool, not a public one. But the export
|
||||||
|
also offers a "sanitize" toggle that replaces the actor with a **stable
|
||||||
|
pseudonymous id** (a per-user hash, consistent across rows and across
|
||||||
|
export runs) rather than dropping the identity field outright — so an
|
||||||
|
external system (Power BI or similar) can still group and trend "by user"
|
||||||
|
without ever receiving a real name. Flagged here as the recommended
|
||||||
|
approach rather than the only one Matt confirmed in so many words: if a
|
||||||
|
fully-anonymous (no stable id at all) export turns out to be what's
|
||||||
|
actually wanted, that is a one-line change to the same feature, raise it
|
||||||
|
in the PR rather than treating it as blocking.
|
||||||
|
4. **The old per-browser report is retired**, not kept alongside the new one.
|
||||||
|
Once `CR-019` ships, `admin.js`'s existing `usage-admin` panel (reading
|
||||||
|
`WPUsage.load(...)` per browser) is removed rather than left next to the
|
||||||
|
real report, where it would show a smaller, misleading number for whoever
|
||||||
|
happens to have it open. `html/wp-usage.js` and its two call sites
|
||||||
|
(`html/work-package-suite-app.js`'s wizard dwell-tracking, and the
|
||||||
|
creator's equivalent) are a separate question — the *recording* code can
|
||||||
|
stay or go independent of the *admin report* being retired, since dwell
|
||||||
|
events were never reliably tied to a real identity anyway. Default to
|
||||||
|
removing both unless a task finds a reason to keep the recorder; log that
|
||||||
|
reason rather than deciding it here.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
|
||||||
|
- A new admin-only tab in `admin.html` (same role gate as the User Directory)
|
||||||
|
shows: active users over a selectable date range (day/week/month), each
|
||||||
|
user's last-active timestamp, and a breakdown of which tools/pages get
|
||||||
|
opened and how often.
|
||||||
|
- Server-side event capture, keyed to the authenticated session (real identity,
|
||||||
|
not a browser-local guess) — a new table, not an extension of `AuditLog`,
|
||||||
|
since page-open/navigation events are not business mutations and mixing them
|
||||||
|
in would make `AuditLog` noisy for its existing, narrower purpose.
|
||||||
|
- The console's filters cover date range, project, user, and tool/page, and
|
||||||
|
combine (e.g., "user X, last 30 days, field view only").
|
||||||
|
- CSV export from the console, in both raw (real usernames) and sanitized
|
||||||
|
(stable pseudonymous id per user) modes.
|
||||||
|
- Retention is indefinite; nothing in this item purges data.
|
||||||
|
- View-only, refresh-on-load. No alerting — matches how the rest of the admin
|
||||||
|
console works today; if that changes later it is new scope, not a rider on
|
||||||
|
this item.
|
||||||
|
- The old per-browser "Usage" report and its admin-console panel are removed
|
||||||
|
in the same wave, not left running alongside the new one.
|
||||||
|
- Accessible per `CLAUDE.md`'s standing `C1` rules (this is a new component,
|
||||||
|
not a legacy one — it ships accessible or it is not done, same as
|
||||||
|
everything else built since wave 7).
|
||||||
|
|
||||||
|
### Frontend/backend boundary
|
||||||
|
|
||||||
|
This needs server work, the same way `CR-004`/`CR-018`/`B4` did: a real table,
|
||||||
|
a capture endpoint, an aggregation endpoint, and an export endpoint. If a task
|
||||||
|
under this item is being built by writing to `localStorage`, it is rebuilding
|
||||||
|
the exact defect this item exists to replace — stop and say so, per
|
||||||
|
`CLAUDE.md`.
|
||||||
|
|
||||||
|
### Scheduling
|
||||||
|
|
||||||
|
New wave. Wave 10 (Okta) is merged, so this does not wait on anything.
|
||||||
|
Task breakdown: `docs/waves/wave-11.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CR-020 — Bulk editing of users in the admin console
|
||||||
|
|
||||||
|
- **Area:** Admin Console / User Directory
|
||||||
|
- **Priority:** proposed High — three of the four actions below touch access
|
||||||
|
control (role, project assignment, active/disabled) and the fourth is a hard
|
||||||
|
delete; getting the guardrails right matters more than getting it built fast.
|
||||||
|
- **Source:** Matt Mabrey, 2026-09-17.
|
||||||
|
|
||||||
|
### Why this is not already built
|
||||||
|
|
||||||
|
Every user-editing action in `html/users.js` today is one row, one action:
|
||||||
|
a role `<select>` per row, an activate/disable button per row, a
|
||||||
|
per-user project-membership checklist opened one user at a time, and a
|
||||||
|
per-row delete. There is no row selection in the User Directory table at all.
|
||||||
|
Server-side, every corresponding endpoint
|
||||||
|
(`/api/auth/users/{id}/active`, `/role`, `/project-role`, `/projects`, and
|
||||||
|
`DELETE /api/auth/users/{id}`) takes exactly one `user_id`. Bulk editing is new
|
||||||
|
UI (selection) and, for most actions, either a loop over the existing
|
||||||
|
single-user endpoints or new endpoints that accept a list — the task decides
|
||||||
|
which, per user count and transaction-safety needs.
|
||||||
|
|
||||||
|
**Deletion is already a hard delete today** (`delete_user`, `server/app.py:1121`
|
||||||
|
— `db.delete(u)`, not a deactivate). Bulk delete inherits that: it is not this
|
||||||
|
item's job to invent a soft-delete pattern that does not exist for the single
|
||||||
|
case, but the confirmation step around it has to be sized for the fact that a
|
||||||
|
bad multi-select now removes more than one account at once, permanently.
|
||||||
|
|
||||||
|
### Decisions, made 2026-09-17
|
||||||
|
|
||||||
|
1. **All four bulk actions are in scope:** role change, activate/disable,
|
||||||
|
project assignment (add to / remove from a project, including the
|
||||||
|
project-role), and delete.
|
||||||
|
2. **Selection works two ways:** checkboxes (select-all and individual) in the
|
||||||
|
existing User Directory table, respecting whatever filter is already
|
||||||
|
applied (role, active/disabled, project) — and a CSV upload, for a one-off
|
||||||
|
bulk operation against an external list (e.g., an offboarding list that
|
||||||
|
didn't originate in this app). Both are in scope, not a choice between them.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
|
||||||
|
- The User Directory table gains row checkboxes and a select-all that respects
|
||||||
|
the current filter; a bulk-action toolbar appears once at least one row is
|
||||||
|
selected.
|
||||||
|
- CSV upload as an alternative to checkbox selection: a list of usernames plus
|
||||||
|
the action to apply. Validates every row, reports rejected ones by row (bad
|
||||||
|
username, user not found, actor lacks permission over that user) rather than
|
||||||
|
silently skipping them — the same validate-and-report pattern `CR-005`
|
||||||
|
established for list uploads.
|
||||||
|
- Every existing single-user guardrail carries forward unchanged: an actor
|
||||||
|
cannot include their own account in a bulk action that would disable, demote,
|
||||||
|
or delete it; a super user's bulk action is scoped to only the users and
|
||||||
|
projects `require_see_user`/`require_manage_user` already let them touch
|
||||||
|
today (a super user cannot use a bulk action to reach a user or project
|
||||||
|
outside what they manage, even via CSV); `grantable_roles` still gates which
|
||||||
|
roles an actor may assign in bulk, the same as one at a time.
|
||||||
|
- Every affected row is written to `AuditLog` individually, exactly as the
|
||||||
|
single-user endpoints do today (one `role_changed` / `user_deleted` / etc.
|
||||||
|
row per user) — a bulk action is many audited changes, not one opaque batch
|
||||||
|
entry, so per-user history stays intact and readable in isolation.
|
||||||
|
- Confirmation before applying, using the `wp-dialog` kit (`T7.9`), not a
|
||||||
|
native `confirm()`. The dialog names exactly how many users are affected and,
|
||||||
|
for delete specifically, lists the affected usernames before committing.
|
||||||
|
- Partial failure is reported, not hidden: if some rows in a batch fail (scope,
|
||||||
|
already-deleted, bad CSV row), the action applies to what it can and states
|
||||||
|
exactly which rows failed and why. It never reports success on a batch that
|
||||||
|
partly failed.
|
||||||
|
- Accessible per `C1`: real controls, keyboard-operable selection and bulk-
|
||||||
|
action toolbar, `aria-live` announcing the result.
|
||||||
|
|
||||||
|
### Recommended, not yet confirmed — raise in the PR if this is wrong
|
||||||
|
|
||||||
|
- **Bulk delete gets an extra confirmation step beyond naming the count** —
|
||||||
|
proposed as typing a confirmation phrase (e.g. the word `DELETE`) regardless
|
||||||
|
of how many rows are selected, since one bad multi-select now removes more
|
||||||
|
than one account, permanently, with no soft-delete to fall back on. This is
|
||||||
|
a recommendation, not a confirmed requirement — Matt has not signed off on
|
||||||
|
the exact mechanism.
|
||||||
|
- **Project assignment is add/remove, not replace-the-whole-list** — a bulk
|
||||||
|
"add these users to project X" or "remove these users from project X"
|
||||||
|
action, rather than a bulk action that overwrites a user's entire project
|
||||||
|
list. Proposed because add/remove is less likely to clobber project
|
||||||
|
memberships the actor didn't intend to touch; a replace-the-whole-list
|
||||||
|
version is a materially different (and riskier) feature if that turns out to
|
||||||
|
be what's actually wanted.
|
||||||
|
|
||||||
|
### Frontend/backend boundary
|
||||||
|
|
||||||
|
Selection state (which rows are checked) is fine as client-side UI state — it
|
||||||
|
is not persisted data. Everything the bulk action actually does (role,
|
||||||
|
active/disabled, project membership, delete) already requires server work
|
||||||
|
today for the single-user case, and bulk does not change that: no new
|
||||||
|
localStorage-derived state, no client-side aggregation of what changed.
|
||||||
|
|
||||||
|
### Scheduling
|
||||||
|
|
||||||
|
New wave. Independent of wave 11 (`CR-019`) — the two do not touch the same
|
||||||
|
code and can build in either order or in parallel. Task breakdown:
|
||||||
|
`docs/waves/wave-12.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D18 — Detecting and acting on Okta/AD deprovisioning
|
||||||
|
|
||||||
|
- **Raised by:** Matt Mabrey, 2026-09-17, in response to a direct question about
|
||||||
|
what happens when someone's AD account is removed and Okta subsequently drops
|
||||||
|
them.
|
||||||
|
- **Amends:** nothing decided, closes a gap `D15`/`D16` left unaddressed. Those
|
||||||
|
items designed how identity flows *into* this app (Okta authenticates, this
|
||||||
|
app JIT-provisions and owns roles); neither addressed what happens when an
|
||||||
|
identity is withdrawn. This app has never had any deprovisioning signal —
|
||||||
|
push or pull — since Okta went live.
|
||||||
|
|
||||||
|
### What was found
|
||||||
|
|
||||||
|
Traced through `server/auth.py` and `server/okta_auth.py`: this app has no
|
||||||
|
connection back to Okta after initial sign-in. Consequences, confirmed against
|
||||||
|
the actual code:
|
||||||
|
|
||||||
|
1. A local `users` row is never touched by anything Okta-side. `is_active`
|
||||||
|
stays `True` indefinitely unless an admin manually disables or deletes the
|
||||||
|
account through the User Directory. There is no flag distinguishing a
|
||||||
|
terminated employee's account from a current one.
|
||||||
|
2. `get_current_user` (`server/auth.py:237`) re-checks `is_active` and
|
||||||
|
`token_version` on **every request** — so a manual disable takes effect
|
||||||
|
immediately, on the very next request. The gap is not enforcement, it's
|
||||||
|
detection: nothing tells an admin to go flip that switch.
|
||||||
|
3. A session already live when someone is deprovisioned keeps working, fully,
|
||||||
|
for up to `AUTH_SESSION_HOURS` (12 hours today, `server/auth.py:63`),
|
||||||
|
because validity is checked against this app's own JWT and DB state only,
|
||||||
|
never against Okta.
|
||||||
|
4. A NEW session can't be established once Okta drops the account —
|
||||||
|
`okta_login`/`okta_callback` requires completing Okta's own sign-in, which
|
||||||
|
Okta itself refuses. The front door closes on its own; the back door (an
|
||||||
|
already-open session, and the stale local record) does not.
|
||||||
|
|
||||||
|
### Decisions, made 2026-09-17
|
||||||
|
|
||||||
|
1. **Build a scheduled sync against Okta's Management API**, not an Event Hook.
|
||||||
|
This app has, since `D15`, only ever reached out to Okta — never received
|
||||||
|
anything from it — and a polling design keeps that shape rather than
|
||||||
|
introducing a new inbound, internet-reachable endpoint with its own
|
||||||
|
signature-verification surface. Traded deliberately: this is
|
||||||
|
poll-interval-late rather than real-time, which is judged acceptable for an
|
||||||
|
HR/offboarding-driven event, not a to-the-second requirement.
|
||||||
|
2. **Revised 2026-09-23, in response to Matt's question about idle time
|
||||||
|
instead of a flat session length: sessions now slide on activity, with a
|
||||||
|
hard ceiling underneath.** A flat `AUTH_SESSION_HOURS` forces a re-check
|
||||||
|
with Okta on a fixed schedule regardless of activity; a pure idle timer
|
||||||
|
with no ceiling does the opposite — a continuously-active session would
|
||||||
|
never force a fresh Okta check on its own, which is a worse fit for the
|
||||||
|
exact threat this item exists to address (someone still clicking around
|
||||||
|
after being deprovisioned). Decided: **both**.
|
||||||
|
- `AUTH_IDLE_MINUTES` (new, default **30**): a session with no request
|
||||||
|
for this long stops being valid. Implemented as a sliding JWT expiry —
|
||||||
|
the token is reissued with a fresh `exp` on activity, throttled so the
|
||||||
|
cookie isn't rewritten on literally every request.
|
||||||
|
- `AUTH_SESSION_HOURS` (existing var, meaning changes to an **absolute
|
||||||
|
ceiling**): no session survives past this many hours from the original
|
||||||
|
sign-in, no matter how continuously active it is. Default changing from
|
||||||
|
12 to a proposed **8** — flagged as a recommendation, not confirmed.
|
||||||
|
- Both defaults, and the mechanism itself, should be sanity-checked
|
||||||
|
against the tenant's actual Okta SSO session policy — if Okta's own
|
||||||
|
session silently outlives either number, re-authentication here is
|
||||||
|
likely a fast redirect, not a real re-login screen, so these numbers
|
||||||
|
cost less than they look like they do. Confirm before treating either
|
||||||
|
as final.
|
||||||
|
3. **The sync only ever disables an account — it never re-enables one.** A
|
||||||
|
rehire showing active in Okta again does not automatically restore access;
|
||||||
|
an admin re-enabling the account is a deliberate act, consistent with
|
||||||
|
`D16`'s posture that this app never auto-grants access on its own initiative.
|
||||||
|
4. **Fail closed on the side of INACTION, not disablement.** This is the
|
||||||
|
opposite failure direction from `D16`'s login-time posture ("if Okta is
|
||||||
|
unreachable, the app is unreachable for everyone"). Here, an Okta API
|
||||||
|
error, timeout, empty response, or anything the sync can't confidently
|
||||||
|
parse must result in **no change to any account** that cycle, plus a
|
||||||
|
logged failure. A sync job that treats "couldn't reach Okta" as "nobody is
|
||||||
|
active" is a far worse outcome than a missed cycle — it would lock out the
|
||||||
|
entire org on an Okta API hiccup. This is the single most important
|
||||||
|
acceptance criterion in this item.
|
||||||
|
5. **Every auto-disable is audited individually**, same as every other
|
||||||
|
account-state change in this app: an `AuditLog` row per user, with an actor
|
||||||
|
value that's clearly the sync job and not a person (e.g.
|
||||||
|
`system:okta_sync`), so it reads correctly in the User Directory's history
|
||||||
|
and is never confused with an admin's own action.
|
||||||
|
6. **New credential required:** a read-scoped Okta API token (or an Okta
|
||||||
|
service-app OAuth2 client), separate from the `OKTA_CLIENT_ID`/
|
||||||
|
`OKTA_CLIENT_SECRET` pair used for sign-in. This needs provisioning by
|
||||||
|
whoever administers the Okta tenant — the same dependency that gated the
|
||||||
|
original OIDC rollout (D15's "security scoping reply").
|
||||||
|
|
||||||
|
### Recommended, not yet confirmed — raise in the PR if this is wrong
|
||||||
|
|
||||||
|
- **Sync interval:** proposed every 15 minutes. Frequent enough that the
|
||||||
|
detection gap is small, infrequent enough not to hammer Okta's API or need
|
||||||
|
special rate-limit handling. Not confirmed with IT/security.
|
||||||
|
- **Where the job runs:** proposed as an in-process background task inside the
|
||||||
|
existing `api` container (it already has `outbound` network access to reach
|
||||||
|
Okta, and already holds the Okta client config) rather than a new sidecar
|
||||||
|
container. The `backup` container (`docker-compose.yml`) is the existing
|
||||||
|
precedent for a scheduled-interval container in this stack, if isolation
|
||||||
|
from the `api` process is preferred instead — a reasonable alternative, not
|
||||||
|
the recommendation.
|
||||||
|
- **Admin visibility:** at minimum, an auto-disable is a normal, readable
|
||||||
|
`AuditLog` entry (visible whever admin already reviews audit history, and
|
||||||
|
naturally covered once `CR-019`'s activity view exists). Whether it should
|
||||||
|
also trigger an email/notification to admins is a genuine open question —
|
||||||
|
proposed as a fast-follow rather than blocking this item, since `D10`
|
||||||
|
already established the pattern for admin-controlled email toggles this
|
||||||
|
could reuse.
|
||||||
|
|
||||||
|
### Frontend/backend boundary
|
||||||
|
|
||||||
|
Entirely server-side and infrastructure. No new `localStorage` state, no
|
||||||
|
frontend surface beyond what already reads `is_active` and `AuditLog` today
|
||||||
|
(the User Directory, and eventually `CR-019`'s activity view).
|
||||||
|
|
||||||
|
### Scheduling
|
||||||
|
|
||||||
|
New wave, independent of wave 11 and wave 12 in the sense that nothing here is
|
||||||
|
blocked by them — but note it touches the same `is_active`/account-state
|
||||||
|
surface `CR-020`'s bulk actions touch in `server/app.py`. Not a hard
|
||||||
|
dependency; sequence commits to avoid an avoidable merge conflict, per
|
||||||
|
`CLAUDE.md`'s "one task per PR" spirit. Task breakdown: `docs/waves/wave-13.md`.
|
||||||
85
docs/waves/notes-2026-09-23.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Session notes — 2026-09-23
|
||||||
|
|
||||||
|
Working notes for the `feat/waves-11-13` line (CR-019, CR-020 reserved, D18),
|
||||||
|
written up before merge. Not a spec document — `wave-11.md`, `wave-13.md` and
|
||||||
|
`decisions-2026-09-17.md` are the source of truth for scope and acceptance
|
||||||
|
criteria. This is the "what actually happened building it" record.
|
||||||
|
|
||||||
|
## What shipped today
|
||||||
|
|
||||||
|
**CR-019 (wave 11) — usage/activity metrics, T11.1 through T11.6 complete:**
|
||||||
|
|
||||||
|
- `UsageEvent` table + migration (T11.1), a capture endpoint wired into
|
||||||
|
`auth-guard.js` so every protected page pings it once per load, plus a
|
||||||
|
`login` event at Okta sign-in (T11.2).
|
||||||
|
- `GET /api/usage/summary` (T11.3) — active users by day/week/month,
|
||||||
|
per-user last-active, per-tool breakdown, filterable by date/project/
|
||||||
|
user/tool, all combinable.
|
||||||
|
- `GET /api/usage/export` (T11.4) — raw and sanitized CSV. Sanitized mode
|
||||||
|
replaces the username with an HMAC-SHA256 pseudonym (keyed with
|
||||||
|
`auth.SECRET_KEY`), stable per user across rows and across separate
|
||||||
|
export calls, so an external tool (Power BI etc.) can still group by
|
||||||
|
user without ever seeing a real name.
|
||||||
|
- A new "Activity & usage" card in the admin console (T11.5): real filter
|
||||||
|
controls, the summary tables, both export buttons. Client-side gated
|
||||||
|
admin-only same as the rest of the console; the API underneath is
|
||||||
|
independently gated server-side regardless.
|
||||||
|
- Retired the old per-browser "Usage logs" panel and `wp-usage.js`
|
||||||
|
entirely (T11.6) — it had no reader left and was never a real data
|
||||||
|
source for the new report anyway. The scattered `track()` call sites in
|
||||||
|
the creator and wizard were left in place calling a documented no-op,
|
||||||
|
rather than deleting ~45 individual call sites for the same effect.
|
||||||
|
|
||||||
|
Only **T11.7 (final wave verification)** is left before wave 11 is fully
|
||||||
|
closed out — everything under it has already been verified per-task, so
|
||||||
|
this is a consolidation pass, not new work.
|
||||||
|
|
||||||
|
**D18 (wave 13) — Okta/AD deprovisioning, T13.1 only:**
|
||||||
|
|
||||||
|
- Session lifetime changed from one flat `AUTH_SESSION_HOURS` to a sliding
|
||||||
|
idle timeout (`AUTH_IDLE_MINUTES`, default 30) capped by a hard ceiling
|
||||||
|
from original sign-in (`AUTH_SESSION_HOURS`, default 8, meaning changed
|
||||||
|
from "session length" to "absolute ceiling"). Both defaults are flagged
|
||||||
|
in `.env.example` and `DEPLOYMENT.md` as proposed, not confirmed against
|
||||||
|
the tenant's actual Okta SSO policy.
|
||||||
|
- **T13.2 onward (the actual Okta Management API sync job) is paused** —
|
||||||
|
explicit call from Matt: no Okta API credential yet, come back to it
|
||||||
|
later. Not started, not blocked on anything code-side.
|
||||||
|
|
||||||
|
**CR-020 (wave 12, bulk user editing):** not started. Reserved, scoped in
|
||||||
|
`wave-12.md`, no code touched.
|
||||||
|
|
||||||
|
## Environment work (not itself a task, but load-bearing)
|
||||||
|
|
||||||
|
- Fixed a CRLF/LF mismatch that was making every tracked file look modified
|
||||||
|
to this session's git client (`core.autocrlf true`, repo-local, no file
|
||||||
|
content changed).
|
||||||
|
- This machine had no Python. Installed it via `winget` (`Python.Python.3.12`)
|
||||||
|
and set up a `.venv` in the repo with `server/requirements.txt` installed,
|
||||||
|
specifically so `tests/baseline_shots.py` could run locally — this
|
||||||
|
sandbox has no headless-capable browser and can't download one (network
|
||||||
|
allowlist), so the 390px/1440px screenshot verification CLAUDE.md asks
|
||||||
|
for had to run on Matt's own machine instead, using the browser already
|
||||||
|
installed there (Edge).
|
||||||
|
- Established a repeatable local verification loop for every task: throwaway
|
||||||
|
SQLite, fake-Okta sign-in, promote to admin, `seed_demo.py` +
|
||||||
|
`smoketest.py` (27/27 passing throughout), plus `baseline_shots.py` for
|
||||||
|
anything touching `html/`.
|
||||||
|
|
||||||
|
## Standing constraints, still in effect
|
||||||
|
|
||||||
|
- Everything stays local on this branch line. No `git push` at any point
|
||||||
|
today.
|
||||||
|
- One task per PR discipline was kept even though all of it landed on one
|
||||||
|
branch — each commit corresponds to exactly one task ID, in wave order,
|
||||||
|
each individually verified before the next started.
|
||||||
|
|
||||||
|
## Before merging
|
||||||
|
|
||||||
|
- Run T11.7 (full wave-11 verification pass) and record it in `wave-11.md`.
|
||||||
|
- Decide where this branch actually merges to — `feat/waves-11-13` has all
|
||||||
|
of today's commits already; this notes branch was cut from it so it can
|
||||||
|
fast-forward back in, or merge as its own PR if the notes should be
|
||||||
|
reviewed separately from the code.
|
||||||
|
- T13.2+ and all of wave 12 remain explicitly out of scope until Matt says
|
||||||
|
otherwise.
|
||||||
@@ -1,390 +1,233 @@
|
|||||||
# Wave 10 — Domain authentication over LDAPS
|
# Wave 10 — Okta OIDC authentication
|
||||||
|
|
||||||
**Items:** `D13`, `D14`
|
Fresh wave 10. The label was previously used by the LDAPS work under `D13`/`D14`, built on
|
||||||
**Depends on:** wave 9 merged (it is — `a8e28bf`)
|
`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
|
||||||
One item, eight tasks. The item is stated in `docs/waves/decisions-2026-08-21.md`; read it
|
`docs/waves/decisions-2026-09-02.md` (`D15`) for why LDAPS was retired before deployment
|
||||||
before starting, particularly the **Non-negotiables** section, which is where this change
|
and Okta chosen instead.
|
||||||
goes wrong if it goes wrong.
|
|
||||||
|
Depends only on `main` as it stands after `D15`. Not sequenced behind any other wave.
|
||||||
**This wave is field-visible in one direction only.** Nobody gains a screen. People sign in
|
|
||||||
with their Windows password instead of an app password, the "Forgot password?" link
|
## Tasks
|
||||||
disappears, and admins stop issuing passwords. Everything else is invisible — which means
|
|
||||||
the done-when checks are the only evidence the wave worked.
|
- **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
|
||||||
**Build order is task order** for once. `T10.1` is a standalone module with no callers and
|
pattern `AUTH_SECRET_KEY` already uses in `server/auth.py`.
|
||||||
should merge first; nothing else can be tested until it exists.
|
|
||||||
|
- **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
|
||||||
### T10.1 — The LDAPS client
|
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,
|
||||||
- **Items:** `D13` (1)
|
which had to gate on a required AD group itself because an LDAPS bind alone could not
|
||||||
- **Depends on:** nothing
|
distinguish an assigned user from any other domain account.
|
||||||
- **Blocks:** T10.2, T10.4, T10.5, T10.7
|
|
||||||
- **Surface:** `server/`
|
- **T10.3 — Identity matching and JIT provisioning.** Reuses D13's shape
|
||||||
- **Files:** new `server/ldap_auth.py`, `server/requirements.txt`, `docker-compose.yml`,
|
(`_provision_from_directory`-style matching) keyed off an OIDC claim instead of an LDAP
|
||||||
`Dockerfile`, `server/.env.example`
|
search result. **Open dependency:** which claim carries the AD `sAMAccountName`
|
||||||
|
equivalent (`preferred_username`, `upn`, or a custom claim) is asked of security and not
|
||||||
**Problem:** There is no directory client in the repo. `ldap3` is not a dependency, the API
|
yet answered. Build with a configurable claim name and a documented default, not a
|
||||||
container has no CA bundle, and the `api` service sits on the `internal` network, which has
|
hardcoded one, so the answer can drop in without a code change.
|
||||||
no default gateway and therefore no route to `192.168.3.x` at all.
|
|
||||||
|
- **T10.4 — Remove the local password path entirely.** Drop `password_hash` (Alembic
|
||||||
**Do:** Add `ldap3` (pinned, per the convention at the top of `requirements.txt`). Write
|
migration; plain `op.drop_column`, matching existing precedent for other NOT NULL
|
||||||
`server/ldap_auth.py` exposing two functions and nothing else:
|
columns on `users` — no `batch_alter_table` needed), remove the bcrypt-based
|
||||||
|
`login()`, remove the username/password form. Real deletion, matching `D15`'s "full
|
||||||
- `verify(username, password) -> LdapResult | None` — simple bind as
|
replacement," not a toggle or a fallback. Scope corrected by `D16` after hazard
|
||||||
`f"{username}@{domain}"` against `ldaps://{host}:636`.
|
review turned up more call sites than the original bullet named:
|
||||||
- `member_of(conn, group) -> bool` — nested-group aware, via
|
- `create_user()` and `admin_reset_password()` in `server/app.py` (admin console's
|
||||||
`LDAP_MATCHING_RULE_IN_CHAIN` (`1.2.840.113556.1.4.1941`). `memberOf` alone is direct
|
"add user" and "reset password" routes) — rework to drop the password field
|
||||||
membership only and will wrongly refuse anyone in a nested group.
|
entirely rather than break.
|
||||||
|
- `html/users.js`'s "add user" form (`nu-password`) and "Reset password" button —
|
||||||
Configuration by environment: `LDAP_HOST` (default `prime.local`), `LDAP_DOMAIN` (default
|
matching frontend change.
|
||||||
`prime.local`), `LDAP_CA_FILE`, `LDAP_REQUIRED_GROUP`, `LDAP_TIMEOUT_SECONDS`. Attach the
|
- `server/manage_users.py` — reworked per `D16` from account *creation* to
|
||||||
`outbound` network to `api` in `docker-compose.yml` — the same reason `assets_db.py` needed
|
*promotion*: `create-admin`/`create`/`reset-password` (password-based) are
|
||||||
it, and the comment there already explains the gateway-less `internal` network. Mount or
|
replaced by a promote-by-username command that operates on a row Okta's JIT
|
||||||
`COPY` the PEM bundle and point `LDAP_CA_FILE` at it.
|
provisioning (T10.3) already created, never a hand-typed new one. This is now
|
||||||
|
the documented admin-bootstrap path — see `D16`.
|
||||||
Connect to **`prime.local`**, never a DC hostname and never an IP. See the decision doc for
|
- `tests/browser_check.py` and `tests/launcher_check.py` — stop calling
|
||||||
why: SAN coverage plus round-robin across six DCs in one move. Wrap the bind in a retry
|
`auth.hash_password()` to seed fixture rows.
|
||||||
across resolved addresses, because round-robin will hand out a rebooting DC's address.
|
- `server/smoketest.py` and `server/seed_demo.py` — currently authenticate via
|
||||||
|
`POST /api/auth/login`. Switch to minting a session with `auth.create_token()`
|
||||||
**Done when:**
|
directly, the same technique `browser_check.py` already uses, so both scripts
|
||||||
|
(named explicitly in `CLAUDE.md`'s verification section) keep working without
|
||||||
- [ ] `ldap3` is pinned to an exact version in `requirements.txt`
|
depending on `T10.7`'s timing.
|
||||||
- [ ] an empty or whitespace-only password returns failure **without calling `bind()`**
|
|
||||||
- [ ] an empty username returns failure without calling `bind()`
|
- **T10.5 — Frontend: login becomes a redirect, not a form.** `login.html`/`login.js`
|
||||||
- [ ] `Tls` is constructed with `validate=ssl.CERT_REQUIRED` and an explicit `ca_certs_file`
|
change to a "Sign in with Okta" flow. Sign-out lands back on the app's own login page.
|
||||||
- [ ] no code path sets `CERT_NONE`, and none falls back to the system trust store
|
|
||||||
- [ ] `member_of` returns true for an account in a **nested** child of the required group
|
- **T10.6 — Deployment docs and env var reference.** `DEPLOYMENT.md`,
|
||||||
- [ ] a bind against `192.168.3.37` (raw IP) fails hostname validation rather than silently passing
|
`server/.env.example`, `server/README.md` describe the Okta config in place of the LDAP
|
||||||
- [ ] `docker compose exec api openssl s_client -connect prime.local:636 -CAfile $LDAP_CA_FILE` reports `Verify return code: 0 (ok)`
|
config they never ended up describing (D13 never shipped, so these still describe the
|
||||||
- [ ] the module imports cleanly with no LDAP env set (unconfigured is a first-class state, as with `MICRON_DB_URL`)
|
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
|
||||||
### T10.2 — The login path binds instead of hashing
|
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
|
||||||
- **Items:** `D13` (1)
|
(T10.4's `smoketest.py` rewrite). Also `docker-compose.yml`, not originally named in
|
||||||
- **Depends on:** T10.1
|
this bullet: its `api` service sets `environment:` as an explicit allowlist, not
|
||||||
- **Blocks:** T10.3, T10.4
|
`env_file`, so the documented vars would silently never reach the container without
|
||||||
- **Surface:** `server/`
|
adding them there too — found and fixed in the same commit rather than shipping docs
|
||||||
- **Files:** `server/app.py` (`login`), `server/auth.py`
|
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
|
||||||
**Problem:** `login()` at `server/app.py:681` calls `auth.verify_password` against
|
defaulting to an empty string, which would 503 every sign-in.
|
||||||
`user.password_hash`. The lockout counter it maintains is about to start counting *domain*
|
|
||||||
bind failures, which changes what that counter is for.
|
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
|
||||||
**Do:** Replace the credential check with `ldap_auth.verify`. Keep the surrounding shape —
|
`login()` is gone); `server/README.md`'s own "Production — Docker Compose" section
|
||||||
the deliberate timing equalisation, the generic `401`, the `403` for a disabled local
|
is a self-contained alternate quickstart that already diverged from the real root
|
||||||
account, `last_login_at`, the session cookie. Rework the throttle so the local counter trips
|
`docker-compose.yml` before this task and still does.
|
||||||
**before** the domain policy is reached and short-circuits without calling the DC: the
|
|
||||||
failure mode to avoid is this endpoint being usable to lock domain accounts out of Windows.
|
- **T10.7 — Test coverage without a live Okta dependency.** A fake-OIDC-provider test
|
||||||
Log directory error-49 sub-codes for diagnosis; return the same generic message regardless.
|
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
|
||||||
**Break-glass: none — decided Aug 21, see the decision doc.** LDAPS is the only way in, so
|
the same way `ldap_fake.py` does), dispatched from `okta_auth._build_oauth()` before
|
||||||
this task adds no fallback path. What it must add instead is *visibility*: a startup log line
|
the real Authlib client is considered. Only the two Authlib calls that touch the
|
||||||
stating whether LDAP is configured and whether the DC answered, because with no fallback a
|
network — `authorize_redirect` / `authorize_access_token` — are faked; `app.py`'s
|
||||||
misconfigured deploy is indistinguishable from a forgotten password at the login box.
|
`okta_login()`/`okta_callback()` (the `?next=` guard, the disabled-account check, JIT
|
||||||
|
provisioning, the identity-claim lookup) run unmodified against the fake, same
|
||||||
**Done when:**
|
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
|
||||||
- [ ] a correct domain password signs in and sets the session cookie
|
screen and are registered in `app.py` only when the fake is active at import time —
|
||||||
- [ ] a wrong password is refused with the generic message
|
in production they do not exist, not merely refuse. `tests/browser_check.py`'s
|
||||||
- [ ] a blank password is refused (guards `T10.1` from the caller's side too)
|
`start_server()` now takes an optional `extra_env` and sets
|
||||||
- [ ] a user not in the required group is refused even though the bind succeeded
|
`WP_OKTA_FAKE_DIRECTORY` unconditionally (same reasoning `ldap_fake`'s equivalent
|
||||||
- [ ] `is_active = false` locally still refuses, independent of the directory
|
used: almost nothing signs in, but the one check that does should not fail
|
||||||
- [ ] the local throttle trips below the domain lockout threshold and stops calling the DC
|
mysteriously). `tests/url_state_check.py` scenario 2 is un-skipped and drives the
|
||||||
- [ ] no response body distinguishes "no such user" from "wrong password"
|
real round trip — login.html's button, the fake picker page, the fake consent
|
||||||
- [ ] error-49 sub-codes appear in the log and nowhere in any response
|
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,
|
||||||
### T10.3 — Drop `password_hash`
|
an unknown identity, a disabled account, JIT provisioning, an existing admin
|
||||||
|
surviving unchanged, same-site vs. off-site `?next=`, and `OKTA_IDENTITY_CLAIM`
|
||||||
- **Items:** `D13` (1)
|
genuinely working under a non-default claim name — 22/22.
|
||||||
- **Depends on:** T10.2
|
|
||||||
- **Blocks:** T10.6, T10.8
|
The dev sandbox this was built in has no headless browser and no way to install
|
||||||
- **Surface:** `server/`
|
one, so `tests/url_state_check.py` and `tests/browser_check.py` (both need
|
||||||
- **Files:** `server/models.py`, new migration, `server/app.py`, `server/auth.py`,
|
`tests/cdp.py`'s real headless Chromium) could not be run there — only
|
||||||
`server/manage_users.py`
|
`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
|
||||||
**Problem:** With binds doing the work, every password code path is dead weight and a
|
(`headless-py-test-runner`, kept out of this repo — it is not Work Package Suite
|
||||||
liability. It is also the criterion that makes this item irreversible, so it lands on its own
|
specific): `url_state_check.py` 26/26, including scenario 2's real click-through of
|
||||||
commit.
|
the fake-Okta round trip, and `browser_check.py` 71/71. Gap closed.
|
||||||
|
|
||||||
**Do:** Remove `password_hash` from `models.User` and drop the column in a new Alembic
|
**Validated 2026-09-03:** `tests/okta_auth_check.py` 22/22 (no browser needed) ·
|
||||||
revision with `down_revision = 'a1b8c6d4e2f9'` (the current head — confirm with
|
`tests/url_state_check.py` 26/26 · `tests/browser_check.py` 71/71 — all three run
|
||||||
`alembic heads` rather than trusting this line). Remove from `auth.py`: `hash_password`,
|
clean, the last two against real headless Chromium via Docker.
|
||||||
`verify_password`, `password_problem`, `MIN_PASSWORD_LEN`, `_COMMON_PASSWORDS`,
|
|
||||||
`create_reset_token`, `decode_reset_token`, `RESET_MINUTES`. Remove from `app.py`:
|
- **T10.8 — Verification.** 390px and 1440px, full suite, done-when checks per task,
|
||||||
`/api/auth/forgot-password`, `/api/auth/reset-password`, `/api/auth/reset-available`,
|
matching the rigor D13 was held to.
|
||||||
`/api/auth/password`, `/api/auth/users/{user_id}/password`, and the `_reset_last` throttle
|
|
||||||
with `reset_body`. Remove `reset-password` from `manage_users.py` and the password prompt
|
Done-when checks per task, verified against the actual code rather than
|
||||||
from `create` / `create-admin`.
|
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
|
||||||
`token_version` **stays.** It is still the session-revocation mechanism — role changes and
|
app-side group or claim gate layered on Okta's own), T10.3 (identity-claim
|
||||||
deactivation should bump it even though password changes no longer exist.
|
matching, JIT at the lowest role, local deprovisioning still enforced after
|
||||||
|
Okta approves), T10.4 (zero remaining references to `bcrypt` /
|
||||||
**Do not** remove the account-management endpoints themselves. `/api/auth/users/{id}/role`
|
`password_hash` / `hash_password` / `verify_password` anywhere in `.py` or
|
||||||
is criterion 4 and must keep working.
|
`.js`, `manage_users.py promote`, `smoketest.py` / `seed_demo.py` /
|
||||||
|
`browser_check.py` / `launcher_check.py` all minting via `create_token()`),
|
||||||
**Done when:**
|
T10.5 (`login.html` is one Okta link, no password field). All matched what
|
||||||
|
this file already claimed — no drift found.
|
||||||
- [ ] `grep -rn "password_hash\|hash_password\|verify_password\|password_problem" server/` returns nothing outside the migration
|
|
||||||
- [x] `alembic upgrade head` then `downgrade -1` round-trips on SQLite and on Postgres (16.15, the compose image — Aug 24; needed a pre-existing T8.6 migration bug fixed first, see `495d87d`)
|
Full suite, run through the Docker test runner (all 41 files in `tests/`,
|
||||||
- [ ] the migration's `downgrade()` recreates the column nullable, not `NOT NULL` — there are no hashes to put back
|
bare invocation): 39 passed clean. `tests/token_check.py` "failed" at exit 2,
|
||||||
- [ ] `token_version` still invalidates sessions, exercised by a role change
|
but that is a harness mismatch, not a check failure — it is a two-step
|
||||||
- [ ] `manage_users.py list`, `disable`, `enable` still work; `reset-password` is gone
|
snapshot/diff tool (`--out` to capture, `--compare A B` to diff) and prints
|
||||||
- [ ] `python -m server.manage_users create-admin <u>` creates an admin with no password prompt
|
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
|
||||||
### T10.4 — Just-in-time provisioning, without trampling existing accounts
|
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
|
||||||
- **Items:** `D13` (2, 4)
|
fixed here — an unrelated CSS token-rule violation is not this wave's to fix.
|
||||||
- **Depends on:** T10.2
|
`tests/okta_auth_check.py` re-run fresh (no browser needed): 22/22.
|
||||||
- **Blocks:** T10.7
|
|
||||||
- **Surface:** `server/`
|
390px and 1440px: `tests/baseline_shots.py` captured all fourteen shots
|
||||||
- **Files:** `server/app.py` (`login`), `server/auth.py`
|
(login, launcher, sop, creator, admin, users, field × two widths) into
|
||||||
|
`docs/reference/baseline/`. `login-390.png`/`login-1440.png` and
|
||||||
**Problem:** Criteria 2 and 4 pull in opposite directions. Provisioning on first login must
|
`users-390.png`/`users-1440.png` visually confirmed: the login page is a
|
||||||
create accounts that do not exist, and must not touch the role of accounts that do — an
|
single "Sign in with Okta" button with no username/password form at either
|
||||||
existing `admin` signing in for the first time after this wave must still be an admin
|
width, and the User Directory's table and "Add a user" form both carry no
|
||||||
afterwards.
|
password column and no reset-password action anywhere, at either width.
|
||||||
|
|
||||||
**Do:** On a successful bind that passes the group check, look the account up with
|
Wave 10 is complete. The three items in "Still open" below are external
|
||||||
`auth.find_user` (already case-insensitive across username **and** email). If it exists,
|
(security team / Okta admin), not blocked on any task in this wave.
|
||||||
update only `last_login_at` and — if empty locally — `full_name` and `email` from the
|
|
||||||
directory. **Never write `role`.** If it does not exist, create it at
|
- **T10.10 — Audit `manage_users.py promote`.** Raised in review after T10.8:
|
||||||
`ROLE_PROJECT_USER` with `full_name`/`email` from the directory.
|
`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
|
||||||
**A JIT account gets NO project access, and that is deliberate.** An earlier draft of this
|
`set_user_role()` → `log_event()`, action `"role_changed"`). Not a new
|
||||||
task said to honour the `auto_add_projects` machinery so a new account "lands in the right
|
privilege — anyone with Portainer/container-exec access to `wp_api` already
|
||||||
projects" — that was wrong about how the flag works. `auto_add_projects` is evaluated when a
|
has shell access to the database directly, same trust tier D16 already named
|
||||||
**project** is created (`app.py:245`), marking accounts that should join every *new* job; it
|
for this command — but there was no record of who ran it or what changed.
|
||||||
cannot retroactively add a new account to existing ones. There is no correct default, so
|
|
||||||
least privilege applies: the account exists, can sign in, and sees nothing until someone
|
Built: `cmd_promote()` now writes an `AuditLog` row with the same
|
||||||
grants access. That is a real UX cliff — a successful sign-in into an empty app — so it has
|
`action`/`detail` shape `set_user_role()` uses (`{"from": old_role, "to":
|
||||||
to be visible to admins rather than silent, which is what the `AuditLog` row is for.
|
role}`), tagged `"via": "cli"` (mirrors JIT provisioning's own `"via":
|
||||||
|
"okta_jit"` tag) and `actor="cli:manage_users"` — a container shell exec
|
||||||
Note the flush-order warning in the `models.py` docstring: `create_user` in `app.py` handles
|
carries no signed-in identity to attribute the change to a real person, so
|
||||||
account-then-membership correctly in one flush — follow it if you add rows.
|
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,
|
||||||
Write an `AuditLog` row for each JIT creation. An account appearing without an administrator
|
the role change persists, and the existing "no such user" refusal still
|
||||||
creating it is exactly the kind of event that record exists for.
|
exits 1 with no partial write.
|
||||||
|
|
||||||
**Done when:**
|
- **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
|
||||||
- [ ] an unknown username with a valid bind and group membership gets a `users` row at `project_user`
|
`downgrade()` cannot restore the data it drops — see `D17`. `T10.4`'s
|
||||||
- [ ] `full_name` and `email` are populated from the directory on creation
|
`1d60a608bb51_drop_local_password` is exactly that: the schema comes back, the
|
||||||
- [ ] an existing `admin` signing in is still `admin` afterwards — asserted, not assumed
|
bcrypt hashes do not.
|
||||||
- [ ] an existing account with a locally-set `full_name` does not have it overwritten
|
|
||||||
- [ ] a JIT account has NO `ProjectMember` rows and sees no projects
|
Built: `DEPLOY-runbook-2026-09-03.md`, following the 2026-08-04 runbook's structure
|
||||||
- [ ] the new account appears in the Admin console user list so access can be granted
|
(fill-in table, numbered deploy steps, case-by-case Rollback section, Notes). Names
|
||||||
- [ ] each JIT creation writes an `AuditLog` row
|
the five `OKTA_*` vars as newly required (the precedent's "no new environment
|
||||||
- [ ] a failed bind creates **no** row
|
variables" note does not carry over), treats the pre-deploy backup as the only way
|
||||||
- [ ] a bind that succeeds but fails the group check creates **no** row
|
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
|
||||||
### T10.5 — CLOSED, NOT BUILT (Aug 24 2026): the required group stays an env var
|
procedure). D16's no-break-glass posture is stated plainly rather than left implicit.
|
||||||
|
|
||||||
- **Items:** `D13` (3)
|
Logged, not fixed here (out of scope): `okta_auth.describe()`'s startup log line
|
||||||
- **Status:** **won't build.** `LDAP_REQUIRED_GROUP` in the environment is the answer.
|
(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
|
||||||
**Do not build this later by reading the original task and assuming it was skipped.**
|
therefore verifies via a live Okta sign-in rather than a log line, and this gap is
|
||||||
It was proposed, examined and rejected on purpose, and the reasoning is below.
|
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`
|
||||||
**What it was going to be:** the required group moved out of the environment into an
|
is now fully stale (bcrypt, `create-admin --password`, none of which still exist) —
|
||||||
Admin console setting, with a validate-on-save guard that resolved the group in the
|
not touched, no task claims it.
|
||||||
directory and confirmed the saving admin was a member.
|
|
||||||
|
## Still open
|
||||||
**Why it is not being built:**
|
|
||||||
|
- The `Business Technology Group` pilot assignment in Okta. Originally six names
|
||||||
1. **The console requirement was invented here, not asked for.** D13 criterion 3 says
|
(Carlee Swihart, Drew Hilliard, Matt Mabrey, Nick Siegfried, Rachel Schreiber, Terry
|
||||||
*"An AD group is configured"* — not "configurable from the console". The env var
|
Sajan); Cody and Cameron added 2026-09-09. Adrian added only Matt at first,
|
||||||
satisfies the criterion as written.
|
deliberately, pending the live sign-in confirmation below — awaiting his response to
|
||||||
2. **The validate-on-save guard existed only to defend against a risk the console
|
add the rest of the group now that it has.
|
||||||
itself introduced.** A feature whose complexity exists to defend against itself is
|
|
||||||
usually the wrong feature.
|
Closed since first written: admin bootstrap and break-glass posture, previously open
|
||||||
3. **The lockout scenario it defended against is already handled.** A group that does
|
questions, decided in `D16` (2026-09-03) and folded into `T10.4` above.
|
||||||
not resolve raises `LookupError` in `member_of`, which `verify()` maps to
|
|
||||||
`GROUP_NOT_FOUND`, which `is_config_problem` classifies as ours — so `login()`
|
**Closed 2026-09-09, live in production:** the redirect/callback URI
|
||||||
answers **503**, not 401, and the log says *"required group 'X' does not resolve in
|
(`https://wp.controls.dev/api/auth/okta/callback`) is confirmed working, and so is the
|
||||||
DC=prime,DC=local — refusing the sign-in. This is a configuration fault, not a bad
|
OIDC claim mapping (`T10.3`) — `preferred_username` (the code's documented default,
|
||||||
password."* A genuine non-member still gets 401. The two are already distinguishable
|
never actually confirmed by name in Request 50649's thread) is correct, no
|
||||||
in both the log and the response.
|
`OKTA_IDENTITY_CLAIM` override needed. Both settled by an actual live sign-in against
|
||||||
4. **A redeploy is deliberate and reviewable; a text box is not.** The group is set
|
the real Okta tenant after `main` was merged (`cc64c88`) and deployed: Matt signed in
|
||||||
once and effectively never changes — it is not SMTP configuration.
|
as himself, matched his existing pre-Okta admin account by `find_user()` rather than
|
||||||
5. **The console version creates a circular failure.** Fixing a lockout would require
|
JIT-provisioning a duplicate (the account already existed — this app has ~40 real
|
||||||
the console the lockout prevents you from reaching. Editing the env var does not.
|
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
|
||||||
**What is genuinely lost, and accepted:**
|
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
|
||||||
- **Discoverability.** An app admin cannot see which group is required without
|
Portainer (env var names added, values never filled in) — caught via
|
||||||
Portainer or shell access. A read-only line in the Admin console diagnostics would
|
`is_configured()`'s all-four-required check failing closed (the 503 "Sign-in is
|
||||||
give the useful half without the dangerous half; it was offered and declined on
|
temporarily unavailable"), not silently. A second snag after filling those in:
|
||||||
Aug 24 as not needed.
|
`OKTA_ISSUER` was pasted without its `https://` scheme, which surfaced as
|
||||||
- **Deploy-time validation.** Nothing confirms the group resolves until the first
|
`httpx.UnsupportedProtocol` from Authlib's OIDC discovery fetch rather than anything
|
||||||
sign-in attempt. This is not fixable: resolving a group needs an authenticated
|
the app's own code raises deliberately — the exact case the runbook's Notes flagged as
|
||||||
search, anonymous bind is disabled on this estate, and there is no service account
|
having no startup-time confirmation (`okta_auth.describe()` still has no caller,
|
||||||
by design. The first-attempt 503 is the earliest possible detection.
|
`BL-028`). Both fixed by correcting the env var values in Portainer and redeploying;
|
||||||
|
neither needed the backup or the database.
|
||||||
### T10.6 — Strip the password UI
|
|
||||||
|
|
||||||
- **Items:** `D13` (1)
|
|
||||||
- **Depends on:** T10.3
|
|
||||||
- **Blocks:** nothing
|
|
||||||
- **Surface:** `html/`
|
|
||||||
- **Files:** `html/login.html`, `html/login.js`, `html/admin.js`, `html/users.js`,
|
|
||||||
`html/auth-guard.js`
|
|
||||||
|
|
||||||
**Problem:** `login.html` has three views — sign-in, forgot-password, set-new-password — and
|
|
||||||
two of them now point at endpoints that no longer exist. `auth-guard.js` has a
|
|
||||||
change-password dialog, and the user-admin UI has a "reset password" action per row.
|
|
||||||
|
|
||||||
**Decided Aug 21: "Forgot password?" is KEPT and repointed at
|
|
||||||
`https://primecontrols.okta.com/`.** An earlier draft of this task removed the link, and a
|
|
||||||
plain sentence saying "contact IT" was proposed instead. Okta is the better answer — it is a
|
|
||||||
real self-service path, and with no app password and no break-glass it is the only recovery
|
|
||||||
route that exists. Note this is the first sign of an Okta tenancy on this estate; see the
|
|
||||||
new backlog entry.
|
|
||||||
|
|
||||||
Mechanics that matter: it is a plain `<a href>`, not a form post, so the `form-action 'self'`
|
|
||||||
in the CSP does not apply and no `navigate-to` directive is set — off-origin link navigation
|
|
||||||
is allowed as-is. `target="_blank"` needs `rel="noopener noreferrer"`, and there must be NO
|
|
||||||
click handler on `#forgot-link`: the old one called `preventDefault()` to swap views, and
|
|
||||||
leaving it would silently swallow the navigation.
|
|
||||||
|
|
||||||
**Do:** Remove the `#view-forgot` and `#view-reset` sections and the reset-token handling in
|
|
||||||
`login.js`, and repoint `#forgot-link` as above. Remove the change-password dialog from `auth-guard.js`
|
|
||||||
(`backlog.md:180` refers to it) and the per-row password reset from the users UI. Keep the
|
|
||||||
sign-in form, and relabel the password field's hint to say it is the Windows/domain password
|
|
||||||
— people need to know which password to type.
|
|
||||||
|
|
||||||
Keep the role-granting controls exactly as they are. That is criterion 4.
|
|
||||||
|
|
||||||
**Done when:**
|
|
||||||
|
|
||||||
- [ ] `grep -rn "forgot\|reset-password\|new-password" html/` returns nothing but prose
|
|
||||||
- [ ] "Forgot password?" opens `https://primecontrols.okta.com/` in a new tab
|
|
||||||
- [ ] `#forgot-link` has NO click handler (a `preventDefault()` would swallow the navigation)
|
|
||||||
- [ ] a 503 from the login endpoint says sign-in is unavailable, not that the password is wrong
|
|
||||||
- [ ] the sign-in form still submits, and a failure still announces through `role="alert"` (`login.html` already does this correctly — do not regress it)
|
|
||||||
- [ ] the password field says which password to enter
|
|
||||||
- [ ] no dead `<a href="#">` or handler remains for a removed view
|
|
||||||
- [ ] granting admin to an existing user still works from the console
|
|
||||||
- [ ] exercised at 390px and at 1440px, screenshots in the PR
|
|
||||||
- [ ] no raw hex added to any stylesheet (the token rule)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### T10.7 — Make the suite testable without a domain controller
|
|
||||||
|
|
||||||
- **Items:** `D13` (1, 2, 3)
|
|
||||||
- **Depends on:** T10.1, T10.4
|
|
||||||
- **Blocks:** T10.8
|
|
||||||
- **Surface:** `tests/` + `server/`
|
|
||||||
- **Files:** `server/ldap_auth.py`, `tests/browser_check.py`, `tests/launcher_check.py`,
|
|
||||||
`tests/console_dialogs_check.py`, `tests/url_state_check.py`, `server/smoketest.py`,
|
|
||||||
`server/seed_demo.py`, new `tests/ldap_auth_check.py`
|
|
||||||
|
|
||||||
**Problem:** This is the task most likely to be underestimated. Four existing checks build
|
|
||||||
users with `password_hash=auth.hash_password(PW)` and sign in over HTTP;
|
|
||||||
`console_dialogs_check.py` drives the password-reset prompt specifically. `smoketest.py` and
|
|
||||||
`seed_demo.py` both sign in. None of them can reach a DC, and CI has no domain.
|
|
||||||
|
|
||||||
**Do:** Make the bind injectable — a module-level seam in `ldap_auth.py` that a test can
|
|
||||||
substitute (a fake directory: usernames, passwords, groups, full names), selected by an env
|
|
||||||
var that is refused when a real `DATABASE_URL` is configured, mirroring how
|
|
||||||
`auth._load_secret` refuses an ephemeral key in production. Port the four checks onto it.
|
|
||||||
Delete the password-reset half of `console_dialogs_check.py` — the flow it covers no longer
|
|
||||||
exists — and say so in the PR rather than leaving a skipped test.
|
|
||||||
|
|
||||||
Add `tests/ldap_auth_check.py` covering the non-negotiables from the decision doc: empty
|
|
||||||
password, empty username, `CERT_REQUIRED` asserted by inspecting the constructed `Tls`,
|
|
||||||
nested group membership, group-check refusal creating no user, and existing-admin role
|
|
||||||
preservation.
|
|
||||||
|
|
||||||
**Done when:**
|
|
||||||
|
|
||||||
- [x] the stub backend cannot be selected when a non-SQLite `DATABASE_URL` is set — asserted by a test
|
|
||||||
- [x] `tests/ldap_auth_check.py` covers the cases above — 20/20
|
|
||||||
- [x] the empty-password case is proved by nulling `Connection`, so any call to `bind()` would raise — it asserts the guard returns *before* the transport, not merely that the result is a failure
|
|
||||||
- [x] `smoketest.py` and `seed_demo.py` document which credentials they now need (T10.8)
|
|
||||||
- [x] the removed password-reset checks are called out, not silently dropped — `console_dialogs_check.py`'s docstring records the coverage loss and where the prompt kit is still covered
|
|
||||||
- [x] the full `tests/` suite passes with no DC reachable — **1284/1288 checks across 40 files**, Aug 24. Two files fail, both proven pre-existing and unrelated (`BL-028` `assets_check`, `BL-029` `generalinfo_check`): `git diff main...HEAD` shows this branch touches neither file. `token_check.py` is not a member of the suite — it is a capture/diff tool that requires `--out` or `--compare`, and a sweep script that runs it bare gets a usage message and exit 2.
|
|
||||||
|
|
||||||
**Scope note.** This task was estimated as far larger than it turned out to be. The
|
|
||||||
premise was that four checks sign in and would all need the seam; in fact `seed()` mints
|
|
||||||
a token with `auth.create_token()` and sets the cookie directly, so **no** browser check
|
|
||||||
signs in except `url_state_check`'s deep-link case. `browser_check` and `launcher_check`
|
|
||||||
needed one kwarg deleted each — and since 39 files import `seed`/`start_server` from
|
|
||||||
`browser_check`, that single line unblocked nearly the whole suite.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### T10.8 — Documentation and the deploy runbook
|
|
||||||
|
|
||||||
- **Items:** `D13`
|
|
||||||
- **Depends on:** T10.3, T10.5, T10.7
|
|
||||||
- **Blocks:** nothing
|
|
||||||
- **Surface:** docs
|
|
||||||
- **Files:** `DEPLOYMENT.md`, `server/README.md`, `DEPLOY-login-portal.md`, `CLAUDE.md`,
|
|
||||||
`IMPLEMENTATION.md`
|
|
||||||
|
|
||||||
**Problem:** `DEPLOY-login-portal.md` documents creating the first admin with a password and
|
|
||||||
is the page an admin will reach for. `DEPLOYMENT.md` describes `AUTH_SECRET_KEY` and SMTP but
|
|
||||||
knows nothing about a directory. `CLAUDE.md`'s verification section tells anyone touching the
|
|
||||||
frontend to run the smoke test, which changes here.
|
|
||||||
|
|
||||||
**Do:** Document the LDAP variables, how to produce the CA bundle from the two thumbprints
|
|
||||||
in the decision doc, and the `prime.local`-not-an-IP rule with the reason. Add the
|
|
||||||
`openssl s_client -CAfile` check as the first-line diagnostic. Rewrite the
|
|
||||||
`DEPLOY-login-portal.md` bootstrap step: the first admin is now an existing directory account
|
|
||||||
promoted with `manage_users.py`, not an account created with a password. State plainly what
|
|
||||||
happens when the DC is unreachable, whatever `T10.2` decides.
|
|
||||||
|
|
||||||
**Done when:**
|
|
||||||
|
|
||||||
- [x] every new env var is documented in `server/.env.example` and `DEPLOYMENT.md`
|
|
||||||
- [x] the CA bundle procedure is reproducible by an admin who has not read this thread — thumbprints and a `Get-ChildItem` one-liner in `DEPLOYMENT.md`
|
|
||||||
- [x] `DEPLOY-login-portal.md` no longer instructs anyone to set a password — rewritten, with a note saying what it replaced so an admin holding the old copy is not misled
|
|
||||||
- [x] the DC-unreachable behaviour is stated explicitly, with the diagnostic commands
|
|
||||||
- [x] `IMPLEMENTATION.md` section 4 lists wave 10
|
|
||||||
- [x] no doc still claims passwords are stored as bcrypt hashes (swept; remaining matches all say the opposite)
|
|
||||||
- [x] `CLAUDE.md` carries the four load-bearing auth rules, next to the token rule
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### T10.9 — D14: the CLI authenticates, and stops creating accounts
|
|
||||||
|
|
||||||
- **Items:** `D14`
|
|
||||||
- **Depends on:** T10.3
|
|
||||||
- **Blocks:** T10.8
|
|
||||||
- **Surface:** `server/`
|
|
||||||
- **Files:** `server/manage_users.py`
|
|
||||||
|
|
||||||
**Problem:** `manage_users.py` writes to the `users` table with no authentication at all.
|
|
||||||
It also still offers `create-admin` / `create`, which are redundant now that accounts
|
|
||||||
provision themselves — and worse than redundant, because a hand-typed username can end up
|
|
||||||
matching no directory identity.
|
|
||||||
|
|
||||||
**Do:** As stated in `D14`. Remove the two create commands, add `promote` / `demote`, gate
|
|
||||||
every state-changing command on a prompted domain bind, and write an `AuditLog` row naming
|
|
||||||
the operator. Write the audit row by hand rather than importing `log_event` from `app.py` —
|
|
||||||
that would pull FastAPI and the whole application into a CLI startup for one INSERT.
|
|
||||||
|
|
||||||
**Done when:**
|
|
||||||
|
|
||||||
- [x] `create-admin`, `create` and `reset-password` are rejected as invalid choices
|
|
||||||
- [x] `list` works with no credential and with no LDAP configured
|
|
||||||
- [x] a state-changing command with LDAP misconfigured refuses instead of proceeding
|
|
||||||
- [x] there is no `--password` flag on any command
|
|
||||||
- [x] `promote` raises a role; `demote` returns an account to `project_user`
|
|
||||||
- [x] promoting YOURSELF is allowed and recorded with `self: true`
|
|
||||||
- [x] the last active admin cannot be demoted
|
|
||||||
- [x] an unknown account gives an error that says accounts are made on first sign-in
|
|
||||||
- [x] every change writes an `AuditLog` row naming the operator
|
|
||||||
- [x] verified against a real domain bind — `promote` and `demote` confirmed working Aug 24 2026
|
|
||||||
|
|||||||
257
docs/waves/wave-11.md
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
# Wave 11 — Usage and activity metrics
|
||||||
|
|
||||||
|
**Items:** `CR-019`
|
||||||
|
**Depends on:** wave 10 merged (it is; this wave does not wait on anything else)
|
||||||
|
**Decision record:** `docs/waves/decisions-2026-09-17.md`
|
||||||
|
|
||||||
|
Seven tasks, one concern each, in build order. Do not start a task whose
|
||||||
|
dependency is not merged. `CR-020` (bulk user editing) is reserved but not
|
||||||
|
scoped — it does not belong in this wave.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T11.1 — CR-019: `usage_events` table + migration
|
||||||
|
|
||||||
|
- **Items:** `CR-019`
|
||||||
|
- **Depends on:** nothing (first task)
|
||||||
|
- **Blocks:** T11.2
|
||||||
|
- **Surface:** `server/`
|
||||||
|
- **Files:** `server/models.py`, `server/alembic/versions/`
|
||||||
|
|
||||||
|
**Do:** Add a `UsageEvent` model — append-only, same spirit as `AuditLog` but for
|
||||||
|
navigation/feature-open events rather than business mutations. Suggested shape:
|
||||||
|
`id`, `at` (indexed), `user_id` (or username — match whatever `AuditLog.actor`
|
||||||
|
does today for consistency), `project_id` (nullable — not every event is
|
||||||
|
project-scoped, e.g. opening the admin console), `tool` (e.g. `creator`,
|
||||||
|
`wizard`, `field_view`, `dashboard`, `admin`, `directory`), `event` (e.g.
|
||||||
|
`page_open`, `login`), `detail` (JSON, optional). Write the migration. Do not
|
||||||
|
touch `AuditLog` — this is a new table, not an extension of it (see the
|
||||||
|
decision record's reasoning).
|
||||||
|
|
||||||
|
**Do not:** fold this into `AuditLog`. They serve different questions and mixing
|
||||||
|
them makes the existing audit trail noisier for its existing readers.
|
||||||
|
|
||||||
|
**Done when:**
|
||||||
|
|
||||||
|
- [ ] `UsageEvent` exists with an indexed `at` column (this table will be scanned
|
||||||
|
by date range constantly)
|
||||||
|
- [ ] migration applies cleanly against both SQLite (dev) and Postgres (prod) —
|
||||||
|
render `alembic upgrade --sql` for postgresql and read it before calling
|
||||||
|
this done, per the class of defect `BL-027` logged
|
||||||
|
- [ ] no change to `AuditLog`'s shape or behavior
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T11.2 — CR-019: capture the events
|
||||||
|
|
||||||
|
- **Items:** `CR-019`
|
||||||
|
- **Depends on:** T11.1
|
||||||
|
- **Blocks:** T11.3
|
||||||
|
- **Surface:** `server/` + `html/`
|
||||||
|
- **Files:** `server/app.py` (new endpoint), `html/auth-guard.js`
|
||||||
|
|
||||||
|
**Problem:** Six pages need this and none should implement it separately — that
|
||||||
|
is exactly how `S4`'s "no global nav on two pages" and the four parallel token
|
||||||
|
systems (`S5`) happened. `auth-guard.js` is already loaded first, in the `<head>`,
|
||||||
|
on all six protected pages (`index.html`, `field.html`, `users.html`,
|
||||||
|
`wp-creation-index.html`, `work-package-suite.html`, `admin.html`) and already
|
||||||
|
knows the verified user once the `wp-auth-ready` event fires. That is the one
|
||||||
|
place this belongs.
|
||||||
|
|
||||||
|
**Do:** Add a small `POST /api/usage/ping`-style endpoint that writes one
|
||||||
|
`UsageEvent` row per call, keyed to the session (server trusts the session, not
|
||||||
|
anything the client claims about identity). Call it once from `auth-guard.js`
|
||||||
|
after `wp-auth-ready`, tagging `tool` from the page's own path. Also write a
|
||||||
|
`login` event at the point a session is actually established (reuse whatever
|
||||||
|
`okta_callback` already does at sign-in — do not add a second source of truth
|
||||||
|
for "did this person log in").
|
||||||
|
|
||||||
|
**Do not:** build a per-page capture call. If a page needs this and
|
||||||
|
`auth-guard.js` does not cover it, fix `auth-guard.js`, not the page.
|
||||||
|
|
||||||
|
**Done when:**
|
||||||
|
|
||||||
|
- [ ] one `page_open` event is recorded for a real sign-in on each of the six
|
||||||
|
pages, verified per page
|
||||||
|
- [ ] exactly one `login` event per Okta sign-in, not one per page load after
|
||||||
|
it
|
||||||
|
- [ ] the endpoint rejects a request with no valid session (this is server-
|
||||||
|
enforced identity, not client-reported)
|
||||||
|
- [ ] no page other than `auth-guard.js` calls this endpoint directly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T11.3 — CR-019: aggregation endpoint with filters
|
||||||
|
|
||||||
|
- **Items:** `CR-019`
|
||||||
|
- **Depends on:** T11.2
|
||||||
|
- **Blocks:** T11.4, T11.5
|
||||||
|
- **Surface:** `server/`
|
||||||
|
- **Files:** `server/app.py`
|
||||||
|
|
||||||
|
**Do:** Build the read side: active-user counts by day/week/month, per-user
|
||||||
|
last-active timestamp (derived from `UsageEvent`, not `User.last_login_at`,
|
||||||
|
which only ever holds one value), and a per-tool usage breakdown. Accept query
|
||||||
|
filters: date range, project, user, tool — combinable, per the decision record.
|
||||||
|
This is server aggregation, the same principle `B4` established for the
|
||||||
|
pipeline strip: the browser asks for a number, the server computes it from real
|
||||||
|
rows, nothing is derived client-side from a partial cache.
|
||||||
|
|
||||||
|
**Done when:**
|
||||||
|
|
||||||
|
- [ ] active-user counts are correct against a seeded fixture with known dates
|
||||||
|
- [ ] filters combine correctly (verified: user + date range + tool together
|
||||||
|
narrows correctly, not just each alone)
|
||||||
|
- [ ] a project filter that matches nothing returns an empty result, not an
|
||||||
|
error or the unfiltered total
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T11.4 — CR-019: export, raw and sanitized
|
||||||
|
|
||||||
|
- **Items:** `CR-019`
|
||||||
|
- **Depends on:** T11.3
|
||||||
|
- **Blocks:** T11.5
|
||||||
|
- **Surface:** `server/`
|
||||||
|
- **Files:** `server/app.py`
|
||||||
|
|
||||||
|
**Do:** A CSV export endpoint over the same filtered query T11.3 exposes.
|
||||||
|
Two modes: raw (real usernames, the console's default) and sanitized. Sanitized
|
||||||
|
mode replaces the actor field with a stable pseudonymous id — a per-user hash,
|
||||||
|
consistent across rows in the same export and across separate export runs —
|
||||||
|
so an external system (Power BI or similar) can still group and trend "by
|
||||||
|
user" without ever receiving a real name. Do not simply drop the identity
|
||||||
|
column; that breaks per-user grouping downstream, which defeats the point of
|
||||||
|
an activity export.
|
||||||
|
|
||||||
|
**Done when:**
|
||||||
|
|
||||||
|
- [ ] raw export contains real usernames
|
||||||
|
- [ ] sanitized export never contains a real username or email anywhere in the
|
||||||
|
file, including in a `detail` blob if one is included
|
||||||
|
- [ ] the same real user maps to the same pseudonymous id within one export AND
|
||||||
|
across two separate export runs (a hash of something stable, not a
|
||||||
|
per-request random id)
|
||||||
|
- [ ] both modes otherwise contain identical rows for the same filter
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T11.5 — CR-019: admin console Activity tab
|
||||||
|
|
||||||
|
- **Items:** `CR-019`
|
||||||
|
- **Depends on:** T11.3, T11.4
|
||||||
|
- **Blocks:** T11.7
|
||||||
|
- **Surface:** `html/`
|
||||||
|
- **Files:** `html/admin.html`, `html/admin.js`
|
||||||
|
|
||||||
|
**Do:** New tab, same role gate as the User Directory. Filters (date range,
|
||||||
|
project, user, tool) driving the tables from T11.3; export buttons (raw and
|
||||||
|
sanitized) calling T11.4. Build accessible from the start per `C1` — this is a
|
||||||
|
new component, not a legacy one carrying an old defect forward: real
|
||||||
|
`<button>`/`<select>` controls, keyboard-reachable, `aria-live` on any
|
||||||
|
count that updates without a page reload, focus visible throughout.
|
||||||
|
|
||||||
|
**Done when:**
|
||||||
|
|
||||||
|
- [x] the tab is reachable only by an admin (the card lives inside admin.html,
|
||||||
|
already gated client-side by gateByRole(); the API underneath it is
|
||||||
|
independently gated server-side by require_user_manager regardless)
|
||||||
|
- [x] every filter is a real form control, keyboard-operable (date/select/text
|
||||||
|
inputs and a `<button>`, no click-div)
|
||||||
|
- [x] both export buttons produce the files T11.4 defines (verified against
|
||||||
|
the live endpoint in T11.4's own checks, and present/wired here)
|
||||||
|
- [x] works at 390px and 1440px — verified 2026-09-23 via
|
||||||
|
`tests/baseline_shots.py --pages admin` run locally on Windows (this
|
||||||
|
sandbox has no headless browser available; the script was run on the
|
||||||
|
user's machine instead, after installing Python via winget since it
|
||||||
|
wasn't present). Screenshots in `docs/reference/baseline/admin-390.png`
|
||||||
|
/ `admin-1440.png`. No JS errors, no horizontal overflow at either
|
||||||
|
width; the card rendered with real seeded data (events, by-tool,
|
||||||
|
per-user-last-active tables) confirming the filters and summary read
|
||||||
|
correctly, not just that the markup exists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T11.6 — CR-019: retire the per-browser Usage report
|
||||||
|
|
||||||
|
- **Items:** `CR-019`
|
||||||
|
- **Depends on:** T11.5
|
||||||
|
- **Blocks:** T11.7
|
||||||
|
- **Surface:** `html/`
|
||||||
|
- **Files:** `html/admin.js` (the `usage-admin` panel, `admin.js:666-699`),
|
||||||
|
`html/wp-usage.js` and its two call sites
|
||||||
|
|
||||||
|
**Do:** Remove the old per-browser `usage-admin` panel from `admin.js` now that
|
||||||
|
the real one exists, per the 2026-09-17 decision. Decide what happens to
|
||||||
|
`wp-usage.js`'s recording calls (wizard dwell-tracking, the creator's
|
||||||
|
equivalent): they were never reliably tied to a real identity, so they are not
|
||||||
|
a data source the new report can adopt. Default to removing the recorder too
|
||||||
|
unless it is still doing something useful on its own (re-read what it actually
|
||||||
|
records before deciding — do not assume from this file alone).
|
||||||
|
|
||||||
|
**Do not:** leave the old panel in place "just in case." Two activity reports
|
||||||
|
showing two different numbers is worse than one.
|
||||||
|
|
||||||
|
**Decision (2026-09-23):** `wp-usage.js` is removed, not kept — it had no
|
||||||
|
reader left once the admin panel above it was removed (the "download the
|
||||||
|
full event log" button lived only in that panel), and per the original
|
||||||
|
decision record it was never reliably tied to a real identity, so it was
|
||||||
|
never a candidate source for the new report either. The file itself and its
|
||||||
|
three `<script>` includes (`admin.html`, `wp-creation-index.html`,
|
||||||
|
`work-package-suite.html`) are gone. Its two call sites
|
||||||
|
(`wp-creation-app.js`, `work-package-suite-app.js`) keep a local `track()`
|
||||||
|
function as a documented no-op rather than having each of their ~45
|
||||||
|
individual `track('event', …)` call sites deleted one at a time — that
|
||||||
|
would be a far larger, riskier diff for the same outcome (no more data is
|
||||||
|
recorded either way), and it keeps each call site as a marker of what was
|
||||||
|
worth recording if usage analytics are ever rebuilt server-side. The
|
||||||
|
dwell-timer plumbing that only ever fed `track()` (`work-package-suite-app.js`'s
|
||||||
|
`_stepEnter`/`trackStepDwell`) was left in place for the same reason — it is
|
||||||
|
inert now, not broken, and touching it buys nothing.
|
||||||
|
|
||||||
|
Also removed: `tests/usage_check.py` (tested exactly the retired feature —
|
||||||
|
D5/T7.10's per-browser analytics core and admin report) and its line in
|
||||||
|
`docs/reference/file-map.md`, replaced with a note pointing at this decision.
|
||||||
|
|
||||||
|
**Done when:**
|
||||||
|
|
||||||
|
- [x] the old `usage-admin` panel and its markup are gone from `admin.html`/
|
||||||
|
`admin.js`
|
||||||
|
- [x] a decision on `wp-usage.js` itself is recorded (removed, or kept with a
|
||||||
|
stated reason) — not left ambiguous — see above
|
||||||
|
- [x] nothing else in the app references the removed code; grep confirms
|
||||||
|
(only remaining hits are this file, the 2026-09-17 decision record, and
|
||||||
|
the two explanatory code comments left at the retired call sites — all
|
||||||
|
prose, not live references)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T11.7 — CR-019: verification
|
||||||
|
|
||||||
|
- **Items:** `CR-019`
|
||||||
|
- **Depends on:** T11.6
|
||||||
|
- **Blocks:** nothing
|
||||||
|
- **Surface:** `html/` + `server/`
|
||||||
|
- **Files:** as touched above
|
||||||
|
|
||||||
|
**Do:** Full verification per `CLAUDE.md`: run the app locally, exercise the
|
||||||
|
new tab at 390px and 1440px, before/after screenshots, run the existing smoke
|
||||||
|
test and `seed_demo.py`, run the full suite.
|
||||||
|
|
||||||
|
**Done when:**
|
||||||
|
|
||||||
|
- [ ] all `CR-019` acceptance criteria in `decisions-2026-09-17.md` are met or
|
||||||
|
a failure is stated with a reason
|
||||||
|
- [ ] screenshots committed
|
||||||
|
- [ ] smoke test and `seed_demo.py` both still pass
|
||||||
|
- [ ] full test suite passes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wave 11 exit criteria
|
||||||
|
|
||||||
|
- [ ] real, server-side activity data exists per user, indefinitely retained
|
||||||
|
- [ ] the admin console shows it, filterable by date/project/user/tool
|
||||||
|
- [ ] export works in both raw and sanitized form
|
||||||
|
- [ ] the old per-browser report is gone, not duplicated
|
||||||
|
- [ ] `CR-019` fully accounted for, no open acceptance criteria
|
||||||
193
docs/waves/wave-12.md
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
# Wave 12 — Bulk editing of users
|
||||||
|
|
||||||
|
**Items:** `CR-020`
|
||||||
|
**Depends on:** wave 10 merged (it is). Independent of wave 11 (`CR-019`) — no
|
||||||
|
shared files, may build in either order or in parallel.
|
||||||
|
**Decision record:** `docs/waves/decisions-2026-09-17.md`
|
||||||
|
|
||||||
|
Six tasks, in build order.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T12.1 — CR-020: bulk endpoints
|
||||||
|
|
||||||
|
- **Items:** `CR-020`
|
||||||
|
- **Depends on:** nothing (first task)
|
||||||
|
- **Blocks:** T12.2, T12.4
|
||||||
|
- **Surface:** `server/`
|
||||||
|
- **Files:** `server/app.py`
|
||||||
|
|
||||||
|
**Do:** Add bulk variants of the four existing single-user actions — role
|
||||||
|
change, active/disabled, project assignment (add/remove + project-role), and
|
||||||
|
delete. Each takes a list of `user_id`s plus the action's parameters and
|
||||||
|
applies `require_see_user`/`require_manage_user`/`grantable_roles` **per row**,
|
||||||
|
exactly as the single-user endpoint does today — a super user's bulk request
|
||||||
|
cannot reach further than their existing single-user requests can. Do not skip
|
||||||
|
the self-action guard: an actor's own account is rejected out of any batch
|
||||||
|
that would disable, demote, or delete it, same as today.
|
||||||
|
|
||||||
|
Each successful row writes its own `AuditLog` entry via `log_event`, same
|
||||||
|
action names the single endpoints already use. A row that fails is reported in
|
||||||
|
the response (user id, reason) and does not stop the rest of the batch from
|
||||||
|
being attempted.
|
||||||
|
|
||||||
|
**Do not:** invent a single opaque "bulk_action" audit entry in place of the
|
||||||
|
per-row entries. Do not build a soft-delete path for bulk delete that doesn't
|
||||||
|
exist for the single case — bulk delete stays a hard delete, matching
|
||||||
|
`delete_user` today.
|
||||||
|
|
||||||
|
**Done when:**
|
||||||
|
|
||||||
|
- [ ] each of the four bulk actions is callable with a list of user ids and a
|
||||||
|
single set of parameters
|
||||||
|
- [ ] a batch containing the actor's own account rejects only that row, not
|
||||||
|
the whole batch — verified for disable, demote, and delete
|
||||||
|
- [ ] a super user's batch that includes a user/project outside what they
|
||||||
|
manage rejects only that row, with a stated reason
|
||||||
|
- [ ] every successful row produces its own `AuditLog` entry, identical in
|
||||||
|
shape to what the single-user endpoint would have written
|
||||||
|
- [ ] a batch with some failing rows still applies to the rows that succeed,
|
||||||
|
and the response lists exactly which rows failed and why
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T12.2 — CR-020: row selection in the User Directory table
|
||||||
|
|
||||||
|
- **Items:** `CR-020`
|
||||||
|
- **Depends on:** T12.1
|
||||||
|
- **Blocks:** T12.3
|
||||||
|
- **Surface:** `html/`
|
||||||
|
- **Files:** `html/users.js`, `html/users.html`
|
||||||
|
|
||||||
|
**Do:** Add a checkbox per row and a select-all control, respecting whatever
|
||||||
|
filter (`role`, `active`/`disabled`, project) is currently applied — select-all
|
||||||
|
selects the filtered set, not every user in the system regardless of what's
|
||||||
|
shown. A bulk-action toolbar appears once at least one row is checked and
|
||||||
|
disappears at zero.
|
||||||
|
|
||||||
|
**Done when:**
|
||||||
|
|
||||||
|
- [ ] select-all selects exactly the rows currently visible under the active
|
||||||
|
filter, not the full unfiltered table
|
||||||
|
- [ ] changing the filter while rows are selected does something sane and
|
||||||
|
visible (either clears the selection or keeps it explicit which rows are
|
||||||
|
still selected) — pick one and state it, don't leave it undefined
|
||||||
|
- [ ] the toolbar is keyboard-reachable and only present when >=1 row is
|
||||||
|
selected
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T12.3 — CR-020: bulk-action toolbar
|
||||||
|
|
||||||
|
- **Items:** `CR-020`
|
||||||
|
- **Depends on:** T12.2
|
||||||
|
- **Blocks:** T12.5
|
||||||
|
- **Surface:** `html/`
|
||||||
|
- **Files:** `html/users.js`
|
||||||
|
|
||||||
|
**Do:** Wire the toolbar to T12.1's endpoints for role change, activate/
|
||||||
|
disable, and project assignment (add to / remove from a project + project-
|
||||||
|
role). Confirmation before applying uses the `wp-dialog` kit (`T7.9`) —
|
||||||
|
`wpConfirmDialog`, not a native `confirm()` — naming exactly how many users are
|
||||||
|
affected. On completion, report per-row results if anything failed (T12.1
|
||||||
|
already returns this) rather than a single success/failure toast that hides a
|
||||||
|
partial failure.
|
||||||
|
|
||||||
|
**Delete is built separately, in T12.5** — do not wire delete here.
|
||||||
|
|
||||||
|
**Done when:**
|
||||||
|
|
||||||
|
- [ ] role change, activate/disable, and project assignment each work end to
|
||||||
|
end against a multi-row selection
|
||||||
|
- [ ] the confirmation dialog names the exact affected count before anything is
|
||||||
|
sent
|
||||||
|
- [ ] a batch with a partial failure shows which rows failed, not just an
|
||||||
|
undifferentiated error
|
||||||
|
- [ ] `aria-live` announces the outcome
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T12.4 — CR-020: CSV upload path
|
||||||
|
|
||||||
|
- **Items:** `CR-020`
|
||||||
|
- **Depends on:** T12.1
|
||||||
|
- **Blocks:** T12.6
|
||||||
|
- **Surface:** `html/` + `server/`
|
||||||
|
- **Files:** `html/users.js`, `server/app.py`
|
||||||
|
|
||||||
|
**Do:** An upload accepting a list of usernames plus the action to apply,
|
||||||
|
following the validate-and-report pattern `CR-005` established: reject and
|
||||||
|
report bad rows (username not found, actor lacks permission over that user)
|
||||||
|
rather than silently dropping them. This is a second entry point onto the same
|
||||||
|
T12.1 endpoints, not a third implementation of the bulk logic.
|
||||||
|
|
||||||
|
**Done when:**
|
||||||
|
|
||||||
|
- [ ] a CSV with a mix of valid and invalid usernames applies to the valid
|
||||||
|
rows and reports the invalid ones by row, with a reason
|
||||||
|
- [ ] the same permission/self-action guards from T12.1 apply here — a CSV
|
||||||
|
cannot reach a user a checkbox-driven batch couldn't
|
||||||
|
- [ ] duplicate usernames in one CSV are handled without double-applying or
|
||||||
|
erroring confusingly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T12.5 — CR-020: bulk delete confirmation
|
||||||
|
|
||||||
|
- **Items:** `CR-020`
|
||||||
|
- **Depends on:** T12.3
|
||||||
|
- **Blocks:** T12.6
|
||||||
|
- **Surface:** `html/`
|
||||||
|
- **Files:** `html/users.js`
|
||||||
|
|
||||||
|
**Do:** Wire delete into the toolbar with a heavier confirmation than the other
|
||||||
|
three actions, per the decision record's recommendation: list the affected
|
||||||
|
usernames and require typing a confirmation phrase (e.g. `DELETE`) before the
|
||||||
|
request is sent, regardless of how many rows are selected. This is flagged in
|
||||||
|
the decision record as a recommendation Matt has not explicitly signed off on
|
||||||
|
— if the PR reviewer wants a lighter or heavier mechanism, that's the moment to
|
||||||
|
change it, not a reason to skip building a real confirmation now.
|
||||||
|
|
||||||
|
**Done when:**
|
||||||
|
|
||||||
|
- [ ] the affected usernames are listed in the confirmation dialog before
|
||||||
|
delete is sent
|
||||||
|
- [ ] the request is not sent until the confirmation phrase is typed correctly
|
||||||
|
- [ ] the actor's own account, if somehow selected, is rejected with a clear
|
||||||
|
reason rather than silently included or silently dropped
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T12.6 — CR-020: verification
|
||||||
|
|
||||||
|
- **Items:** `CR-020`
|
||||||
|
- **Depends on:** T12.4, T12.5
|
||||||
|
- **Blocks:** nothing
|
||||||
|
- **Surface:** `html/` + `server/`
|
||||||
|
- **Files:** as touched above
|
||||||
|
|
||||||
|
**Do:** Full verification per `CLAUDE.md`: run locally, exercise bulk role
|
||||||
|
change, activate/disable, project assignment, CSV upload, and bulk delete at
|
||||||
|
390px and 1440px, before/after screenshots, smoke test, `seed_demo.py`, full
|
||||||
|
suite.
|
||||||
|
|
||||||
|
**Done when:**
|
||||||
|
|
||||||
|
- [ ] all `CR-020` acceptance criteria in `decisions-2026-09-17.md` are met or
|
||||||
|
a failure is stated with a reason
|
||||||
|
- [ ] screenshots committed
|
||||||
|
- [ ] smoke test and `seed_demo.py` both still pass
|
||||||
|
- [ ] full test suite passes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wave 12 exit criteria
|
||||||
|
|
||||||
|
- [ ] all four bulk actions work against both a checkbox selection and a CSV
|
||||||
|
upload
|
||||||
|
- [ ] every existing single-user guardrail (self-action, scope, grantable
|
||||||
|
roles) holds under bulk use
|
||||||
|
- [ ] bulk delete requires typed confirmation and lists affected usernames
|
||||||
|
- [ ] partial failures are always reported, never hidden behind a blanket
|
||||||
|
success
|
||||||
|
- [ ] `CR-020` fully accounted for, no open acceptance criteria
|
||||||
242
docs/waves/wave-13.md
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
# Wave 13 — Okta/AD deprovisioning sync
|
||||||
|
|
||||||
|
**Items:** `D18`
|
||||||
|
**Depends on:** wave 10 merged (it is). Not blocked by wave 11 or wave 12, but
|
||||||
|
shares `server/app.py` account-state surface with wave 12 (`CR-020`) — sequence
|
||||||
|
commits to avoid an avoidable conflict.
|
||||||
|
**Decision record:** `docs/waves/decisions-2026-09-17.md`
|
||||||
|
|
||||||
|
Six tasks, in build order. **T13.3's fail-closed behavior is the most
|
||||||
|
important done-when list in this wave — do not relax it to ship faster.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T13.1 — D18: idle timeout with an absolute ceiling
|
||||||
|
|
||||||
|
- **Items:** `D18`
|
||||||
|
- **Depends on:** nothing (first task, independent of the rest)
|
||||||
|
- **Blocks:** nothing
|
||||||
|
- **Surface:** `server/`
|
||||||
|
- **Files:** `server/auth.py`, `server/app.py` (`auth_gate` middleware),
|
||||||
|
`server/.env.example`, `DEPLOYMENT.md`
|
||||||
|
|
||||||
|
**Revised 2026-09-23** — originally just "shrink `AUTH_SESSION_HOURS`". Matt
|
||||||
|
asked whether an idle timeout would be a better fit than a flat session
|
||||||
|
length. It is, but not by itself — see the decision record's reasoning on why
|
||||||
|
idle-with-no-ceiling is actually a worse fit for this item's own threat model
|
||||||
|
than a flat expiry would have been. Build both.
|
||||||
|
|
||||||
|
**Do:**
|
||||||
|
|
||||||
|
- Add `login_at` to the JWT payload in `create_token()` — the original
|
||||||
|
sign-in time, distinct from `iat`, which becomes "when THIS token was
|
||||||
|
issued" once tokens start getting reissued. `login_at` never changes across
|
||||||
|
reissues; it's what the absolute ceiling is measured from.
|
||||||
|
- Add `AUTH_IDLE_MINUTES` (default 30). `AUTH_SESSION_HOURS` stays the name
|
||||||
|
for the absolute ceiling, default changing from 12 to a proposed 8 — update
|
||||||
|
its docstring/comment in `auth.py` and `.env.example`, since its MEANING is
|
||||||
|
changing (session length -> hard ceiling on top of a sliding idle window),
|
||||||
|
not just its value.
|
||||||
|
- In `auth_gate` (`server/app.py`), after confirming a request is
|
||||||
|
authenticated: if `now < login_at + AUTH_SESSION_HOURS` (the ceiling hasn't
|
||||||
|
passed), compute `new_exp = min(now + AUTH_IDLE_MINUTES, login_at +
|
||||||
|
AUTH_SESSION_HOURS)`. If `new_exp` is meaningfully later than the current
|
||||||
|
token's `exp` (throttle this — do not reissue on every single request, only
|
||||||
|
when enough time has passed to be worth a new cookie; a few minutes of
|
||||||
|
slack is fine), mint a refreshed token carrying forward `sub`/`username`/
|
||||||
|
`role`/`ver`/`login_at` unchanged, and set it on the response.
|
||||||
|
- If the ceiling HAS passed, do not refresh — let the existing token expire
|
||||||
|
on its own terms (it may already be invalid, or may tick over within the
|
||||||
|
idle window; either way, no new one is issued past the ceiling).
|
||||||
|
- The middleware should not need a DB round trip to do this — everything
|
||||||
|
needed (`sub`, `username`, `role`, `ver`, `login_at`) is already in the
|
||||||
|
validated claims. `get_current_user`'s existing per-request `is_active`/
|
||||||
|
`token_version` check is unaffected and still runs separately.
|
||||||
|
|
||||||
|
**Do not:** reissue the cookie on every request unconditionally — that's a
|
||||||
|
`Set-Cookie` header on every API call for no benefit over a coarser refresh.
|
||||||
|
Do not let the ceiling check silently vanish for tokens issued before this
|
||||||
|
change ships — a token with no `login_at` claim should fall back to treating
|
||||||
|
its own `iat` as `login_at`, not bypass the ceiling entirely.
|
||||||
|
|
||||||
|
**Done when:**
|
||||||
|
|
||||||
|
- [ ] a session with continuous activity stays alive past 30 minutes but is
|
||||||
|
cut off at the `AUTH_SESSION_HOURS` ceiling regardless
|
||||||
|
- [ ] a session with no activity for 30+ minutes is rejected on its next
|
||||||
|
request
|
||||||
|
- [ ] the cookie is not rewritten on every single request — verify the
|
||||||
|
refresh is throttled, not unconditional
|
||||||
|
- [ ] a pre-existing token with no `login_at` claim (simulating a session
|
||||||
|
issued before this shipped) still gets a hard ceiling, via the `iat`
|
||||||
|
fallback
|
||||||
|
- [ ] `.env.example` and `DEPLOYMENT.md` explain both variables and flag both
|
||||||
|
defaults as proposed, not confirmed against Okta's own session policy
|
||||||
|
- [ ] existing session/auth tests updated for the new mechanism, not just a
|
||||||
|
new number
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T13.2 — D18: Okta Management API client
|
||||||
|
|
||||||
|
- **Items:** `D18`
|
||||||
|
- **Depends on:** nothing (independent of T13.1)
|
||||||
|
- **Blocks:** T13.3
|
||||||
|
- **Surface:** `server/`
|
||||||
|
- **Files:** new `server/okta_sync.py` (or extend `server/okta_auth.py` —
|
||||||
|
task's call), `server/.env.example`
|
||||||
|
|
||||||
|
**Do:** A small client for Okta's user-list endpoint, authenticated with a new,
|
||||||
|
separate credential (e.g. `OKTA_API_TOKEN`) — not the OIDC client secret used
|
||||||
|
for sign-in. Fetch the full user list (paginated per Okta's API) rather than
|
||||||
|
one-by-one lookups per local user; this app has ~20-odd accounts today, and a
|
||||||
|
list-and-diff is simpler and cheaper than N calls. Return each Okta user's
|
||||||
|
identity-claim value (matching `OKTA_IDENTITY_CLAIM`, already confirmed live
|
||||||
|
per wave 10) and status.
|
||||||
|
|
||||||
|
Follow the existing pattern for external credentials in this repo (`MICRON_DB_URL`,
|
||||||
|
`SMTP_PASSWORD`): env-only, never logged, never returned to the browser in an
|
||||||
|
error message.
|
||||||
|
|
||||||
|
**Do not:** reuse `OKTA_CLIENT_ID`/`OKTA_CLIENT_SECRET` for this. Sign-in and
|
||||||
|
the management API are different trust boundaries with different scopes;
|
||||||
|
conflating them means a compromise or rotation of one affects the other
|
||||||
|
unnecessarily.
|
||||||
|
|
||||||
|
**Done when:**
|
||||||
|
|
||||||
|
- [ ] the client authenticates with its own credential, distinct from the OIDC
|
||||||
|
client
|
||||||
|
- [ ] it fetches the complete Okta user list, handling pagination
|
||||||
|
- [ ] an auth failure or malformed response raises a clear, specific error
|
||||||
|
rather than returning an empty list indistinguishable from "everyone was
|
||||||
|
deprovisioned" — this distinction is what T13.3 depends on
|
||||||
|
- [ ] the credential is never logged or surfaced in any API response
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T13.3 — D18: the sync job
|
||||||
|
|
||||||
|
- **Items:** `D18`
|
||||||
|
- **Depends on:** T13.2
|
||||||
|
- **Blocks:** T13.4
|
||||||
|
- **Surface:** `server/`
|
||||||
|
- **Files:** `server/okta_sync.py`, `server/app.py` (or wherever `log_event`
|
||||||
|
lives)
|
||||||
|
|
||||||
|
**Do:** Compare the Okta user list (T13.2) against local `users` rows. For any
|
||||||
|
local `is_active=True` user whose Okta identity is missing from the list, or
|
||||||
|
present with a non-active status, set `is_active=False` and write an
|
||||||
|
`AuditLog` row (`actor="system:okta_sync"`, action e.g.
|
||||||
|
`user_deprovisioned_by_sync`, detail naming the Okta status found). Never
|
||||||
|
touch a user already `is_active=False`. Never re-enable anyone.
|
||||||
|
|
||||||
|
**The fail-closed rule, non-negotiable:** if T13.2's client raises an error of
|
||||||
|
any kind (network, auth, malformed response, timeout), this task takes **no
|
||||||
|
action on any account** for that run and logs the failure clearly (server log,
|
||||||
|
at minimum). An error is never treated as "Okta returned zero active users."
|
||||||
|
Write a test that asserts this directly: feed the sync a failing client and
|
||||||
|
assert zero rows changed and zero `AuditLog` entries written.
|
||||||
|
|
||||||
|
**Done when:**
|
||||||
|
|
||||||
|
- [ ] a user missing from Okta's list, or present but not active, is disabled
|
||||||
|
with a correctly-detailed audit row
|
||||||
|
- [ ] a user already disabled is left alone (no duplicate audit row each run)
|
||||||
|
- [ ] an active Okta user already active locally produces no audit row (only
|
||||||
|
changes are logged, not a clean bill of health every cycle)
|
||||||
|
- [ ] **a simulated Okta API failure results in zero account changes and zero
|
||||||
|
audit rows** — this is the one check that must never be skipped or
|
||||||
|
weakened
|
||||||
|
- [ ] the job never re-enables an account under any input
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T13.4 — D18: run it on a schedule
|
||||||
|
|
||||||
|
- **Items:** `D18`
|
||||||
|
- **Depends on:** T13.3
|
||||||
|
- **Blocks:** T13.6
|
||||||
|
- **Surface:** `server/`, `docker-compose.yml`
|
||||||
|
- **Files:** `server/app.py` (startup hook) or a new sidecar per the `backup`
|
||||||
|
container's pattern — task's call, per the decision record's noted
|
||||||
|
alternative
|
||||||
|
|
||||||
|
**Do:** Wire T13.3 to run on an interval (proposed 15 minutes, env-overridable
|
||||||
|
— e.g. `OKTA_SYNC_INTERVAL_SECONDS`, matching `BACKUP_INTERVAL_SECONDS`'s
|
||||||
|
naming). Default choice is an in-process background task in the `api`
|
||||||
|
container; if a separate container is chosen instead, follow the `backup`
|
||||||
|
service's shape (own Dockerfile or script, `internal` network plus whatever
|
||||||
|
egress reaching Okta requires — check whether `outbound` as currently defined
|
||||||
|
is sufficient or Okta needs a distinct allowance).
|
||||||
|
|
||||||
|
**Done when:**
|
||||||
|
|
||||||
|
- [ ] the job runs automatically on the configured interval without manual
|
||||||
|
invocation
|
||||||
|
- [ ] interval is env-configurable with a sane default
|
||||||
|
- [ ] a container restart does not produce a duplicate/overlapping run, and a
|
||||||
|
slow cycle does not stack with the next one
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T13.5 — D18: admin visibility
|
||||||
|
|
||||||
|
- **Items:** `D18`
|
||||||
|
- **Depends on:** T13.3
|
||||||
|
- **Blocks:** T13.6
|
||||||
|
- **Surface:** `html/`
|
||||||
|
- **Files:** `html/users.js` / `html/admin.js` (wherever audit history is
|
||||||
|
already surfaced)
|
||||||
|
|
||||||
|
**Do:** Confirm an auto-disable reads clearly wherever admins already look at
|
||||||
|
account history — the actor string (`system:okta_sync`) should be
|
||||||
|
self-explanatory in context, not require reading server logs to understand.
|
||||||
|
Do not build new UI beyond making sure the existing audit surface renders this
|
||||||
|
actor sensibly. Email/notification to admins on auto-disable is a noted
|
||||||
|
fast-follow (the decision record flags it as open, not required here) — do not
|
||||||
|
build it in this task; log it instead if it's tempting to add.
|
||||||
|
|
||||||
|
**Done when:**
|
||||||
|
|
||||||
|
- [ ] an auto-disabled account's audit entry is visible and legible in the
|
||||||
|
existing admin UI without special-casing
|
||||||
|
- [ ] nothing here silently assumes `CR-019`'s activity view exists yet — this
|
||||||
|
must work standalone
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T13.6 — D18: verification
|
||||||
|
|
||||||
|
- **Items:** `D18`
|
||||||
|
- **Depends on:** T13.4, T13.5
|
||||||
|
- **Blocks:** nothing
|
||||||
|
- **Surface:** `server/`
|
||||||
|
- **Files:** as touched above
|
||||||
|
|
||||||
|
**Do:** Full verification per `CLAUDE.md`. Beyond the usual run: specifically
|
||||||
|
re-run T13.3's fail-closed test in isolation and confirm it still passes after
|
||||||
|
T13.4's scheduling wrapper is in place — the scheduling layer must not
|
||||||
|
introduce a path that swallows the client's error and proceeds anyway.
|
||||||
|
|
||||||
|
**Done when:**
|
||||||
|
|
||||||
|
- [ ] all `D18` acceptance criteria in `decisions-2026-09-17.md` are met or a
|
||||||
|
failure is stated with a reason
|
||||||
|
- [ ] the fail-closed behavior is verified end to end through the scheduled
|
||||||
|
wrapper, not just the bare sync function
|
||||||
|
- [ ] smoke test and `seed_demo.py` both still pass
|
||||||
|
- [ ] full test suite passes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wave 13 exit criteria
|
||||||
|
|
||||||
|
- [ ] `AUTH_SESSION_HOURS` defaults to 2, documented
|
||||||
|
- [ ] the sync job runs on a schedule and correctly disables accounts Okta no
|
||||||
|
longer shows as active
|
||||||
|
- [ ] every auto-disable is individually audited with a clearly non-human actor
|
||||||
|
- [ ] an Okta API failure of any kind changes zero accounts — verified, not
|
||||||
|
assumed
|
||||||
|
- [ ] the sync never re-enables an account
|
||||||
|
- [ ] `D18` fully accounted for, no open acceptance criteria
|
||||||
@@ -34,12 +34,12 @@
|
|||||||
/* Every container admin.js paints a table into is a scrollport of its own, so a
|
/* Every container admin.js paints a table into is a scrollport of its own, so a
|
||||||
sticky header always has something to stick to rather than sliding up behind
|
sticky header always has something to stick to rather than sliding up behind
|
||||||
the app bar. Same rule as console.css's .tscroll. */
|
the app bar. Same rule as console.css's .tscroll. */
|
||||||
#comments-admin, #audit-admin, #notif-box, #usage-admin, #projects-table, #defmem-table{
|
#comments-admin, #audit-admin, #notif-box, #activity-admin, #projects-table, #defmem-table{
|
||||||
overflow:auto; max-height:min(70vh,640px); overscroll-behavior:contain; }
|
overflow:auto; max-height:min(70vh,640px); overscroll-behavior:contain; }
|
||||||
/* If admin.js wraps its table in its own .tscroll, the outer box steps aside so
|
/* If admin.js wraps its table in its own .tscroll, the outer box steps aside so
|
||||||
one table never ends up with two scrollbars. */
|
one table never ends up with two scrollbars. */
|
||||||
#comments-admin:has(.tscroll), #audit-admin:has(.tscroll), #notif-box:has(.tscroll),
|
#comments-admin:has(.tscroll), #audit-admin:has(.tscroll), #notif-box:has(.tscroll),
|
||||||
#usage-admin:has(.tscroll), #projects-table:has(.tscroll), #defmem-table:has(.tscroll){
|
#activity-admin:has(.tscroll), #projects-table:has(.tscroll), #defmem-table:has(.tscroll){
|
||||||
overflow:visible; max-height:none; }
|
overflow:visible; max-height:none; }
|
||||||
|
|
||||||
/* Comment text and audit detail are the two columns you are actually here to
|
/* Comment text and audit detail are the two columns you are actually here to
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
border:1px solid var(--border-strong); border-radius:0; margin-bottom:var(--s3); }
|
border:1px solid var(--border-strong); border-radius:0; margin-bottom:var(--s3); }
|
||||||
|
|
||||||
@media (max-width:900px){
|
@media (max-width:900px){
|
||||||
#comments-admin, #audit-admin, #notif-box, #usage-admin, #projects-table, #defmem-table{
|
#comments-admin, #audit-admin, #notif-box, #activity-admin, #projects-table, #defmem-table{
|
||||||
max-height:none; }
|
max-height:none; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -175,16 +175,38 @@
|
|||||||
<div id="audit-admin" class="note">Click refresh to load.</div>
|
<div id="audit-admin" class="note">Click refresh to load.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- USAGE LOGS -->
|
<!-- ACTIVITY & USAGE (CR-019) -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Usage logs</h2>
|
<h2>Activity & usage</h2>
|
||||||
<div class="sub">Engagement recorded by both tools — the work package creator and the SOP wizard —
|
<div class="sub">Who is using the suite, which tools, and how often — recorded server-side on every
|
||||||
sessions, actions and counts, with a download per tool (D5). Note: stored locally per browser,
|
sign-in and page open, kept indefinitely. Filter below, or export a CSV: raw (real usernames) for
|
||||||
so this reflects activity on <strong>this</strong> machine.</div>
|
internal use, or sanitized (each user replaced with a stable, non-reversible id) for feeding into
|
||||||
|
Power BI or another external reporting tool without carrying real identities.</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button onclick="loadUsage()">Refresh</button>
|
<label for="act-from">From</label>
|
||||||
|
<input type="date" id="act-from" onchange="loadActivity()">
|
||||||
|
<label for="act-to">To</label>
|
||||||
|
<input type="date" id="act-to" onchange="loadActivity()">
|
||||||
|
<select id="act-project" onchange="loadActivity()"><option value="">All projects</option></select>
|
||||||
|
<input id="act-user" placeholder="Username…" oninput="loadActivity()">
|
||||||
|
<select id="act-tool" onchange="loadActivity()">
|
||||||
|
<option value="">All tools</option>
|
||||||
|
<option value="launcher">Launcher</option>
|
||||||
|
<option value="wizard">SOP wizard</option>
|
||||||
|
<option value="creator">Work package creator</option>
|
||||||
|
<option value="field_view">Field view</option>
|
||||||
|
<option value="admin">Admin console</option>
|
||||||
|
<option value="directory">User directory</option>
|
||||||
|
</select>
|
||||||
|
<button onclick="loadActivity()">Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="usage-admin" class="note">Click refresh to load.</div>
|
<div id="activity-banner" class="banner" style="display:none"></div>
|
||||||
|
<div id="activity-admin" class="note">Loading…</div>
|
||||||
|
<div class="toolbar" style="margin-top:var(--s3)">
|
||||||
|
<button onclick="exportActivity(false)">Download CSV (raw)</button>
|
||||||
|
<button onclick="exportActivity(true)">Download CSV (sanitized)</button>
|
||||||
|
</div>
|
||||||
|
<div id="activity-export-banner" class="banner" style="display:none"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- DB SNAPSHOT -->
|
<!-- DB SNAPSHOT -->
|
||||||
@@ -215,7 +237,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="wp-usage.js"></script>
|
|
||||||
<script src="console-util.js"></script>
|
<script src="console-util.js"></script>
|
||||||
<script src="wp-dialog.js"></script>
|
<script src="wp-dialog.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
|
|||||||
141
html/admin.js
@@ -24,7 +24,7 @@ function reveal(){
|
|||||||
loadNotifications();
|
loadNotifications();
|
||||||
loadComments();
|
loadComments();
|
||||||
loadAudit();
|
loadAudit();
|
||||||
loadUsage();
|
loadActivity();
|
||||||
}
|
}
|
||||||
function showDenied(){
|
function showDenied(){
|
||||||
document.getElementById('admin-denied').style.display='';
|
document.getElementById('admin-denied').style.display='';
|
||||||
@@ -189,6 +189,19 @@ async function loadProjects(){
|
|||||||
banner.style.display='none';
|
banner.style.display='none';
|
||||||
_adminProjects = json;
|
_adminProjects = json;
|
||||||
renderProjects();
|
renderProjects();
|
||||||
|
populateActivityProjectFilter();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The activity project filter reuses the same project list the Projects card
|
||||||
|
// already fetched — no second /api/projects call just to fill a <select>.
|
||||||
|
function populateActivityProjectFilter(){
|
||||||
|
const sel = document.getElementById('act-project');
|
||||||
|
if(!sel) return;
|
||||||
|
const cur = sel.value;
|
||||||
|
sel.innerHTML = '<option value="">All projects</option>' +
|
||||||
|
_adminProjects.slice().sort((a,b)=>String(a.name||'').localeCompare(String(b.name||'')))
|
||||||
|
.map(p => '<option value="'+uesc(p.id)+'">'+uesc(p.name||p.number||p.id)+'</option>').join('');
|
||||||
|
sel.value = cur;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderProjects(){
|
function renderProjects(){
|
||||||
@@ -465,6 +478,90 @@ function renderAudit(){
|
|||||||
'</tr>').join('')+'</tbody></table>';
|
'</tr>').join('')+'</tbody></table>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── activity & usage (CR-019) ────────────────────────────────────────────────────
|
||||||
|
// Server-side, per-user activity — distinct from the "Activity log" card above
|
||||||
|
// (that's AuditLog: business mutations) and from the "Usage logs" card below
|
||||||
|
// (that's per-browser localStorage, D5/T7.10, on the way out per T11.6). This is
|
||||||
|
// /api/usage/summary and /api/usage/export: real rows, aggregated server-side,
|
||||||
|
// filterable by date/project/user/tool, and exportable raw or sanitized.
|
||||||
|
function _activityFilters(){
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
const from = document.getElementById('act-from').value; if(from) q.set('from', from);
|
||||||
|
const to = document.getElementById('act-to').value; if(to) q.set('to', to);
|
||||||
|
const proj = document.getElementById('act-project').value; if(proj) q.set('project_id', proj);
|
||||||
|
const user = (document.getElementById('act-user').value||'').trim(); if(user) q.set('username', user);
|
||||||
|
const tool = document.getElementById('act-tool').value; if(tool) q.set('tool', tool);
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
async function loadActivity(){
|
||||||
|
const banner = document.getElementById('activity-banner');
|
||||||
|
const box = document.getElementById('activity-admin');
|
||||||
|
if(!box) return;
|
||||||
|
banner.style.display='none';
|
||||||
|
box.textContent = 'Loading…';
|
||||||
|
const { status, json } = await api('GET', '/api/usage/summary?'+_activityFilters().toString());
|
||||||
|
if(status===403){
|
||||||
|
banner.className='banner bad'; banner.style.display='';
|
||||||
|
banner.textContent = '✕ Your account can’t see suite-wide activity here — this needs admin, or Project Super User on at least one project.';
|
||||||
|
box.innerHTML=''; return;
|
||||||
|
}
|
||||||
|
if(status!==200 || !json){
|
||||||
|
banner.className='banner bad'; banner.style.display='';
|
||||||
|
banner.textContent = '✕ Could not load activity ('+apiError(status, json, 'load failed')+').';
|
||||||
|
box.innerHTML=''; return;
|
||||||
|
}
|
||||||
|
renderActivity(json);
|
||||||
|
}
|
||||||
|
function renderActivity(sum){
|
||||||
|
const box = document.getElementById('activity-admin');
|
||||||
|
if(!sum.event_count){ box.innerHTML = '<div class="note">No activity matches this filter.</div>'; return; }
|
||||||
|
const fmt = s => s ? wpFormatDateTime(s) : '—';
|
||||||
|
const bucket = (label, obj, take) => {
|
||||||
|
const entries = Object.entries(obj).sort((a,b)=> b[0].localeCompare(a[0])).slice(0, take);
|
||||||
|
if(!entries.length) return '';
|
||||||
|
return '<table class="kv" style="margin-top:8px"><caption style="text-align:left;font-weight:600;margin-bottom:4px">'+
|
||||||
|
uesc(label)+'</caption>'+entries.map(([k,v]) => '<tr><th>'+uesc(k)+'</th><td>'+v+' active user'+(v===1?'':'s')+'</td></tr>').join('')+
|
||||||
|
'</table>';
|
||||||
|
};
|
||||||
|
const perTool = Object.entries(sum.by_tool||{});
|
||||||
|
const toolTable = perTool.length ? '<table class="users"><thead><tr><th>Tool</th><th>Events</th></tr></thead><tbody>'+
|
||||||
|
perTool.map(([t,n]) => '<tr><td>'+uesc(t||'(login)')+'</td><td>'+n+'</td></tr>').join('')+'</tbody></table>' :
|
||||||
|
'<div class="note">No per-tool data.</div>';
|
||||||
|
const perUser = Object.entries(sum.per_user_last_active||{}).sort((a,b)=> String(b[1]).localeCompare(String(a[1])));
|
||||||
|
const userTable = perUser.length ? '<table class="users"><thead><tr><th>User</th><th>Last active</th></tr></thead><tbody>'+
|
||||||
|
perUser.map(([u,t]) => '<tr><td><strong>'+uesc(u)+'</strong></td><td style="color:var(--muted)">'+fmt(t)+'</td></tr>').join('')+
|
||||||
|
'</tbody></table>' : '<div class="note">No per-user data.</div>';
|
||||||
|
box.innerHTML =
|
||||||
|
'<div class="note">'+sum.event_count+' event'+(sum.event_count===1?'':'s')+' matched.</div>'+
|
||||||
|
'<div class="row" style="gap:var(--s4);flex-wrap:wrap;align-items:flex-start">'+
|
||||||
|
'<div>'+bucket('Active users by day', sum.active_users.by_day, 30)+'</div>'+
|
||||||
|
'<div>'+bucket('Active users by week', sum.active_users.by_week, 12)+'</div>'+
|
||||||
|
'<div>'+bucket('Active users by month', sum.active_users.by_month, 12)+'</div>'+
|
||||||
|
'</div>'+
|
||||||
|
'<h2 style="margin-top:16px">By tool</h2>'+toolTable+
|
||||||
|
'<h2 style="margin-top:16px">Per-user last active</h2>'+userTable;
|
||||||
|
}
|
||||||
|
async function exportActivity(sanitize){
|
||||||
|
const banner = document.getElementById('activity-export-banner');
|
||||||
|
banner.className='banner'; banner.style.display=''; banner.textContent='Preparing export…';
|
||||||
|
const q = _activityFilters();
|
||||||
|
if(sanitize) q.set('sanitize', 'true');
|
||||||
|
const { status, json } = await api('GET', '/api/usage/export?'+q.toString());
|
||||||
|
if(status!==200 || typeof json !== 'string'){
|
||||||
|
banner.className='banner bad';
|
||||||
|
banner.textContent = '✕ Export failed ('+apiError(status, json, 'export failed')+').';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = new Blob([json], { type:'text/csv' });
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = 'usage_export_'+(sanitize?'sanitized':'raw')+'_'+new Date().toISOString().slice(0,10)+'.csv';
|
||||||
|
document.body.appendChild(a); a.click(); a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||||
|
banner.className='banner ok';
|
||||||
|
banner.textContent = '✓ Downloaded the '+(sanitize?'sanitized':'raw')+' export.';
|
||||||
|
}
|
||||||
|
|
||||||
// ── notifications / email settings ──────────────────────────────────────────────
|
// ── notifications / email settings ──────────────────────────────────────────────
|
||||||
let _settings = {};
|
let _settings = {};
|
||||||
async function loadSettings(){
|
async function loadSettings(){
|
||||||
@@ -656,48 +753,6 @@ async function loadNotifications(){
|
|||||||
'</tr>').join('')+'</tbody></table>';
|
'</tr>').join('')+'</tbody></table>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── usage logs (read from this browser's localStorage) ──────────────────────────
|
|
||||||
// D5 / T7.10: the report for BOTH tools' recorded usage, in the one place an
|
|
||||||
// operator-facing readout belongs - behind the same admin gate as this whole
|
|
||||||
// page (gateByRole() below shows nothing else either). Data comes from
|
|
||||||
// wp-usage.js, the single implementation; the keys predate the move, so
|
|
||||||
// everything recorded before it is still here.
|
|
||||||
function loadUsage(){
|
|
||||||
const box = document.getElementById('usage-admin');
|
|
||||||
if(!box) return;
|
|
||||||
const tools = [
|
|
||||||
['Work package creator', WPUsage.KEYS.creator, 'wp-iwp-usage'],
|
|
||||||
['SOP wizard', WPUsage.KEYS.wizard, 'wp-suite-usage'],
|
|
||||||
];
|
|
||||||
let html = '';
|
|
||||||
tools.forEach(([label, key, prefix]) => {
|
|
||||||
const evs = (WPUsage.load(key).events) || [];
|
|
||||||
html += '<h2 style="margin-top:16px">' + uesc(label) + '</h2>';
|
|
||||||
if(!evs.length){
|
|
||||||
html += '<div class="note">No usage recorded in this browser yet.</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const byEvent = {}, sessions = new Set();
|
|
||||||
let first = evs[0].ts, last = evs[0].ts;
|
|
||||||
evs.forEach(e => {
|
|
||||||
byEvent[e.event] = (byEvent[e.event]||0)+1;
|
|
||||||
if(e.session) sessions.add(e.session);
|
|
||||||
if(e.ts < first) first = e.ts; if(e.ts > last) last = e.ts;
|
|
||||||
});
|
|
||||||
const fmt = v => v ? wpFormatDateTime(v) : '—';
|
|
||||||
html += '<table class="kv">'+
|
|
||||||
'<tr><th>Sessions</th><td>'+sessions.size+'</td></tr>'+
|
|
||||||
'<tr><th>Events</th><td>'+evs.length+'</td></tr>'+
|
|
||||||
'<tr><th>Range</th><td style="font-weight:600">'+fmt(first)+' → '+fmt(last)+'</td></tr></table>';
|
|
||||||
html += '<table class="users"><thead><tr><th>Event</th><th>Count</th></tr></thead><tbody>';
|
|
||||||
Object.keys(byEvent).sort().forEach(k => html += '<tr><td>'+uesc(k)+'</td><td>'+byEvent[k]+'</td></tr>');
|
|
||||||
html += '</tbody></table>';
|
|
||||||
html += '<div class="toolbar" style="margin-top:8px"><button onclick="WPUsage.download(WPUsage.KEYS.'+
|
|
||||||
(key === WPUsage.KEYS.creator ? 'creator' : 'wizard')+', ' + jsq(prefix) + ')">Download the full event log</button></div>';
|
|
||||||
});
|
|
||||||
box.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── access control: admins only ─────────────────────────────────────────────────
|
// ── access control: admins only ─────────────────────────────────────────────────
|
||||||
// auth-guard.js requires a login and sets window.WP_USER (firing 'wp-auth-ready').
|
// auth-guard.js requires a login and sets window.WP_USER (firing 'wp-auth-ready').
|
||||||
// Show the console for admins; otherwise show the "Admins only" notice.
|
// Show the console for admins; otherwise show the "Admins only" notice.
|
||||||
|
|||||||
@@ -60,6 +60,11 @@
|
|||||||
.then(function () { window.location.replace('login.html'); });
|
.then(function () { window.location.replace('login.html'); });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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 ────────────────────────────────────────────────────
|
// ── permissions helpers ────────────────────────────────────────────────────
|
||||||
// The server enforces all of this; these are for hiding controls the signed-in
|
// The server enforces all of this; these are for hiding controls the signed-in
|
||||||
// user can't use, so nobody clicks a button just to get a 403.
|
// user can't use, so nobody clicks a button just to get a 403.
|
||||||
@@ -114,17 +119,46 @@
|
|||||||
// Admin, Users and Sign out from the navigation drawer, and being one unbreakable
|
// 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
|
// 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
|
// 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
|
// Nothing replaces it. Every signed-in page mounts the drawer, so there is no page
|
||||||
// left that would need a floating fallback pill.
|
// left that would need a floating fallback pill.
|
||||||
|
|
||||||
|
// ── CR-019: usage ping ───────────────────────────────────────────────────
|
||||||
|
// One page_open event per authenticated load, sent from exactly ONE place
|
||||||
|
// (here) rather than from each page's own script - the shared-chrome lesson
|
||||||
|
// S4 and the token-drift lesson S5 both taught this codebase the hard way.
|
||||||
|
// Fire-and-forget: never blocks reveal(), never retries, never surfaces an
|
||||||
|
// error to the person using the app - a missed usage ping is not something
|
||||||
|
// anyone here should notice happening.
|
||||||
|
var TOOL_BY_PAGE = {
|
||||||
|
'index.html': 'launcher',
|
||||||
|
'work-package-suite.html': 'wizard',
|
||||||
|
'wp-creation-index.html': 'creator',
|
||||||
|
'field.html': 'field_view',
|
||||||
|
'admin.html': 'admin',
|
||||||
|
'users.html': 'directory'
|
||||||
|
};
|
||||||
|
function pingUsage() {
|
||||||
|
var page = (location.pathname.split('/').pop() || 'index.html');
|
||||||
|
var tool = TOOL_BY_PAGE[page] || page.replace(/\.html$/, '');
|
||||||
|
try {
|
||||||
|
fetch('/api/usage/ping', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ tool: tool })
|
||||||
|
}).catch(function () {});
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
function proceed(user) {
|
function proceed(user) {
|
||||||
clearTimeout(safety);
|
clearTimeout(safety);
|
||||||
window.WP_USER = user;
|
window.WP_USER = user;
|
||||||
reveal();
|
reveal();
|
||||||
if (window.WP_USER) {
|
if (window.WP_USER) {
|
||||||
window.wpFlags(); // start the feature-flag fetch; pages await it as needed
|
window.wpFlags(); // start the feature-flag fetch; pages await it as needed
|
||||||
|
pingUsage();
|
||||||
try { document.dispatchEvent(new CustomEvent('wp-auth-ready', { detail: window.WP_USER })); } catch (e) {}
|
try { document.dispatchEvent(new CustomEvent('wp-auth-ready', { detail: window.WP_USER })); } catch (e) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,34 +31,23 @@
|
|||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
.brand img { height: 36px; width: auto; }
|
.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; }
|
h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
|
||||||
.sub { color: var(--cds-text-secondary); font-size: 0.875rem; margin-bottom: 1.75rem; }
|
.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; }
|
.btn {
|
||||||
.field { margin-bottom: 1.25rem; }
|
display: block;
|
||||||
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 {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.875rem 1rem;
|
padding: 0.875rem 1rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
color: var(--cds-text-on-color);
|
color: var(--cds-text-on-color);
|
||||||
background: var(--cds-button-primary);
|
background: var(--cds-button-primary);
|
||||||
border: none;
|
border: none;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
button:hover:not(:disabled) { background: var(--cds-hover-primary); }
|
.btn:hover { background: var(--cds-hover-primary); }
|
||||||
button:disabled { background: var(--cds-disabled-02); cursor: not-allowed; }
|
.btn:focus-visible { outline: 2px solid var(--cds-focus); outline-offset: 2px; }
|
||||||
.error {
|
.error {
|
||||||
display: none;
|
display: none;
|
||||||
background: var(--wp-status-error-bg);
|
background: var(--wp-status-error-bg);
|
||||||
@@ -69,7 +58,6 @@
|
|||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
.error.show { display: block; }
|
.error.show { display: block; }
|
||||||
.foot { margin-top: 1.5rem; font-size: 0.75rem; color: var(--cds-text-helper); text-align: center; }
|
|
||||||
.ok {
|
.ok {
|
||||||
display: none;
|
display: none;
|
||||||
background: var(--wp-status-success-bg);
|
background: var(--wp-status-success-bg);
|
||||||
@@ -80,15 +68,7 @@
|
|||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
.ok.show { display: block; }
|
.ok.show { display: block; }
|
||||||
.note {
|
.foot { margin-top: 1.5rem; font-size: 0.75rem; color: var(--cds-text-helper); text-align: center; }
|
||||||
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; }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -99,30 +79,10 @@
|
|||||||
<div id="error" class="error" role="alert"></div>
|
<div id="error" class="error" role="alert"></div>
|
||||||
<div id="ok" class="ok" role="status"></div>
|
<div id="ok" class="ok" role="status"></div>
|
||||||
|
|
||||||
<!-- SIGN IN -->
|
<h1>Sign in</h1>
|
||||||
<section id="view-login">
|
<p class="sub">Work Package Suite uses your organization's Okta sign-in. Select the
|
||||||
<h1>Sign in</h1>
|
button below and follow the prompts there.</p>
|
||||||
<p class="sub">Work Package Suite</p>
|
<a id="okta-signin" class="btn" href="/api/auth/okta/login" autofocus>Sign in with Okta</a>
|
||||||
<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>
|
|
||||||
<div class="hint">Use your Windows password — the same one you use to sign in to your computer.</div>
|
|
||||||
<button id="submit" type="submit">Sign in</button>
|
|
||||||
</form>
|
|
||||||
<!-- D13: there is no app password to reset. Self-service goes to Okta.
|
|
||||||
A plain external link, not a form post — CSP sets form-action 'self'
|
|
||||||
and does not set navigate-to, so link navigation off-origin is allowed.
|
|
||||||
rel="noopener noreferrer" because target="_blank" without it hands the
|
|
||||||
opened page a window.opener handle back to this one. -->
|
|
||||||
<p class="center"><a href="https://primecontrols.okta.com/" id="forgot-link" class="link"
|
|
||||||
target="_blank" rel="noopener noreferrer">Forgot password?</a></p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<p class="foot">Authorized use only · BTG / Pilot</p>
|
<p class="foot">Authorized use only · BTG / Pilot</p>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
/* Login page logic for the Work Package Suite.
|
/* Login page logic for the Work Package Suite.
|
||||||
|
|
||||||
One view. Sign in posts to /api/auth/login, the server authenticates by binding
|
One action: sign in with Okta. There is no local password anymore (D15/D16,
|
||||||
to the domain over LDAPS (D13), and on success sets an HttpOnly session cookie —
|
T10.4) — this page's only job is building the link to /api/auth/okta/login
|
||||||
not readable from here, which is the point — after which we redirect to ?next=
|
(carrying ?next=, if there was one) and showing a plain-language message for
|
||||||
or the home page. The password entered is the person's WINDOWS password.
|
the failure states server/app.py's okta_callback() sends back here (T10.5). */
|
||||||
|
|
||||||
There is no forgot-password flow and no reset view: the suite holds no password
|
|
||||||
to reset. "Forgot password?" is a plain external link to Okta in login.html, so
|
|
||||||
there is deliberately no click handler for it here — one that called
|
|
||||||
preventDefault() would swallow the navigation. */
|
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var errorBox = document.getElementById('error');
|
var errorBox = document.getElementById('error');
|
||||||
var okBox = document.getElementById('ok');
|
var okBox = document.getElementById('ok');
|
||||||
|
var signinLink = document.getElementById('okta-signin');
|
||||||
|
|
||||||
function byId(id) { return document.getElementById(id); }
|
function byId(id) { return document.getElementById(id); }
|
||||||
|
|
||||||
@@ -22,73 +18,38 @@
|
|||||||
errorBox.textContent = msg;
|
errorBox.textContent = msg;
|
||||||
errorBox.classList.add('show');
|
errorBox.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
|
// Same-site path only — mirrors the check server/app.py's _safe_next_path()
|
||||||
// path, otherwise the home page. (Reject absolute/scheme URLs to avoid an
|
// makes again on the way back, so a crafted ?next= can't become an open
|
||||||
// open-redirect.)
|
// redirect even if this client-side check were somehow bypassed.
|
||||||
function nextTarget() {
|
function safeNext() {
|
||||||
try {
|
try {
|
||||||
var next = new URLSearchParams(location.search).get('next') || '';
|
var next = new URLSearchParams(location.search).get('next') || '';
|
||||||
if (next && next.charAt(0) === '/' && next.charAt(1) !== '/') return next;
|
if (next && next.charAt(0) === '/' && next.charAt(1) !== '/') return next;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
return 'index.html';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function postJson(url, payload) {
|
if (signinLink) {
|
||||||
return fetch(url, {
|
var next = safeNext();
|
||||||
method: 'POST',
|
if (next) signinLink.href = '/api/auth/okta/login?next=' + encodeURIComponent(next);
|
||||||
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 };
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function detail(res, fallback) {
|
var ERROR_MESSAGES = {
|
||||||
var d = res && res.json && res.json.detail;
|
disabled: 'Your account has been disabled. Contact an administrator.',
|
||||||
return (typeof d === 'string' && d) ? d : fallback;
|
cancelled: 'Sign-in was not completed. Select the button below to try again.'
|
||||||
}
|
};
|
||||||
|
|
||||||
// ── sign in ────────────────────────────────────────────────────────────────
|
|
||||||
var form = byId('login-form');
|
|
||||||
var submitBtn = byId('submit');
|
|
||||||
// Guarded: an unguarded addEventListener on null would break sign-in itself if a
|
|
||||||
// cached older login.html were served.
|
|
||||||
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.'));
|
|
||||||
// 503 means the directory is unreachable or misconfigured — OUR fault, not a
|
|
||||||
// wrong password. Saying so stops people hunting for a password they no
|
|
||||||
// longer have while a deploy is broken.
|
|
||||||
else if (res.status === 503) showError(detail(res, 'Sign-in is temporarily unavailable. Contact IT.'));
|
|
||||||
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';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
(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,8 +26,6 @@
|
|||||||
room for "Assistant Project Manager" without pushing Actions off screen. */
|
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-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); }
|
#users-banner:not(:empty), #scope-banner:not(:empty){ margin-bottom:var(--s3); }
|
||||||
/* The create form is a lot of fields; let them wrap and
|
|
||||||
let the project picker take a full row of its own. */
|
|
||||||
#nu-projects{ margin-top:var(--s2); }
|
#nu-projects{ margin-top:var(--s2); }
|
||||||
#nu-projects .pickrow{ padding:var(--s1) var(--s1); }
|
#nu-projects .pickrow{ padding:var(--s1) var(--s1); }
|
||||||
/* A manager with one project doesn't need a scrolling picker; a manager with
|
/* A manager with one project doesn't need a scrolling picker; a manager with
|
||||||
@@ -82,7 +80,8 @@
|
|||||||
<h2>Add a user</h2>
|
<h2>Add a user</h2>
|
||||||
<div class="sub" id="create-sub"></div>
|
<div class="sub" id="create-sub"></div>
|
||||||
<div class="urow">
|
<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-fullname" placeholder="Full name" autocomplete="off">
|
||||||
<input id="nu-email" placeholder="Email" 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-role" title="Permissions — what this account may do"></select>
|
||||||
|
|||||||
@@ -186,9 +186,6 @@ function managerRow(u){
|
|||||||
if(can && !me) actions.push('<button class="mini" onclick="toggleActive(\''+uid+'\','+(!u.is_active)+')">'+
|
if(can && !me) actions.push('<button class="mini" onclick="toggleActive(\''+uid+'\','+(!u.is_active)+')">'+
|
||||||
(u.is_active?'Disable':'Enable')+'</button>');
|
(u.is_active?'Disable':'Enable')+'</button>');
|
||||||
if(can && !me) actions.push('<button class="mini danger" onclick="deleteUser(\''+uid+'\',\''+uname+'\')">Delete</button>');
|
if(can && !me) actions.push('<button class="mini danger" onclick="deleteUser(\''+uid+'\',\''+uname+'\')">Delete</button>');
|
||||||
// D13: no self-service action left on your own row — the domain owns the password
|
|
||||||
// and role changes are never self-applied.
|
|
||||||
if(me) actions.push('<span class="note" style="margin:0" title="Your own account">you</span>');
|
|
||||||
if(!can && !me) actions.push('<span class="note" style="margin:0" title="'+uesc(why)+'">read-only</span>');
|
if(!can && !me) actions.push('<span class="note" style="margin:0" title="'+uesc(why)+'">read-only</span>');
|
||||||
|
|
||||||
return '<tr'+(can||me ? '' : ' class="is-locked"')+'>'+
|
return '<tr'+(can||me ? '' : ' class="is-locked"')+'>'+
|
||||||
@@ -332,6 +329,10 @@ function renderCreateForm(){
|
|||||||
async function createUser(){
|
async function createUser(){
|
||||||
const msg = document.getElementById('users-create-msg');
|
const msg = document.getElementById('users-create-msg');
|
||||||
const val = id => (document.getElementById(id)||{}).value || '';
|
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 username = val('nu-username').trim();
|
||||||
const project_ids = [...document.querySelectorAll('#nu-project-list input[type=checkbox]:checked')]
|
const project_ids = [...document.querySelectorAll('#nu-project-list input[type=checkbox]:checked')]
|
||||||
.map(c => c.value);
|
.map(c => c.value);
|
||||||
|
|||||||
@@ -2207,20 +2207,18 @@ function loadStepComments(){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── USAGE ANALYTICS ─────────────────────────────────────────────────────────
|
// ── USAGE ANALYTICS (retired, T11.6) ────────────────────────────────────────
|
||||||
// Lightweight usage analytics stored in localStorage so the tool owner can review
|
// This used to write to wp-usage.js's per-browser localStorage log (D5/T7.10),
|
||||||
// engagement over time. No field VALUES are stored (field-edit events record only
|
// read back by the admin console's old "Usage logs" panel. CR-019 replaced
|
||||||
// the field id), keeping captured data non-sensitive.
|
// both with real, server-side, per-user activity (UsageEvent + the Activity &
|
||||||
// D5 / T7.10: the analytics implementation lives in wp-usage.js and the report
|
// usage card) - see decisions-2026-09-17.md for why this per-browser data was
|
||||||
// on the admin console. The wizard's own copy of showAnalytics() never had a
|
// never a source the new report could adopt. track() is now a no-op; kept
|
||||||
// caller here - the button lived on the creator - and once B7 dissolved the
|
// (rather than deleting its handful of call sites, including the dwell-timer
|
||||||
// frame the duplicate sat in the same document as five colliding globals.
|
// plumbing below) so this stays a one-line change instead of touching every
|
||||||
// This page only records; dwell tracking keeps its page-local state below.
|
// caller for the same outcome.
|
||||||
let _stepEnter = Date.now();
|
let _stepEnter = Date.now();
|
||||||
|
|
||||||
function track(event, detail){
|
function track(event, detail){ /* retired, T11.6 — see comment above */ }
|
||||||
WPUsage.track(WPUsage.KEYS.wizard, event, detail);
|
|
||||||
}
|
|
||||||
function trackStepDwell(){
|
function trackStepDwell(){
|
||||||
const ms = Date.now() - _stepEnter;
|
const ms = Date.now() - _stepEnter;
|
||||||
if(ms > 400 && ms < 1000*60*60) track('step_dwell', {step: currentStep, ms});
|
if(ms > 400 && ms < 1000*60*60) track('step_dwell', {step: currentStep, ms});
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
|
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
|
||||||
during their own boot. -->
|
during their own boot. -->
|
||||||
<script src="wp-url.js"></script>
|
<script src="wp-url.js"></script>
|
||||||
<script src="wp-usage.js"></script>
|
|
||||||
<script src="wp-list-import.js"></script>
|
<script src="wp-list-import.js"></script>
|
||||||
<!-- Autosave, unsaved-work guard, draft recovery (S2). -->
|
<!-- Autosave, unsaved-work guard, draft recovery (S2). -->
|
||||||
<script src="wp-autosave.js"></script>
|
<script src="wp-autosave.js"></script>
|
||||||
|
|||||||
@@ -3993,11 +3993,17 @@ function openSopModal(){
|
|||||||
document.getElementById('sop-modal').classList.add('open'); track('view_sop');
|
document.getElementById('sop-modal').classList.add('open'); track('view_sop');
|
||||||
}
|
}
|
||||||
function closeSopModal(){ document.getElementById('sop-modal').classList.remove('open'); }
|
function closeSopModal(){ document.getElementById('sop-modal').classList.remove('open'); }
|
||||||
// D5 / T7.10: the analytics implementation lives in wp-usage.js - ONE copy for
|
// CR-019 / T11.6 (2026-09-23): this used to write to wp-usage.js's per-browser
|
||||||
// the whole suite - and its report lives on the admin console, where an
|
// localStorage log (D5/T7.10). Retired along with the admin console's old
|
||||||
// operator-facing readout belongs. This page only records. Same key, same
|
// "Usage logs" panel, its only reader - real, server-side, per-user activity
|
||||||
// event shape: everything recorded before the move is still readable after it.
|
// now exists (UsageEvent, the Activity & usage card, T11.1-T11.5) and this
|
||||||
function track(event,detail){ if(devMode) return; WPUsage.track(WPUsage.KEYS.creator, event, detail); }
|
// data was never reliably tied to a real identity anyway, so it was not a
|
||||||
|
// source the new report could adopt (decisions-2026-09-17.md). track() stays
|
||||||
|
// as a no-op rather than deleting its ~35 call sites throughout this file:
|
||||||
|
// removing every call individually is a much larger, riskier diff for the
|
||||||
|
// same outcome, and a call site here still documents the moment worth
|
||||||
|
// recording if usage analytics are ever rebuilt server-side.
|
||||||
|
function track(event,detail){ /* retired, T11.6 — see comment above */ }
|
||||||
|
|
||||||
// ── COMMENTS ─────────────────────────────────────────────────────────────────
|
// ── COMMENTS ─────────────────────────────────────────────────────────────────
|
||||||
const COMMENTS_KEY='wp_iwp_comments_v1';
|
const COMMENTS_KEY='wp_iwp_comments_v1';
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
|
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
|
||||||
during their own boot. -->
|
during their own boot. -->
|
||||||
<script src="wp-url.js"></script>
|
<script src="wp-url.js"></script>
|
||||||
<script src="wp-usage.js"></script>
|
|
||||||
<!-- Autosave, unsaved-work guard, draft recovery (S2). -->
|
<!-- Autosave, unsaved-work guard, draft recovery (S2). -->
|
||||||
<script src="wp-autosave.js"></script>
|
<script src="wp-autosave.js"></script>
|
||||||
<!-- Which sections this project uses (CR-006). The same file the SOP wizard
|
<!-- Which sections this project uses (CR-006). The same file the SOP wizard
|
||||||
|
|||||||
@@ -105,6 +105,18 @@
|
|||||||
say(bits.join(''), problem);
|
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) {
|
function importText(dryRun) {
|
||||||
var text = (el(p + '-paste') || {}).value || '';
|
var text = (el(p + '-paste') || {}).value || '';
|
||||||
if (!text.trim()) { say('Paste some rows or choose a CSV file first.', true); return; }
|
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' },
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||||
body: JSON.stringify({ text: text, dry_run: !!dryRun }),
|
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) {
|
.then(function (res) {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
say('⚠ Import refused — ' + esc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);
|
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' },
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||||
body: JSON.stringify(read.payload),
|
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) {
|
.then(function (res) {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setAddError((res.body && res.body.detail) || ('Could not add it (HTTP ' + res.status + ')'));
|
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' },
|
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||||
body: JSON.stringify(patchBody),
|
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) {
|
.then(function (res) {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
say('⚠ ' + esc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);
|
say('⚠ ' + esc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
/* Usage analytics core — the ONE implementation (D5 / T7.10).
|
|
||||||
|
|
||||||
This existed three times: the creator's copy, the wizard's copy (which had no
|
|
||||||
caller — the button lived on the creator), and the admin console's own reader.
|
|
||||||
Once the creator stopped being an iframe (B7/T7.1) the first two sat in one
|
|
||||||
document as five colliding globals; an unreferenced duplicate is exactly what
|
|
||||||
produced D5. One core now; the pages keep only a thin track() wrapper because
|
|
||||||
page state (the creator's dev-mode pause) belongs to the page.
|
|
||||||
|
|
||||||
The storage KEYS are unchanged on purpose: everything recorded before this
|
|
||||||
file existed is still readable through it. No field VALUES are ever stored —
|
|
||||||
a field-edit event records the field id, nothing else.
|
|
||||||
|
|
||||||
Classic script, no modules: exposes window.WPUsage. */
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
(function () {
|
|
||||||
var SESSION = 's_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
||||||
|
|
||||||
function load(key) {
|
|
||||||
try { return JSON.parse(localStorage.getItem(key)) || { events: [] }; }
|
|
||||||
catch (e) { return { events: [] }; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function save(key, data) {
|
|
||||||
try { localStorage.setItem(key, JSON.stringify(data)); }
|
|
||||||
catch (e) { /* storage unavailable — degrade silently */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
function track(key, event, detail) {
|
|
||||||
try {
|
|
||||||
var d = load(key);
|
|
||||||
d.events.push({ ts: new Date().toISOString(), session: SESSION, event: event, detail: detail || null });
|
|
||||||
if (d.events.length > 5000) d.events = d.events.slice(-5000);
|
|
||||||
save(key, d);
|
|
||||||
} catch (e) { /* never let telemetry break the tool it watches */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
function download(key, prefix) {
|
|
||||||
var blob = new Blob([JSON.stringify(load(key), null, 2)], { type: 'application/json' });
|
|
||||||
var a = document.createElement('a');
|
|
||||||
a.href = URL.createObjectURL(blob);
|
|
||||||
a.download = (prefix || 'wp-usage') + '-' + new Date().toISOString().slice(0, 10) + '.json';
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
a.remove();
|
|
||||||
setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.WPUsage = {
|
|
||||||
load: load,
|
|
||||||
save: save,
|
|
||||||
track: track,
|
|
||||||
download: download,
|
|
||||||
// The pre-D5 keys, verbatim — continuity of the recorded data is a done-when.
|
|
||||||
KEYS: { creator: 'wp_iwp_analytics_v1', wizard: 'wp_suite_analytics_v1' },
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@@ -12,48 +12,58 @@ DATABASE_URL=postgresql+psycopg://wpsuite:CHANGE_ME@localhost:5432/wpsuite
|
|||||||
# CORS_ORIGINS=http://localhost:5500
|
# CORS_ORIGINS=http://localhost:5500
|
||||||
|
|
||||||
# ── Authentication ────────────────────────────────────────────────────────────
|
# ── Authentication ────────────────────────────────────────────────────────────
|
||||||
# Secret used to sign session cookies (JWTs). REQUIRED in production: if unset,
|
# There is no local password (D15/D16) — Okta OIDC is the only way in. Sign-in
|
||||||
# the API falls back to a random per-process key, so logins reset on every
|
# still ends the same way it always did: a signed JWT in an HttpOnly session
|
||||||
# restart and break across multiple gunicorn workers. Generate a strong one:
|
# cookie, which is what the four vars right below this line are for. The five
|
||||||
|
# OKTA_* vars after that are what makes the actual sign-in possible; without
|
||||||
|
# them the API starts (this is not a hard failure like AUTH_SECRET_KEY), but
|
||||||
|
# describe()'s startup log line says so and nobody can sign in.
|
||||||
|
|
||||||
|
# Secret used to sign session cookies (JWTs), AFTER Okta has confirmed who
|
||||||
|
# someone is — this app still decides roles/authorization locally, unchanged
|
||||||
|
# by Okta (see server/okta_auth.py). REQUIRED in production: if unset, the API
|
||||||
|
# falls back to a random per-process key, so logins reset on every restart and
|
||||||
|
# break across multiple gunicorn workers. Generate a strong one:
|
||||||
# python -c "import secrets; print(secrets.token_urlsafe(48))"
|
# python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||||
AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
|
AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
|
||||||
|
|
||||||
# How long a login lasts before re-authentication (hours). Default 12.
|
# D18 (2026-09-23): a session slides on activity, capped by a hard ceiling
|
||||||
# AUTH_SESSION_HOURS=12
|
# underneath - not one flat lifetime. Both defaults are proposed, not
|
||||||
|
# confirmed against this tenant's actual Okta SSO session policy - if Okta's
|
||||||
# ── Domain authentication, D13 (REQUIRED — there is no fallback) ───────────────
|
# own session outlives either number, re-auth here is likely a fast redirect
|
||||||
# The suite stores no passwords. Sign-in is an LDAPS simple bind against the
|
# rather than a real login screen, so these cost less than they look like.
|
||||||
# domain, so if this is misconfigured NOBODY CAN SIGN IN, admins included. There
|
|
||||||
# is deliberately no local break-glass account (decided Aug 21 2026 — see
|
|
||||||
# docs/waves/decisions-2026-08-21.md). Check `docker compose logs api` on startup:
|
|
||||||
# the API logs one line saying whether LDAP is configured and reachable.
|
|
||||||
#
|
#
|
||||||
# Connect to the DOMAIN NAME, never a DC hostname and never an IP. Every DC's
|
# No request for this many minutes ends the session outright.
|
||||||
# certificate carries `prime.local` in its SAN, so the domain name both passes
|
# AUTH_IDLE_MINUTES=30
|
||||||
# hostname validation and round-robins across all six DCs. An IP fails with
|
#
|
||||||
# `hostname mismatch` (there is no IP SAN) and the only way to force it through is
|
# The absolute ceiling from original sign-in, regardless of activity - no
|
||||||
# to disable validation, which must not happen — domain passwords cross this link.
|
# session outlives this no matter how continuously active it is. Default 8.
|
||||||
# LDAP_DOMAIN=prime.local
|
# AUTH_SESSION_HOURS=8
|
||||||
# LDAP_HOST=prime.local
|
|
||||||
# LDAP_PORT=636
|
|
||||||
|
|
||||||
# Trust anchor: PRIME CONTROLS ROOT CA + PRIME CONTROLS ISSUING CA 1 as a PEM
|
# ── Okta OIDC (required — this is the only sign-in path) ───────────────────────
|
||||||
# bundle. These are PUBLIC certificates — no private key, nothing issued to this
|
# The Okta *authorization server* issuer, e.g. https://yourorg.okta.com/oauth2/default
|
||||||
# app, nothing to request from IT. The repo ships a verified copy and the default
|
# or a custom authorization server URL. The API discovers the authorize/token/
|
||||||
# points at it, so you only set this to override with a mounted file.
|
# jwks endpoints from <OKTA_ISSUER>/.well-known/openid-configuration — nothing
|
||||||
# LDAP_CA_FILE=/app/server/certs/prime-ca-chain.pem
|
# else about Okta's endpoints is hand-entered.
|
||||||
|
OKTA_ISSUER=https://your-org.okta.com/oauth2/default
|
||||||
|
|
||||||
# An AD group required to sign in. Empty means every domain account may sign in.
|
# Client ID and secret from the Okta app integration (Sign-in method: OIDC -
|
||||||
# This is the INITIAL value and the fallback; the live value is set in the Admin
|
# Authorization Code, Application type: Web Application). The secret is exactly
|
||||||
# console, which refuses to save a group that does not resolve or that the saving
|
# that — treat it like AUTH_SECRET_KEY, never commit it.
|
||||||
# admin is not a member of. Nested groups count.
|
OKTA_CLIENT_ID=CHANGE_ME
|
||||||
# LDAP_REQUIRED_GROUP=WP-Suite-Users
|
OKTA_CLIENT_SECRET=CHANGE_ME
|
||||||
|
|
||||||
# Bind/connect timeout, and how many extra CONNECT attempts to make. Retries never
|
# Must exactly match a "Sign-in redirect URI" registered on the Okta app
|
||||||
# apply to a rejected password — each failed bind counts against the domain lockout
|
# integration, scheme and path included, e.g.:
|
||||||
# policy, so guessing would lock real accounts out of Windows.
|
# https://wp-suite.company.local/api/auth/okta/callback
|
||||||
# LDAP_TIMEOUT_SECONDS=8
|
OKTA_REDIRECT_URI=CHANGE_ME
|
||||||
# LDAP_CONNECT_RETRIES=2
|
|
||||||
|
# Which ID token claim carries this person's directory identity, matched
|
||||||
|
# against the local users.username column (server/app.py's okta_callback()).
|
||||||
|
# preferred_username is Okta's usual default for an AD-imported user; override
|
||||||
|
# it if your security team's Okta configuration uses a different claim (upn,
|
||||||
|
# a custom claim, …) — no code change needed, just this value.
|
||||||
|
# OKTA_IDENTITY_CLAIM=preferred_username
|
||||||
|
|
||||||
# ── Email notifications (optional) ─────────────────────────────────────────────
|
# ── Email notifications (optional) ─────────────────────────────────────────────
|
||||||
# WP-assignment emails are OFF by default and are turned on from the Admin
|
# WP-assignment emails are OFF by default and are turned on from the Admin
|
||||||
|
|||||||
148
server/README.md
@@ -14,12 +14,12 @@ browser → NGINX ──serves──> static site (index.html, …)
|
|||||||
| Method | Path | Purpose |
|
| Method | Path | Purpose |
|
||||||
|--------|------|---------|
|
|--------|------|---------|
|
||||||
| GET | `/api/health` | liveness check (unauthenticated) |
|
| GET | `/api/health` | liveness check (unauthenticated) |
|
||||||
| POST | `/api/auth/login` | sign in (`{username, password}`) — binds against the domain, sets the session cookie |
|
| GET | `/api/auth/okta/login` | redirects the browser to Okta's authorize endpoint (`?next=` optional) |
|
||||||
|
| GET | `/api/auth/okta/callback` | Okta redirects back here with the auth code; signs the person in |
|
||||||
| POST | `/api/auth/logout` | clear the session cookie |
|
| POST | `/api/auth/logout` | clear the session cookie |
|
||||||
| GET | `/api/auth/me` | the logged-in user |
|
| GET | `/api/auth/me` | the logged-in user |
|
||||||
| GET | `/api/auth/users` | list accounts (**admin**) |
|
| GET | `/api/auth/users` | list accounts (**admin**) |
|
||||||
| POST | `/api/auth/users` | pre-create an account (**admin**) — optional; accounts self-provision on first sign-in |
|
| POST | `/api/auth/users` | pre-create an account by username (**admin**) |
|
||||||
| POST | `/api/auth/users/{id}/role` | change an account's permissions role (**admin**) |
|
|
||||||
| DELETE | `/api/auth/users/{id}` | delete an account (**admin**) |
|
| DELETE | `/api/auth/users/{id}` | delete an account (**admin**) |
|
||||||
| POST | `/api/sops` | create/update a SOP (upsert by `id`) |
|
| POST | `/api/sops` | create/update a SOP (upsert by `id`) |
|
||||||
| GET | `/api/sops` | list SOP summaries |
|
| GET | `/api/sops` | list SOP summaries |
|
||||||
@@ -40,99 +40,81 @@ fields (name, number, status, …) are promoted to columns for listing/filtering
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sign-in (domain authentication, D13)
|
## Sign-in (Okta)
|
||||||
|
|
||||||
**The suite stores no passwords.** Signing in performs an LDAPS **simple bind** to
|
There is no local password anywhere in this app (D15/D16) — Okta OIDC is the
|
||||||
`ldaps://prime.local:636` as `<sAMAccountName>@prime.local` using the password the
|
only way in. `login.html` is a single "Sign in with Okta" button; the actual
|
||||||
person typed — their Windows password. A successful bind is the authentication.
|
exchange is `server/okta_auth.py` (the Okta client config) and the two routes
|
||||||
See `server/ldap_auth.py`; the schema has no `password_hash` column.
|
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 issues a signed JWT
|
Sign-in still ends the same way it always did: a signed JWT in an **HttpOnly,
|
||||||
that rides in an **HttpOnly, SameSite=Lax** cookie (`wp_session`); the cookie is
|
SameSite=Lax** cookie (`wp_session`), marked **Secure** automatically whenever
|
||||||
marked **Secure** automatically whenever the request arrives over HTTPS (via
|
the request arrives over HTTPS (via NGINX's `X-Forwarded-Proto`). There is no
|
||||||
NGINX's `X-Forwarded-Proto`). There is no server-side session store — each
|
server-side session store — each request is validated by checking the cookie's
|
||||||
request is validated by checking the cookie's signature and expiry.
|
signature and expiry. Okta only confirms *who* someone is; this app still
|
||||||
|
decides *what* they may do — roles, project membership, everything below stays
|
||||||
|
local and unchanged by Okta.
|
||||||
|
|
||||||
**The real security boundary is the API:** every `/api/` data route is refused
|
**The real security boundary is the API:** every `/api/` data route is refused
|
||||||
with `401` unless a valid session cookie is present (see `auth_gate` in
|
with `401` unless a valid session cookie is present (see `auth_gate` in
|
||||||
`app.py`). The static pages additionally include `auth-guard.js`, which redirects
|
`app.py`). The static pages additionally include `auth-guard.js`, which redirects
|
||||||
to `login.html` when there's no session — that's for UX, not protection.
|
to `login.html` when there's no session — that's for UX, not protection.
|
||||||
|
|
||||||
**The directory supplies identity; this app supplies authorization.** Roles live in
|
**Access gating is Okta's job, not this app's.** Only accounts assigned to the
|
||||||
the local `users` table and are never read from AD — so an existing admin stays an
|
app integration in Okta can complete the sign-in flow at all, so there is no
|
||||||
admin. Roles are `admin`, `project_super_user`, `project_admin`, `project_user`.
|
required-group or claim check layered on top here. Once Okta lets someone
|
||||||
|
through, this app decides their role — see below.
|
||||||
|
|
||||||
**Accounts are created on first successful sign-in.** Anyone who binds successfully
|
Roles are `admin`, `project_super_user`, `project_admin`, `project_user`
|
||||||
and is in the required group gets a `users` row at `project_user` with **no project
|
(`html/users.js`, enforced server-side).
|
||||||
access** — they can sign in and will see nothing until an admin grants access. That
|
|
||||||
is least privilege, and it is deliberate; the creation is written to the audit log
|
|
||||||
so it is visible rather than silent.
|
|
||||||
|
|
||||||
**A required AD group gates sign-in.** `LDAP_REQUIRED_GROUP` (a group name or a full
|
### Set the signing secret and the Okta app integration
|
||||||
DN; nested groups count). Empty means any domain account may sign in.
|
|
||||||
|
|
||||||
**There is no password reset and no break-glass.** The login page links to
|
Add `AUTH_SECRET_KEY` and the five `OKTA_*` variables to `.env` — see
|
||||||
`https://primecontrols.okta.com/` for password self-service. If the domain is
|
`.env.example` for what each one is and where it comes from. `AUTH_SECRET_KEY`
|
||||||
unreachable, or `LDAP_CA_FILE` is wrong, or the required group is misconfigured,
|
is **required in production**: without it the API uses a random per-process
|
||||||
**nobody can sign in, including admins** — the API logs one line at startup saying
|
key, so logins reset on restart. The `OKTA_*` variables are not a hard-fail the
|
||||||
whether LDAP is configured and reachable, so check `docker compose logs api` first.
|
same way — the API starts without them, it just refuses every sign-in and says
|
||||||
|
so in the startup log (`okta_auth.describe()`).
|
||||||
**Connect to the domain name, never a DC hostname or an IP.** Every DC certificate
|
|
||||||
carries `prime.local` in its SAN, so the domain name both passes hostname validation
|
|
||||||
and round-robins across all six DCs. An IP fails with `hostname mismatch` — there is
|
|
||||||
no IP SAN — and the only way to force it through is to disable validation, which
|
|
||||||
must never happen: domain passwords cross this link.
|
|
||||||
|
|
||||||
**The trust anchor is a CA certificate, not one issued to this app.** The API is the
|
|
||||||
TLS *client*, and clients present nothing. `server/certs/prime-ca-chain.pem` holds
|
|
||||||
`PRIME CONTROLS ROOT CA` + `PRIME CONTROLS ISSUING CA 1` — public certificates, no
|
|
||||||
private key, nothing to request from IT. Override the path with `LDAP_CA_FILE`.
|
|
||||||
|
|
||||||
Diagnose the connection without touching an account (no bind, so it cannot
|
|
||||||
contribute to a lockout):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec api openssl s_client -connect prime.local:636 -CAfile /app/server/certs/prime-ca-chain.pem </dev/null 2>&1 | grep "Verify return"
|
|
||||||
# want: Verify return code: 0 (ok)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Set the signing secret
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Bootstrap the first admin
|
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).
|
||||||
|
|
||||||
Two steps, in this order. There is no `create-admin` any more — there is no password
|
### Create the first admin
|
||||||
to set and no account to create.
|
|
||||||
|
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
|
```bash
|
||||||
# 1. Sign in to the app once. That provisions your account at project_user.
|
python -m server.manage_users promote alice --role admin
|
||||||
# 2. Promote it:
|
|
||||||
docker compose exec api python -m server.manage_users promote alice
|
|
||||||
```
|
```
|
||||||
|
|
||||||
It prompts for **your** domain username and password, binds to confirm who you are,
|
In Docker:
|
||||||
and prints `alice: project_user -> admin`.
|
|
||||||
|
|
||||||
Other commands: `list`, `promote <user> [--role …]`, `demote <user>`,
|
```bash
|
||||||
`disable <user>`, `enable <user>`. After that, admins manage accounts from the Admin
|
docker compose exec api python -m server.manage_users promote alice --role admin
|
||||||
console.
|
```
|
||||||
|
|
||||||
**Every command that changes anything requires a domain bind** (D14), prompted —
|
Other commands: `list`, `disable <user>`, `enable <user>`. After the first
|
||||||
there is deliberately no `--password` flag, which would put a live domain password
|
admin exists, they can promote others through the User Directory page (or keep
|
||||||
into shell history and `ps` output. `list` needs no credential so an outage stays
|
using the CLI) — no shell access needed for anyone after the first.
|
||||||
diagnosable. The bind here does **not** apply the required-group gate, so a mistyped
|
|
||||||
group cannot lock you out of the tool that fixes it.
|
|
||||||
|
|
||||||
Be clear on what the bind is worth: anyone with a shell here can still write to the
|
**No break-glass path.** If Okta is unreachable or misconfigured, the app is
|
||||||
`users` table with `psql`. It is defence in depth and, mostly, **accountability** —
|
unreachable for everyone, including admins, until Okta is restored (D16) — this
|
||||||
every role change now writes an audit row naming a person, which shell changes
|
is a deliberate choice, the same one the abandoned LDAPS design made, not an
|
||||||
previously did not.
|
oversight.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -340,25 +322,25 @@ docker compose down -v
|
|||||||
|
|
||||||
## Quick test
|
## Quick test
|
||||||
|
|
||||||
`/api/health` is open; data routes now require a session, so log in first and
|
`/api/health` is open; every other `/api/` route needs a session cookie:
|
||||||
reuse the cookie jar:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://127.0.0.1:8000/api/health # {"ok":true} — no auth needed
|
curl http://127.0.0.1:8000/api/health # {"ok":true} — no auth needed
|
||||||
|
|
||||||
# Sign in, saving the session cookie to a jar
|
|
||||||
curl -c jar.txt -X POST http://127.0.0.1:8000/api/auth/login \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{"username":"alice","password":"<password>"}'
|
|
||||||
|
|
||||||
# Reuse the cookie on protected routes
|
|
||||||
curl -b jar.txt http://127.0.0.1:8000/api/comments
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Without the cookie, protected routes return `401 {"detail":"Not authenticated"}`.
|
Without a session cookie, protected routes return `401 {"detail":"Not authenticated"}`.
|
||||||
|
|
||||||
Or via the nginx proxy (replace with your hostname):
|
Or via the nginx proxy (replace with your hostname):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl https://wp-suite.company.local/api/health
|
curl https://wp-suite.company.local/api/health
|
||||||
```
|
```
|
||||||
|
|
||||||
|
There's no `curl`-able login anymore — Okta requires a real browser to
|
||||||
|
complete, which is what `login.html`'s "Sign in with Okta" button is for. To
|
||||||
|
exercise a protected route from a script instead, use `server/smoketest.py`'s
|
||||||
|
own technique (mint a session with `auth.create_token()` and set it as the
|
||||||
|
`wp_session` cookie, the same thing `okta_callback()` does after Okta hands
|
||||||
|
back an identity) rather than reaching for curl by hand — see that script's
|
||||||
|
own AUTHENTICATION section for the exact steps, and why it has to run
|
||||||
|
somewhere that shares the target server's `AUTH_SECRET_KEY` and database.
|
||||||
|
|||||||
@@ -52,6 +52,13 @@ def run_migrations_online() -> None:
|
|||||||
connection=connection,
|
connection=connection,
|
||||||
target_metadata=target_metadata,
|
target_metadata=target_metadata,
|
||||||
compare_type=True,
|
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():
|
with context.begin_transaction():
|
||||||
context.run_migrations()
|
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=''))
|
||||||
46
server/alembic/versions/8e2cb3003f8a_usage_events.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""usage events (CR-019, wave 11)
|
||||||
|
|
||||||
|
Append-only navigation/session activity, separate from audit_log on purpose —
|
||||||
|
see the UsageEvent docstring in server/models.py. Retention is indefinite by
|
||||||
|
decision (docs/waves/decisions-2026-09-17.md); nothing here schedules a purge.
|
||||||
|
|
||||||
|
Revision ID: 8e2cb3003f8a
|
||||||
|
Revises: 1d60a608bb51
|
||||||
|
Create Date: 2026-09-23 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '8e2cb3003f8a'
|
||||||
|
down_revision = '1d60a608bb51'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table('usage_events',
|
||||||
|
sa.Column('id', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('username', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('project_id', sa.String(length=40), nullable=True),
|
||||||
|
sa.Column('tool', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('event', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('detail', sa.JSON(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_usage_events_at'), 'usage_events', ['at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_usage_events_username'), 'usage_events', ['username'], unique=False)
|
||||||
|
op.create_index(op.f('ix_usage_events_project_id'), 'usage_events', ['project_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_usage_events_tool'), 'usage_events', ['tool'], unique=False)
|
||||||
|
op.create_index(op.f('ix_usage_events_event'), 'usage_events', ['event'], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f('ix_usage_events_event'), table_name='usage_events')
|
||||||
|
op.drop_index(op.f('ix_usage_events_tool'), table_name='usage_events')
|
||||||
|
op.drop_index(op.f('ix_usage_events_project_id'), table_name='usage_events')
|
||||||
|
op.drop_index(op.f('ix_usage_events_username'), table_name='usage_events')
|
||||||
|
op.drop_index(op.f('ix_usage_events_at'), table_name='usage_events')
|
||||||
|
op.drop_table('usage_events')
|
||||||
@@ -32,11 +32,10 @@ def upgrade() -> None:
|
|||||||
sa.Column('code', sa.String(length=80), nullable=False, server_default=''),
|
sa.Column('code', sa.String(length=80), nullable=False, server_default=''),
|
||||||
sa.Column('description', sa.String(length=300), nullable=False, server_default=''),
|
sa.Column('description', sa.String(length=300), nullable=False, server_default=''),
|
||||||
sa.Column('unit', sa.String(length=20), nullable=False, server_default=''),
|
sa.Column('unit', sa.String(length=20), nullable=False, server_default=''),
|
||||||
# sa.true(), NOT sa.text('1'). sa.text() emits raw SQL, and Postgres refuses
|
# sa.true(), not sa.text('1'): SQLite coerces integer 1 to boolean,
|
||||||
# an integer default on a boolean column: "column active is of type boolean
|
# Postgres refuses it (DatatypeMismatch) - found when this migration
|
||||||
# but default expression is of type integer". SQLite accepts 1 happily, so
|
# took down the wp.controls.dev api container on 2026-08-21. The
|
||||||
# this passed every local test and failed only on the real engine. The
|
# location-taxonomy migration next door had it right all along.
|
||||||
# sibling migration e2a4c7d91b30 does the identical column correctly.
|
|
||||||
sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.true()),
|
sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
sa.Column('sort', sa.Integer(), nullable=False, server_default='0'),
|
sa.Column('sort', sa.Integer(), nullable=False, server_default='0'),
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
"""drop users.password_hash — D13 / T10.3
|
|
||||||
|
|
||||||
Authentication moved to an LDAPS simple bind against the domain (see
|
|
||||||
server/ldap_auth.py), so the suite no longer holds a credential of any kind.
|
|
||||||
|
|
||||||
THIS MIGRATION DESTROYS DATA AND CANNOT BE UNDONE IN ANY MEANINGFUL SENSE.
|
|
||||||
`downgrade()` recreates the column, but every hash in it is gone — and even a
|
|
||||||
restored hash would be useless, because nothing reads the column any more. The
|
|
||||||
downgrade exists so the revision is well-formed and so an operator can step back
|
|
||||||
past it, not because stepping back restores the old login. To actually revert to
|
|
||||||
local passwords you have to revert the application code and reset every password
|
|
||||||
by hand.
|
|
||||||
|
|
||||||
The column is recreated NULLABLE on downgrade, deliberately. The baseline schema
|
|
||||||
declared it NOT NULL, but there are no values to put back, so a NOT NULL column
|
|
||||||
with no server default would refuse to add itself on any table that has rows.
|
|
||||||
|
|
||||||
DO NOT WRAP THIS IN batch_alter_table. An earlier version of this migration did,
|
|
||||||
and it silently deleted every row of `project_members` on SQLite.
|
|
||||||
|
|
||||||
Why: alembic's batch mode emulates ALTER on SQLite by rebuilding the table —
|
|
||||||
create a new one, copy the rows, DROP the original, rename. `server/alembic/env.py`
|
|
||||||
imports the engine from `server/db.py`, which registers a `connect` listener setting
|
|
||||||
`PRAGMA foreign_keys=ON`, so that DROP TABLE cascades through
|
|
||||||
`project_members.user_id`, which is declared `ondelete="CASCADE"`. Every project
|
|
||||||
membership in the database goes with it, with no error and nothing in the log.
|
|
||||||
|
|
||||||
Turning the pragma off around the batch is not a fix either: `PRAGMA foreign_keys`
|
|
||||||
is a no-op inside a transaction, and alembic runs migrations in one.
|
|
||||||
|
|
||||||
The real answer is that the rebuild is unnecessary. SQLite gained native
|
|
||||||
ALTER TABLE ... DROP COLUMN in 3.35 (2021); this runtime has 3.42 and Postgres has
|
|
||||||
always had it. A plain drop_column touches one table and cascades nowhere.
|
|
||||||
|
|
||||||
Revision ID: b7e4f1a20c93
|
|
||||||
Revises: a1b8c6d4e2f9
|
|
||||||
Create Date: 2026-08-21
|
|
||||||
"""
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision = 'b7e4f1a20c93'
|
|
||||||
down_revision = 'a1b8c6d4e2f9'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
# Plain, un-batched, on both engines. See the docstring: batching this destroys
|
|
||||||
# project_members on SQLite.
|
|
||||||
op.drop_column('users', 'password_hash')
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
op.add_column('users', sa.Column('password_hash', sa.String(length=200),
|
|
||||||
nullable=True))
|
|
||||||
621
server/app.py
@@ -9,29 +9,32 @@ Run (prod): gunicorn -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 serve
|
|||||||
Interactive docs: http://<host>/api/docs
|
Interactive docs: http://<host>/api/docs
|
||||||
"""
|
"""
|
||||||
import base64
|
import base64
|
||||||
|
import csv
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from time import monotonic
|
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from authlib.integrations.base_client import OAuthError
|
||||||
from fastapi import FastAPI, Depends, HTTPException, Query, Request, Response, BackgroundTasks
|
from fastapi import FastAPI, Depends, HTTPException, Query, Request, Response, BackgroundTasks
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from sqlalchemy import select, delete, func
|
from sqlalchemy import select, delete, func, or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from .db import Base, engine, get_db
|
from .db import Base, engine, get_db
|
||||||
from . import models, auth, notify, ldap_auth, assets_db
|
from . import models, auth, notify, assets_db, okta_auth, okta_fake
|
||||||
|
|
||||||
# Same naming as the other modules' loggers (wpsuite.auth / .ldap / .notify), so a
|
log = logging.getLogger("wpsuite.app")
|
||||||
# deployment can raise the level on one subsystem without raising it on all of them.
|
|
||||||
log = logging.getLogger("wpsuite.api")
|
|
||||||
|
|
||||||
# Schema management:
|
# Schema management:
|
||||||
# • Local dev (SQLite) auto-creates tables for a zero-config run.
|
# • Local dev (SQLite) auto-creates tables for a zero-config run.
|
||||||
@@ -61,6 +64,22 @@ if _origins:
|
|||||||
allow_methods=["*"], allow_headers=["*"], expose_headers=["X-Total-Count"],
|
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 ────────────────────────────────────────────────────────
|
# ── Authentication gate ────────────────────────────────────────────────────────
|
||||||
# Every /api/ data route requires a valid session cookie. Login, health, and the
|
# Every /api/ data route requires a valid session cookie. Login, health, and the
|
||||||
@@ -89,12 +108,23 @@ def _csrf_ok(request: Request) -> bool:
|
|||||||
async def auth_gate(request: Request, call_next):
|
async def auth_gate(request: Request, call_next):
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
method = request.method
|
method = request.method
|
||||||
|
claims = None
|
||||||
if method != "OPTIONS" and auth._needs_auth(path):
|
if method != "OPTIONS" and auth._needs_auth(path):
|
||||||
if not auth.is_request_authenticated(request):
|
claims = auth.is_request_authenticated(request)
|
||||||
|
if not claims:
|
||||||
return JSONResponse(status_code=401, content={"detail": "Not authenticated"})
|
return JSONResponse(status_code=401, content={"detail": "Not authenticated"})
|
||||||
if method in _UNSAFE_METHODS and path.startswith("/api/") and not _csrf_ok(request):
|
if method in _UNSAFE_METHODS and path.startswith("/api/") and not _csrf_ok(request):
|
||||||
return JSONResponse(status_code=403, content={"detail": "Cross-origin request rejected"})
|
return JSONResponse(status_code=403, content={"detail": "Cross-origin request rejected"})
|
||||||
return await call_next(request)
|
response = await call_next(request)
|
||||||
|
# D18: slide the session forward on activity, capped by its absolute
|
||||||
|
# ceiling. Reads only the already-validated claims - no DB hit here, and
|
||||||
|
# the separate is_active/token_version check in get_current_user still
|
||||||
|
# runs on its own for every request regardless of whether this refreshes.
|
||||||
|
if claims is not None:
|
||||||
|
refreshed = auth.maybe_refresh_token(claims)
|
||||||
|
if refreshed:
|
||||||
|
auth.set_session_cookie(response, request, refreshed)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def gen_id(prefix: str) -> str:
|
def gen_id(prefix: str) -> str:
|
||||||
@@ -610,15 +640,7 @@ def health():
|
|||||||
|
|
||||||
|
|
||||||
# ── Authentication ─────────────────────────────────────────────────────────────
|
# ── Authentication ─────────────────────────────────────────────────────────────
|
||||||
class LoginIn(BaseModel):
|
|
||||||
username: str
|
|
||||||
password: str
|
|
||||||
|
|
||||||
|
|
||||||
class NewUserIn(BaseModel):
|
class NewUserIn(BaseModel):
|
||||||
# No password: D13 authenticates against the domain, so an administrator
|
|
||||||
# pre-creating an account only supplies identity and authorization. The person
|
|
||||||
# signs in with their Windows password, or is provisioned on first sign-in.
|
|
||||||
username: str
|
username: str
|
||||||
full_name: str = ""
|
full_name: str = ""
|
||||||
email: str = ""
|
email: str = ""
|
||||||
@@ -663,221 +685,150 @@ class AutoAddIn(BaseModel):
|
|||||||
role: str = ""
|
role: str = ""
|
||||||
|
|
||||||
|
|
||||||
# D13 / T10.2 — THIS NUMBER IS NOT A UX PREFERENCE, IT IS A SAFETY LIMIT.
|
|
||||||
#
|
|
||||||
# Failures are now LDAP binds, so every one counts against the DOMAIN account
|
|
||||||
# lockout policy. This estate's AD threshold is 5. The throttle below is
|
|
||||||
# per-process and the API runs 2 gunicorn workers, so a local limit of N lets up
|
|
||||||
# to 2N binds reach a domain controller: 2 x 2 = 4, one under the threshold.
|
|
||||||
#
|
|
||||||
# It defaulted to 5 before this task, which would have allowed up to 10 binds and
|
|
||||||
# locked the account out of WINDOWS — twice over — before the local lockout ever
|
|
||||||
# engaged. If you raise this, or add a worker, redo the arithmetic first.
|
|
||||||
LOGIN_MAX_ATTEMPTS = int(os.getenv("AUTH_MAX_ATTEMPTS", "2"))
|
|
||||||
LOGIN_LOCKOUT_MINUTES = int(os.getenv("AUTH_LOCKOUT_MINUTES", "15"))
|
|
||||||
|
|
||||||
# Pre-account throttle, keyed by USERNAME (not by client IP — an attacker rotating
|
|
||||||
# IPs would sail past an IP-keyed limit, and it is the domain account we are
|
|
||||||
# protecting, not this endpoint's capacity).
|
|
||||||
#
|
|
||||||
# The DB counter on `users` is shared across workers and persists, but it can only
|
|
||||||
# work for an account that already has a local row. Under D13 accounts are created
|
|
||||||
# on first successful login, so a REAL domain account can be hammered before any
|
|
||||||
# row exists — this dict covers exactly that window. Per-worker and lost on
|
|
||||||
# restart, which is why LOGIN_MAX_ATTEMPTS is halved rather than trusted.
|
|
||||||
_bind_attempts: dict[str, list[float]] = {}
|
|
||||||
_BIND_WINDOW_SECONDS = LOGIN_LOCKOUT_MINUTES * 60
|
|
||||||
|
|
||||||
|
|
||||||
def _bind_throttled(sam: str) -> bool:
|
|
||||||
"""True if this username has already used its bind budget in the window.
|
|
||||||
Checked BEFORE any call to the directory."""
|
|
||||||
now = monotonic()
|
|
||||||
key = (sam or "").strip().lower()
|
|
||||||
hits = [t for t in _bind_attempts.get(key, []) if now - t < _BIND_WINDOW_SECONDS]
|
|
||||||
_bind_attempts[key] = hits
|
|
||||||
if len(_bind_attempts) > 5000: # bound the dict on a long-lived worker
|
|
||||||
for k in [k for k, v in _bind_attempts.items()
|
|
||||||
if not v or now - max(v) > _BIND_WINDOW_SECONDS]:
|
|
||||||
_bind_attempts.pop(k, None)
|
|
||||||
return len(hits) >= LOGIN_MAX_ATTEMPTS
|
|
||||||
|
|
||||||
|
|
||||||
def _record_bind_failure(sam: str) -> None:
|
|
||||||
_bind_attempts.setdefault((sam or "").strip().lower(), []).append(monotonic())
|
|
||||||
|
|
||||||
|
|
||||||
def _clear_bind_failures(sam: str) -> None:
|
|
||||||
_bind_attempts.pop((sam or "").strip().lower(), None)
|
|
||||||
|
|
||||||
|
|
||||||
def _match_directory_account(db: Session, result) -> Optional[models.User]:
|
|
||||||
"""Find the local row for a directory identity — D13 criterion 4.
|
|
||||||
|
|
||||||
Matched on `sAMAccountName` OR the directory's `mail`, because existing accounts
|
|
||||||
were created by hand with `manage_users.py` and some were typed as short logon
|
|
||||||
names while others were typed as email addresses. Matching on both is what keeps
|
|
||||||
an existing admin's role instead of handing them a second, default-role account.
|
|
||||||
|
|
||||||
`auth.find_user` already compares case-insensitively against username AND email,
|
|
||||||
so each call covers two columns; the second call is for the case where the local
|
|
||||||
username is the person's address and the directory only told us their sAMAccountName.
|
|
||||||
"""
|
|
||||||
user = auth.find_user(db, result.sam)
|
|
||||||
if user is None and result.mail:
|
|
||||||
user = auth.find_user(db, result.mail)
|
|
||||||
if user is not None:
|
|
||||||
log.info("matched directory identity %r to existing local account %r by mail",
|
|
||||||
result.sam, user.username)
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
def _provision_from_directory(db: Session, result) -> models.User:
|
|
||||||
"""Create a local account for someone who just authenticated and has no row.
|
|
||||||
|
|
||||||
Lands at `project_user` with NO project memberships. That is least privilege and
|
|
||||||
it is deliberate, but it means the person signs in successfully into an empty
|
|
||||||
app until an admin grants access — so it is written to the audit log rather than
|
|
||||||
happening silently. `auto_add_projects` cannot help here: it is evaluated when a
|
|
||||||
PROJECT is created, to mark who joins every new job, and cannot retroactively add
|
|
||||||
a new account to jobs that already exist.
|
|
||||||
"""
|
|
||||||
u = models.User(
|
|
||||||
id=gen_id("user"),
|
|
||||||
username=result.sam,
|
|
||||||
email=result.mail or "",
|
|
||||||
full_name=result.full_name or "",
|
|
||||||
role=auth.ROLE_PROJECT_USER,
|
|
||||||
)
|
|
||||||
db.add(u)
|
|
||||||
db.flush() # see the flush-order note in models.py's docstring
|
|
||||||
log_event(db, u.username, "user_provisioned", "user", u.id, summary=u.username,
|
|
||||||
detail={"source": "directory", "role": u.role, "upn": result.upn,
|
|
||||||
"projects": 0, "note": "created on first successful sign-in"})
|
|
||||||
log.info("provisioned local account %r from the directory at role %r with no "
|
|
||||||
"project access — an admin must grant access before they see anything",
|
|
||||||
u.username, u.role)
|
|
||||||
return u
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/auth/login")
|
|
||||||
def login(body: LoginIn, request: Request, response: Response, db: Session = Depends(get_db)):
|
|
||||||
"""Authenticate against the domain (D13 / T10.2) and set the session cookie.
|
|
||||||
|
|
||||||
The suite stores no passwords: this is an LDAPS simple bind as
|
|
||||||
`<sAMAccountName>@prime.local`, and a successful bind IS the authentication.
|
|
||||||
See server/ldap_auth.py for the transport and its two hard guards.
|
|
||||||
|
|
||||||
ORDER MATTERS HERE. Every throttle check runs BEFORE the directory is touched,
|
|
||||||
because a failed bind counts against the DOMAIN lockout policy — so this
|
|
||||||
endpoint must not be usable to lock a colleague out of Windows. Nothing below
|
|
||||||
reaches `ldap_auth.verify` until the local budget has been checked twice: once
|
|
||||||
against the persistent per-account counter, once against the pre-account
|
|
||||||
window that covers usernames with no local row yet.
|
|
||||||
|
|
||||||
Three outcomes, deliberately distinguished:
|
|
||||||
401 the credential was rejected, or the account is not in the required
|
|
||||||
group. One generic message for every case — the response must never
|
|
||||||
reveal whether an account exists (see `_ERR49` in ldap_auth: the useful
|
|
||||||
detail goes to the log).
|
|
||||||
403 the local account exists and is disabled. Independent of the directory.
|
|
||||||
503 OUR fault — LDAP unconfigured, unreachable, untrusted, or the required
|
|
||||||
group does not resolve. D13 left no password fallback, so this must not
|
|
||||||
masquerade as 401: "your password is wrong" sends people hunting for a
|
|
||||||
password they no longer have, while the real problem is a broken deploy.
|
|
||||||
"""
|
|
||||||
now = models.utcnow()
|
|
||||||
sam = ldap_auth.normalize_username(body.username)
|
|
||||||
if not sam or not (body.password or "").strip():
|
|
||||||
# No directory call for empty input. ldap_auth.verify guards this too; the
|
|
||||||
# duplication is intentional, since an empty password would otherwise be an
|
|
||||||
# anonymous bind and anonymous binds SUCCEED.
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
|
||||||
|
|
||||||
user = auth.find_user(db, sam)
|
|
||||||
|
|
||||||
# ── throttle 1: the persistent per-account lockout ────────────────────────
|
|
||||||
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
|
|
||||||
if locked is not None and locked > now:
|
|
||||||
raise HTTPException(status_code=429, detail="Too many failed attempts. Try again later.")
|
|
||||||
|
|
||||||
# ── throttle 2: the pre-account window ────────────────────────────────────
|
|
||||||
if _bind_throttled(sam):
|
|
||||||
log.warning("refusing to bind for %r — local attempt budget (%d) spent; "
|
|
||||||
"protecting the domain account from lockout", sam, LOGIN_MAX_ATTEMPTS)
|
|
||||||
raise HTTPException(status_code=429, detail="Too many failed attempts. Try again later.")
|
|
||||||
|
|
||||||
# ── the directory ─────────────────────────────────────────────────────────
|
|
||||||
settings = notify.get_settings(db)
|
|
||||||
result = ldap_auth.verify(sam, body.password,
|
|
||||||
required_group=settings.get("ldap_required_group"))
|
|
||||||
|
|
||||||
if result.is_config_problem:
|
|
||||||
# Ours, not theirs. Never counted against the user, never a 401.
|
|
||||||
log.error("sign-in unavailable — %s: %s", result.reason, result.detail)
|
|
||||||
raise HTTPException(status_code=503,
|
|
||||||
detail="Sign-in is temporarily unavailable. Contact IT.")
|
|
||||||
|
|
||||||
if not result.ok:
|
|
||||||
log.info("sign-in refused for %r (%s: %s)", sam, result.reason, result.detail)
|
|
||||||
_record_bind_failure(sam)
|
|
||||||
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, "reason": result.reason})
|
|
||||||
db.commit()
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
|
||||||
|
|
||||||
# ── authenticated ─────────────────────────────────────────────────────────
|
|
||||||
_clear_bind_failures(sam)
|
|
||||||
|
|
||||||
# The pre-bind lookup used the typed name only. Now that the directory has told
|
|
||||||
# us the account's real sAMAccountName and mail, re-match on both (T10.4).
|
|
||||||
if user is None:
|
|
||||||
user = _match_directory_account(db, result)
|
|
||||||
|
|
||||||
provisioned = user is None
|
|
||||||
if provisioned:
|
|
||||||
user = _provision_from_directory(db, result)
|
|
||||||
else:
|
|
||||||
# NEVER touch `role` here. An existing admin stays an admin — that is D13
|
|
||||||
# criterion 4, and it is the whole reason this branch is separate from the
|
|
||||||
# one above. Fill in identity fields only where they are empty locally, so a
|
|
||||||
# name deliberately set in the console is not overwritten by the directory.
|
|
||||||
if not user.full_name and result.full_name:
|
|
||||||
user.full_name = result.full_name
|
|
||||||
if not user.email and result.mail:
|
|
||||||
user.email = result.mail
|
|
||||||
|
|
||||||
if not user.is_active:
|
|
||||||
# Checked after provisioning so a brand-new account (is_active defaults True)
|
|
||||||
# is not caught by it, and after the role branch so a disabled admin is still
|
|
||||||
# refused. Local state overrides the directory: disabling here is how you
|
|
||||||
# revoke access to THIS app without touching the domain account.
|
|
||||||
raise HTTPException(status_code=403, detail="Account is disabled")
|
|
||||||
|
|
||||||
user.failed_attempts = 0
|
|
||||||
user.locked_until = None
|
|
||||||
user.last_login_at = now
|
|
||||||
db.commit()
|
|
||||||
db.refresh(user)
|
|
||||||
token = auth.create_token(user)
|
|
||||||
auth.set_session_cookie(response, request, token)
|
|
||||||
return {"user": user.to_dict()}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/auth/logout")
|
@app.post("/api/auth/logout")
|
||||||
def logout(response: Response):
|
def logout(response: Response):
|
||||||
auth.clear_session_cookie(response)
|
auth.clear_session_cookie(response)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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 ""
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
|
||||||
|
@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.")
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
|
||||||
|
user.failed_attempts = 0
|
||||||
|
user.locked_until = None
|
||||||
|
user.last_login_at = models.utcnow()
|
||||||
|
# CR-019: one login event per Okta sign-in, written here rather than
|
||||||
|
# inferred from session creation elsewhere, so there is exactly one
|
||||||
|
# source of truth for "did this person sign in" - not one per page load
|
||||||
|
# afterward (T11.2 covers that separately, as page_open events).
|
||||||
|
db.add(models.UsageEvent(id=gen_id("uev"), username=user.username, tool="", event="login"))
|
||||||
|
db.commit()
|
||||||
|
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")
|
@app.get("/api/auth/me")
|
||||||
def whoami(user: models.User = Depends(auth.get_current_user)):
|
def whoami(user: models.User = Depends(auth.get_current_user)):
|
||||||
"""Who is logged in. The frontend guard calls this on every page load.
|
"""Who is logged in. The frontend guard calls this on every page load.
|
||||||
@@ -1018,6 +969,14 @@ def user_scope(user: models.User = Depends(auth.get_current_user), db: Session =
|
|||||||
|
|
||||||
@app.post("/api/auth/users")
|
@app.post("/api/auth/users")
|
||||||
def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
|
def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
|
||||||
|
"""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)
|
allowed = grantable_roles(actor)
|
||||||
if body.role not in allowed:
|
if body.role not in allowed:
|
||||||
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(allowed)}")
|
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(allowed)}")
|
||||||
@@ -2398,6 +2357,24 @@ def parse_location_rows(text: str) -> tuple[list[tuple[int, list[str]]], list[di
|
|||||||
rejected.append({"line": i, "text": line,
|
rejected.append({"line": i, "text": line,
|
||||||
"reason": "no letters or digits to make a code from"})
|
"reason": "no letters or digits to make a code from"})
|
||||||
continue
|
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))
|
rows.append((i, parts))
|
||||||
return rows, rejected
|
return rows, rejected
|
||||||
|
|
||||||
@@ -2464,6 +2441,7 @@ def import_locations(project_id: str, body: LocationImportIn,
|
|||||||
require_project_writable(db, user, project_id, "The location list cannot be changed")
|
require_project_writable(db, user, project_id, "The location list cannot be changed")
|
||||||
|
|
||||||
rows, rejected = parse_location_rows(body.text)
|
rows, rejected = parse_location_rows(body.text)
|
||||||
|
read_total = len(rows) + len(rejected)
|
||||||
|
|
||||||
existing = {n.path: n for n in db.scalars(
|
existing = {n.path: n for n in db.scalars(
|
||||||
select(models.LocationNode).where(models.LocationNode.project_id == project_id)
|
select(models.LocationNode).where(models.LocationNode.project_id == project_id)
|
||||||
@@ -2478,6 +2456,10 @@ def import_locations(project_id: str, body: LocationImportIn,
|
|||||||
for line_no, parts in rows:
|
for line_no, parts in rows:
|
||||||
segs = [location_slug(p) for p in parts]
|
segs = [location_slug(p) for p in parts]
|
||||||
full = "/".join(segs)
|
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:
|
if full in seen_in_file:
|
||||||
duplicates.append({"line": line_no, "path": full, "names": parts,
|
duplicates.append({"line": line_no, "path": full, "names": parts,
|
||||||
"reason": "already on line %d of this import" % seen_in_file[full]})
|
"reason": "already on line %d of this import" % seen_in_file[full]})
|
||||||
@@ -2520,7 +2502,7 @@ def import_locations(project_id: str, body: LocationImportIn,
|
|||||||
|
|
||||||
result = {
|
result = {
|
||||||
"project_id": project_id, "dry_run": bool(body.dry_run),
|
"project_id": project_id, "dry_run": bool(body.dry_run),
|
||||||
"read": len(rows) + len(rejected),
|
"read": read_total,
|
||||||
"created": created, "duplicates": duplicates,
|
"created": created, "duplicates": duplicates,
|
||||||
"reactivated": reactivated, "rejected": rejected,
|
"reactivated": reactivated, "rejected": rejected,
|
||||||
}
|
}
|
||||||
@@ -3017,6 +2999,18 @@ def parse_material_rows(text: str):
|
|||||||
rejected.append({"line": i, "text": raw.strip()[:120],
|
rejected.append({"line": i, "text": raw.strip()[:120],
|
||||||
"reason": "more than three columns - description, unit, code is the whole shape"})
|
"reason": "more than three columns - description, unit, code is the whole shape"})
|
||||||
continue
|
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))
|
rows.append((i, parts))
|
||||||
return rows, rejected
|
return rows, rejected
|
||||||
|
|
||||||
@@ -3401,6 +3395,177 @@ def create_feedback(body: CommentIn, user: models.User = Depends(auth.get_curren
|
|||||||
return _save_comment(body, db, user)
|
return _save_comment(body, db, user)
|
||||||
|
|
||||||
|
|
||||||
|
# ── CR-019: usage/activity metrics ──────────────────────────────────────────
|
||||||
|
class UsageIn(BaseModel):
|
||||||
|
tool: str = ""
|
||||||
|
project_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/usage/ping")
|
||||||
|
def usage_ping(body: UsageIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||||
|
"""One usage_events row per authenticated page load. Called exactly once,
|
||||||
|
from auth-guard.js after wp-auth-ready fires (T11.2) - never duplicated
|
||||||
|
per page's own script, the same lesson S4's per-page nav already taught
|
||||||
|
this codebase. `user` comes from the session via get_current_user, never
|
||||||
|
from anything the client claims - identity here is a server-enforced
|
||||||
|
fact, matching every other write in this file, not a client-reported one."""
|
||||||
|
tool = (body.tool or "").strip()[:40]
|
||||||
|
db.add(models.UsageEvent(
|
||||||
|
id=gen_id("uev"), username=user.username, project_id=body.project_id,
|
||||||
|
tool=tool, event="page_open",
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date_q(v: str, end: bool = False) -> Optional[datetime]:
|
||||||
|
"""Accepts a plain YYYY-MM-DD (what a <input type=date> sends) or a full
|
||||||
|
ISO datetime. A date-only `to` means "through the end of that day", not
|
||||||
|
midnight at its start - otherwise a range of "today" would match nothing
|
||||||
|
from today at all."""
|
||||||
|
if not v:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
d = datetime.fromisoformat(v)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if d.tzinfo is None:
|
||||||
|
d = d.replace(tzinfo=timezone.utc)
|
||||||
|
if end and len(v) <= 10: # date-only
|
||||||
|
d = d + timedelta(days=1) - timedelta(microseconds=1)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _usage_query(db: Session, caller: "models.User", date_from, date_to, project_id, username, tool):
|
||||||
|
"""Shared by the summary and export endpoints so the two can never
|
||||||
|
disagree about which rows a filter set matches - the export is a raw
|
||||||
|
dump of exactly what the summary counted, not a separately-derived view."""
|
||||||
|
stmt = select(models.UsageEvent)
|
||||||
|
managed = managed_project_ids(db, caller)
|
||||||
|
if managed is not None:
|
||||||
|
# A project_super_user (never an app admin, who gets managed=None) is
|
||||||
|
# scoped to events tied to a project they administer, plus their OWN
|
||||||
|
# suite-wide activity (admin console opens etc. carry no project_id) -
|
||||||
|
# never another user's activity outside what they manage.
|
||||||
|
if managed:
|
||||||
|
stmt = stmt.where(or_(
|
||||||
|
models.UsageEvent.project_id.in_(managed),
|
||||||
|
models.UsageEvent.username == caller.username,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
stmt = stmt.where(models.UsageEvent.username == caller.username)
|
||||||
|
df = _parse_date_q(date_from)
|
||||||
|
dt = _parse_date_q(date_to, end=True)
|
||||||
|
if df:
|
||||||
|
stmt = stmt.where(models.UsageEvent.at >= df)
|
||||||
|
if dt:
|
||||||
|
stmt = stmt.where(models.UsageEvent.at <= dt)
|
||||||
|
if project_id:
|
||||||
|
stmt = stmt.where(models.UsageEvent.project_id == project_id)
|
||||||
|
if username:
|
||||||
|
stmt = stmt.where(models.UsageEvent.username == username)
|
||||||
|
if tool:
|
||||||
|
stmt = stmt.where(models.UsageEvent.tool == tool)
|
||||||
|
return stmt.order_by(models.UsageEvent.at)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/usage/summary")
|
||||||
|
def usage_summary(
|
||||||
|
date_from: Optional[str] = Query(None, alias="from"),
|
||||||
|
date_to: Optional[str] = Query(None, alias="to"),
|
||||||
|
project_id: Optional[str] = Query(None),
|
||||||
|
username: Optional[str] = Query(None),
|
||||||
|
tool: Optional[str] = Query(None),
|
||||||
|
caller: models.User = Depends(require_user_manager),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""CR-019. Same gate as the User Directory (require_user_manager): an app
|
||||||
|
admin or a project_super_user with at least one managed project. Filters
|
||||||
|
combine. Aggregated in Python over the filtered row set rather than a SQL
|
||||||
|
GROUP BY - correct and simple at today's scale; if usage_events grows
|
||||||
|
into the millions (plausible, given retention is indefinite by decision),
|
||||||
|
the day/week/month bucketing here is the first thing to move server-side
|
||||||
|
into SQL. Not done now because nothing currently requires it."""
|
||||||
|
rows = db.scalars(_usage_query(db, caller, date_from, date_to, project_id, username, tool)).all()
|
||||||
|
|
||||||
|
by_day: dict[str, set] = {}
|
||||||
|
by_week: dict[str, set] = {}
|
||||||
|
by_month: dict[str, set] = {}
|
||||||
|
per_user_last: dict[str, datetime] = {}
|
||||||
|
per_tool: dict[str, int] = {}
|
||||||
|
for e in rows:
|
||||||
|
by_day.setdefault(e.at.date().isoformat(), set()).add(e.username)
|
||||||
|
by_week.setdefault(e.at.strftime("%G-W%V"), set()).add(e.username)
|
||||||
|
by_month.setdefault(e.at.strftime("%Y-%m"), set()).add(e.username)
|
||||||
|
if e.username not in per_user_last or e.at > per_user_last[e.username]:
|
||||||
|
per_user_last[e.username] = e.at
|
||||||
|
per_tool[e.tool] = per_tool.get(e.tool, 0) + 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"active_users": {
|
||||||
|
"by_day": {k: len(v) for k, v in sorted(by_day.items())},
|
||||||
|
"by_week": {k: len(v) for k, v in sorted(by_week.items())},
|
||||||
|
"by_month": {k: len(v) for k, v in sorted(by_month.items())},
|
||||||
|
},
|
||||||
|
"per_user_last_active": {u: models._iso(t) for u, t in sorted(per_user_last.items())},
|
||||||
|
"by_tool": dict(sorted(per_tool.items(), key=lambda kv: -kv[1])),
|
||||||
|
"event_count": len(rows),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _pseudonym(username: str) -> str:
|
||||||
|
"""A stable per-user id for the sanitized export — the SAME input always
|
||||||
|
produces the SAME output, within one export and across separate export
|
||||||
|
runs, so an external system (Power BI or similar) can still group and
|
||||||
|
trend "by user" without ever receiving a real name. HMAC rather than a
|
||||||
|
plain hash: a plain sha256(username) is trivially reversed against a
|
||||||
|
wordlist of the handful of usernames this app actually has; keying it
|
||||||
|
with AUTH_SECRET_KEY (already a real secret, already required in
|
||||||
|
production — see auth.py) means recovering a username from its
|
||||||
|
pseudonym requires the signing key, not just guessing."""
|
||||||
|
digest = hmac.new(auth.SECRET_KEY.encode(), username.encode(), hashlib.sha256).hexdigest()
|
||||||
|
return "u_" + digest[:16]
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/usage/export")
|
||||||
|
def usage_export(
|
||||||
|
date_from: Optional[str] = Query(None, alias="from"),
|
||||||
|
date_to: Optional[str] = Query(None, alias="to"),
|
||||||
|
project_id: Optional[str] = Query(None),
|
||||||
|
username: Optional[str] = Query(None),
|
||||||
|
tool: Optional[str] = Query(None),
|
||||||
|
sanitize: bool = Query(False),
|
||||||
|
caller: models.User = Depends(require_user_manager),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""CR-019. Same gate, same filters, same underlying row set as
|
||||||
|
usage_summary() (_usage_query) — the export can never show a different
|
||||||
|
slice of data than what the console counted for the same filters.
|
||||||
|
|
||||||
|
sanitize=true replaces `username` with a stable pseudonym (_pseudonym)
|
||||||
|
and — deliberately — the `detail` column is not exported in EITHER mode.
|
||||||
|
Every event this app writes today (login, page_open) leaves `detail`
|
||||||
|
empty, so this costs nothing now, but it also means a future event type
|
||||||
|
that DOES populate `detail` can't accidentally leak a real name into a
|
||||||
|
sanitized file through a column nobody thought to scrub. If `detail`
|
||||||
|
is ever needed in the export, it has to be sanitized explicitly, not
|
||||||
|
assumed safe because the rest of the row was."""
|
||||||
|
rows = db.scalars(_usage_query(db, caller, date_from, date_to, project_id, username, tool)).all()
|
||||||
|
buf = io.StringIO()
|
||||||
|
w = csv.writer(buf)
|
||||||
|
w.writerow(["at", "username", "project_id", "tool", "event"])
|
||||||
|
for e in rows:
|
||||||
|
who = _pseudonym(e.username) if sanitize else e.username
|
||||||
|
w.writerow([models._iso(e.at), who, e.project_id or "", e.tool, e.event])
|
||||||
|
filename = "usage_export_%s_%s.csv" % (
|
||||||
|
"sanitized" if sanitize else "raw", datetime.now(timezone.utc).strftime("%Y%m%d"),
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
content=buf.getvalue(), media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/comments")
|
@app.get("/api/comments")
|
||||||
def list_comments(
|
def list_comments(
|
||||||
source: Optional[str] = Query(None),
|
source: Optional[str] = Query(None),
|
||||||
|
|||||||
122
server/auth.py
@@ -1,10 +1,11 @@
|
|||||||
"""Authentication for the Work Package Suite.
|
"""Authentication for the Work Package Suite.
|
||||||
|
|
||||||
Authentication is an LDAPS simple bind against the domain (D13); this module owns
|
Identity is confirmed by Okta (OIDC authorization-code flow, see server/okta_auth.py
|
||||||
everything *after* that. A successful sign-in issues a signed JWT that rides in an
|
and the routes in server/app.py); there is no local password anywhere in this app
|
||||||
HttpOnly cookie (`wp_session`). Because the token is signed and self-validating, there is no
|
(D15, D16 — T10.4 removed the last of it). A successful sign-in issues a signed JWT
|
||||||
server-side session store — every request is checked by verifying the cookie's
|
that rides in an HttpOnly cookie (`wp_session`). Because the token is signed and
|
||||||
signature and expiry (see `auth_gate` and `get_current_user`).
|
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:
|
Security model:
|
||||||
• The real boundary is `auth_gate` (middleware in app.py): every /api/ data
|
• The real boundary is `auth_gate` (middleware in app.py): every /api/ data
|
||||||
@@ -12,14 +13,14 @@ Security model:
|
|||||||
• The cookie is HttpOnly (JS can't read it → XSS can't steal the session),
|
• The cookie is HttpOnly (JS can't read it → XSS can't steal the session),
|
||||||
SameSite=Lax (blunts CSRF), and Secure whenever the request arrives over
|
SameSite=Lax (blunts CSRF), and Secure whenever the request arrives over
|
||||||
HTTPS (detected via X-Forwarded-Proto behind NGINX).
|
HTTPS (detected via X-Forwarded-Proto behind NGINX).
|
||||||
• Roles are LOCAL. The directory supplies identity; this app decides what that
|
|
||||||
identity may do, which is why an existing admin keeps admin (D13 criterion 4).
|
|
||||||
• The signing secret comes from AUTH_SECRET_KEY. In production this MUST be
|
• The signing secret comes from AUTH_SECRET_KEY. In production this MUST be
|
||||||
set; if it is missing we fall back to a random per-process key (which logs a
|
set; if it is missing we fall back to a random per-process key (which logs a
|
||||||
warning and invalidates every session on restart) so dev still works.
|
warning and invalidates every session on restart) so dev still works.
|
||||||
|
|
||||||
Permissions roles (`User.role`) — distinct from a person's job function on the
|
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,
|
• admin application administrator: user administration, app settings,
|
||||||
and implicit access to every project.
|
and implicit access to every project.
|
||||||
• project_super_user
|
• project_super_user
|
||||||
@@ -32,16 +33,13 @@ project, which lives in `User.project_role` and grants nothing:
|
|||||||
modify a SOP after it has been completed, and delete projects.
|
modify a SOP after it has been completed, and delete projects.
|
||||||
• project_user normal member: creates and edits work packages, authors a SOP
|
• project_user normal member: creates and edits work packages, authors a SOP
|
||||||
up to completion. May NOT delete WPs or change a completed 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
|
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
|
(`managed_project_ids`, `manage_user_problem`), because it depends on project
|
||||||
membership rows — this module only decides which roles carry the power at all.
|
membership rows — this module only decides which roles carry the power at all.
|
||||||
|
|
||||||
There is no password and no password reset: D13 replaced local credentials with an
|
|
||||||
LDAPS bind (server/ldap_auth.py). People change their password with the domain, and
|
|
||||||
the login page points them at Okta. `token_version` survives as the
|
|
||||||
session-revocation mechanism — a role change or a deactivation must take effect on
|
|
||||||
sessions that have already been issued.
|
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
@@ -61,8 +59,26 @@ log = logging.getLogger("wpsuite.auth")
|
|||||||
|
|
||||||
COOKIE_NAME = "wp_session"
|
COOKIE_NAME = "wp_session"
|
||||||
JWT_ALG = "HS256"
|
JWT_ALG = "HS256"
|
||||||
# How long a login lasts before the user must sign in again.
|
# D18 (2026-09-23): a session now slides on activity, capped by a hard ceiling
|
||||||
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12"))
|
# underneath - not a single flat lifetime. A pure idle timer with no ceiling
|
||||||
|
# would let a continuously-active session never force a fresh Okta recheck,
|
||||||
|
# which is a worse fit for this item's own purpose (catching someone still
|
||||||
|
# active after being deprovisioned) than a flat expiry would have been. Both
|
||||||
|
# numbers are proposed defaults, not confirmed against the tenant's actual
|
||||||
|
# Okta SSO session policy - see docs/waves/decisions-2026-09-17.md.
|
||||||
|
#
|
||||||
|
# No request for this long invalidates the session outright.
|
||||||
|
IDLE_MINUTES = int(os.getenv("AUTH_IDLE_MINUTES", "30"))
|
||||||
|
# The absolute ceiling from the ORIGINAL sign-in, regardless of activity. Same
|
||||||
|
# env var name as the old flat-lifetime design; the meaning changed, the name
|
||||||
|
# didn't, because it still answers "how long can this session possibly live."
|
||||||
|
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "8"))
|
||||||
|
# How much later a refreshed exp must be before it's worth rewriting the
|
||||||
|
# cookie. Without this, an active session gets a new Set-Cookie on literally
|
||||||
|
# every request - correct, but wasteful, and it makes the cookie a busier
|
||||||
|
# target than it needs to be. A third of the idle window is a reasonable
|
||||||
|
# balance: refreshed a few times within any idle window, never every request.
|
||||||
|
_REFRESH_SLACK = timedelta(minutes=max(1, IDLE_MINUTES // 3))
|
||||||
|
|
||||||
# ── permissions roles ─────────────────────────────────────────────────────────
|
# ── permissions roles ─────────────────────────────────────────────────────────
|
||||||
ROLE_ADMIN = "admin"
|
ROLE_ADMIN = "admin"
|
||||||
@@ -121,7 +137,7 @@ def is_project_admin(user: "models.User") -> bool:
|
|||||||
# same question used to exist here and silently disagreed with the scoped one, which
|
# 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.
|
# locked per-project super users out of the routes they were entitled to.
|
||||||
|
|
||||||
# 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_PREFIXES = ("/api/auth/",)
|
||||||
_EXEMPT_EXACT = {
|
_EXEMPT_EXACT = {
|
||||||
"/api/health",
|
"/api/health",
|
||||||
@@ -162,6 +178,30 @@ SECRET_KEY = _load_secret()
|
|||||||
|
|
||||||
|
|
||||||
# ── tokens ──────────────────────────────────────────────────────────────────
|
# ── tokens ──────────────────────────────────────────────────────────────────
|
||||||
|
def _parse_claim_dt(v) -> Optional[datetime]:
|
||||||
|
"""`login_at` is a custom claim, so unlike `exp`/`iat` (which PyJWT
|
||||||
|
special-cases for a datetime -> POSIX-timestamp conversion on encode) it
|
||||||
|
is stored and read back as a plain numeric timestamp. Returns None for
|
||||||
|
anything unparseable rather than raising - a malformed or legacy token
|
||||||
|
should fail closed into "no refresh", not 500."""
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(float(v), tz=timezone.utc)
|
||||||
|
except (TypeError, ValueError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _next_exp(login_at: datetime, now: datetime) -> datetime:
|
||||||
|
"""Whichever comes first: another IDLE_MINUTES of quiet from now, or the
|
||||||
|
absolute SESSION_HOURS ceiling measured from the session's original
|
||||||
|
sign-in. Shared by create_token and maybe_refresh_token so the two can't
|
||||||
|
drift apart."""
|
||||||
|
ceiling = login_at + timedelta(hours=SESSION_HOURS)
|
||||||
|
idle_edge = now + timedelta(minutes=IDLE_MINUTES)
|
||||||
|
return min(ceiling, idle_edge)
|
||||||
|
|
||||||
|
|
||||||
def create_token(user: "models.User") -> str:
|
def create_token(user: "models.User") -> str:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
payload = {
|
payload = {
|
||||||
@@ -170,7 +210,48 @@ def create_token(user: "models.User") -> str:
|
|||||||
"role": user.role,
|
"role": user.role,
|
||||||
"ver": user.token_version or 0,
|
"ver": user.token_version or 0,
|
||||||
"iat": now,
|
"iat": now,
|
||||||
"exp": now + timedelta(hours=SESSION_HOURS),
|
"login_at": now.timestamp(),
|
||||||
|
"exp": _next_exp(now, now),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
|
||||||
|
|
||||||
|
|
||||||
|
def maybe_refresh_token(claims: dict) -> Optional[str]:
|
||||||
|
"""Given a validated token's claims, return a reissued token if the
|
||||||
|
session is worth extending, or None if nothing should change. Called from
|
||||||
|
`auth_gate` on every authenticated request (D18) - deliberately reads only
|
||||||
|
the already-validated claims, never the database, so it costs nothing
|
||||||
|
beyond the JWT encode itself. The separate is_active/token_version check
|
||||||
|
in get_current_user is unaffected either way.
|
||||||
|
|
||||||
|
Three ways this returns None: past the absolute ceiling (session is done,
|
||||||
|
full re-login required - never extended, not even by a second); the
|
||||||
|
claims can't be parsed (fails closed into no-refresh rather than guessing);
|
||||||
|
or a refresh happened recently enough that a new cookie isn't worth
|
||||||
|
writing yet (_REFRESH_SLACK)."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
login_at = _parse_claim_dt(claims.get("login_at"))
|
||||||
|
if login_at is None:
|
||||||
|
# Pre-D18 token (no login_at claim) - fall back to iat so it still
|
||||||
|
# gets a real ceiling instead of riding on the old flat exp forever.
|
||||||
|
login_at = _parse_claim_dt(claims.get("iat"))
|
||||||
|
if login_at is None:
|
||||||
|
return None
|
||||||
|
ceiling = login_at + timedelta(hours=SESSION_HOURS)
|
||||||
|
if now >= ceiling:
|
||||||
|
return None
|
||||||
|
new_exp = _next_exp(login_at, now)
|
||||||
|
current_exp = _parse_claim_dt(claims.get("exp"))
|
||||||
|
if current_exp is not None and (new_exp - current_exp) < _REFRESH_SLACK:
|
||||||
|
return None
|
||||||
|
payload = {
|
||||||
|
"sub": claims.get("sub"),
|
||||||
|
"username": claims.get("username"),
|
||||||
|
"role": claims.get("role"),
|
||||||
|
"ver": claims.get("ver", 0),
|
||||||
|
"iat": now,
|
||||||
|
"login_at": login_at.timestamp(),
|
||||||
|
"exp": new_exp,
|
||||||
}
|
}
|
||||||
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
|
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
|
||||||
|
|
||||||
@@ -182,7 +263,10 @@ def decode_token(token: str) -> Optional[dict]:
|
|||||||
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
|
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
|
||||||
except jwt.PyJWTError:
|
except jwt.PyJWTError:
|
||||||
return None
|
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"):
|
if claims.get("typ"):
|
||||||
return None
|
return None
|
||||||
return claims
|
return claims
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
# CN=PRIME CONTROLS ROOT CA
|
|
||||||
-----BEGIN CERTIFICATE-----
|
|
||||||
MIIFHzCCAwegAwIBAgIQOnMIcdMiP6pELiXfh2ZmLzANBgkqhkiG9w0BAQsFADAhMR8wHQYDVQQD
|
|
||||||
ExZQUklNRSBDT05UUk9MUyBST09UIENBMCAXDTIxMDkwOTEzMjM1OFoYDzIwNTEwOTA5MTMzMzU2
|
|
||||||
WjAhMR8wHQYDVQQDExZQUklNRSBDT05UUk9MUyBST09UIENBMIICIjANBgkqhkiG9w0BAQEFAAOC
|
|
||||||
Ag8AMIICCgKCAgEAri9Vkf+l3bnjGXWMEX15BYRnQTSKUStctG2NBppJ/lwj2LbHAvHf6HdkJAxx
|
|
||||||
2lqDiG0R+D9NcGEi427XOQ78Nc9aJe4jOwip5u5Md6Szwmu4QDRjUy11dDBvRoftD550052O1WOV
|
|
||||||
0OY5hxcZIo7bOfqDLesHG/Y74GJvrYai/4xq440uN6iTaMmsfzbIahIXP39NhuW4i4dgkQkRSfqX
|
|
||||||
y8i3AGS8WhpViVvIlMgGXRrCcBW8MnVp32OvKE2MqQnjZI2i5f8wMT4L0J0DXlSPbV6FPLdpBXl5
|
|
||||||
+OX+9qcEH6hI+Mv8sC/xGLAt/wwBP6E2kM2lovGGIUhBsay0UM03PJsh4r6q3HV5gpH6uYZlOgOY
|
|
||||||
UW59pNT/VyESdZb4kfEGHNHrHy3uNb8Q71UzQxrq/UVKXnBUMN/1PszV4YA08ZaYf2EGItZoNM3v
|
|
||||||
uTOJIifgtAo1DxaTygmglgk8CHlaN4IU8jotrzdksLYQ9MQaD5xnYADCnIf1eZ5VCF+fhpgQOCCV
|
|
||||||
4TfJ20rR14+jy6L4yKYGgtvg8H+hvhucOOnktpV/zLv4H9pItIzbQPuxNdrEmco2i7zrsMuaNYD/
|
|
||||||
tmbGf4mhy20zFOkdOjXGeb4cTF4HIBh3aue2T7o08vi1pkVN+8RXa7lZNbLJnpgtfTr9DKBpW2Fd
|
|
||||||
LqAb1zN2Azorg3kCAwEAAaNRME8wCwYDVR0PBAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0O
|
|
||||||
BBYEFMoWYtRcmyQQmB3AD4DPiau0r9XRMBAGCSsGAQQBgjcVAQQDAgEAMA0GCSqGSIb3DQEBCwUA
|
|
||||||
A4ICAQB0XhWlrK81ZKW9bBlbQe/i6js7ciDhwObgKB0FGZJH8rqK5HFwM/wZbh9h0t+YPk/ev6oG
|
|
||||||
BXU0rJUr1YVMERBb37c/rHBU4Bnv9r6cLhsfiTTcVGqZTM7JE9JfdmJQ10V1awSFoxU1cYMSzot9
|
|
||||||
TyrspkZJ6JFJH2wdhZirIySQHH5WuqEOdG6g9/bDW2X27B0S62s8WNIfP+gugkTDkiHQvoUDi8GY
|
|
||||||
/DA29lZTKPI8PhmKYZvw+fi1weqIYsFLk9pvHW21nIv6qAT7nfuGvuUR5siB7sFbw80XEX6Em8O0
|
|
||||||
0lnP6u+vecBqLK7D34X+aupDlkZlZdPoa0EaTwvTO8pkecZCdMLcTkB2quc/fcyCmVdj4CGn3yND
|
|
||||||
cUUR5wZbHtuCU27Rc4d3rY0gxPNpK3EXkTSOQL7BgR6EkwwNwUqeYmFb/SyXYeSqpdChvsWKgRrP
|
|
||||||
+8n27SeJ01ezk8GMDC0YcOJAAHxXqqOAJo1IwxVvpRkoljKIAprQwHAGUNTmKy+SJXT73/LpPTX+
|
|
||||||
RnbNTG27UfYONh5/DdkGc1wwmIp1X1a1tAb3MNRasiBGlYJ3NGDHVUwgEtmcVeVXWsj1ZhaXq2YX
|
|
||||||
TFsDl9m1IMhFD1n8pLJTLd7AT8Exxga+OzFjMzDu0uzKw3KoAVIX/IgfybdKexVT4Z+nBCY9N+n9
|
|
||||||
/vpujg==
|
|
||||||
-----END CERTIFICATE-----
|
|
||||||
# CN=PRIME CONTROLS ISSUING CA 1, DC=prime, DC=local
|
|
||||||
-----BEGIN CERTIFICATE-----
|
|
||||||
MIIH/jCCBeagAwIBAgITdQAAAAI3j8Wt9kAshQAAAAAAAjANBgkqhkiG9w0BAQsFADAhMR8wHQYD
|
|
||||||
VQQDExZQUklNRSBDT05UUk9MUyBST09UIENBMB4XDTIxMDkwOTE0NDUxM1oXDTM2MDkwOTE0NTUx
|
|
||||||
M1owVDEVMBMGCgmSJomT8ixkARkWBWxvY2FsMRUwEwYKCZImiZPyLGQBGRYFcHJpbWUxJDAiBgNV
|
|
||||||
BAMTG1BSSU1FIENPTlRST0xTIElTU1VJTkcgQ0EgMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC
|
|
||||||
AgoCggIBANvhsMj7RooZ+FzAExgWLex++F1QsdDqcGkwqjaUI5AziI4yb/FMqnO93Sus60hiqql+
|
|
||||||
hfxW/a55huBfo54kOyp5oWAjd0akfJQvFY8sXsWIGz07wiy0msvZDtHdBXy561zb0SSNpJS1Vceb
|
|
||||||
ihyDvM5Tyc6QAzO1u76QJPkegn+v6lpWmodba0kWVvRg/P3SLCnq1LdU8oA1VhIiCkCbdry0Syop
|
|
||||||
bVX0+evzINGmv81VbHct8ptZTBLctM11C16IwPEmntvOqVKpT166/aUO8FMqyHQQZ32ZHBp6ORNu
|
|
||||||
WlCN/EDdgT2s7Vy9/7Hr6gy1IPjEGNYq1yYxcpth+6/RgvxFnt9SWr7B9qKtYe1rN8r+6qYVw4ne
|
|
||||||
c0L7vRvw8HTjOJ9EQnPfID9+34Y8OQkqIHnnF80yjMGCdvh/tR/tXsUlz+Byt9m5MFdhPE22MX1x
|
|
||||||
jyCJFug1Ufso8toAVZcavsRfX0ygeUkv+zZDZKHmF910rB8Cdb980cpKtFvx/v0BNWnzgAhgGkqO
|
|
||||||
ttUf0PKUeGDIW6DMUKhs5JM7ED5rkepvNG5vePDVy/YidhbM4x1ph+HFVkHgT6rd1M4NeCm818C0
|
|
||||||
MVsofXdOOdbjmkQyxPGukl9EG1ukYMfUIQpFozTbbHKNUDfqcdHu8Qm39K7opwxMOiLprXGRSgx2
|
|
||||||
vMHI82mtAgMBAAGjggL6MIIC9jAQBgkrBgEEAYI3FQEEAwIBADAdBgNVHQ4EFgQU94Bw+7t8JXHx
|
|
||||||
VZoWN/pFMo005ZwwGQYJKwYBBAGCNxQCBAweCgBTAHUAYgBDAEEwCwYDVR0PBAQDAgGGMA8GA1Ud
|
|
||||||
EwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUyhZi1FybJBCYHcAPgM+Jq7Sv1dEwggEvBgNVHR8EggEm
|
|
||||||
MIIBIjCCAR6gggEaoIIBFoaByGxkYXA6Ly8vQ049UFJJTUUlMjBDT05UUk9MUyUyMFJPT1QlMjBD
|
|
||||||
QSxDTj1QUklNRS1ST09UQ0EsQ049Q0RQLENOPVB1YmxpYyUyMEtleSUyMFNlcnZpY2VzLENOPVNl
|
|
||||||
cnZpY2VzLENOPUNvbmZpZ3VyYXRpb24sREM9cHJpbWUsREM9bG9jYWw/Y2VydGlmaWNhdGVSZXZv
|
|
||||||
Y2F0aW9uTGlzdD9iYXNlP29iamVjdENsYXNzPWNSTERpc3RyaWJ1dGlvblBvaW50hklodHRwOi8v
|
|
||||||
Y3JsLnByaW1lLWNvbnRyb2xzLmNvbS9DZXJ0RW5yb2xsL1BSSU1FJTIwQ09OVFJPTFMlMjBST09U
|
|
||||||
JTIwQ0EuY3JsMIIBNAYIKwYBBQUHAQEEggEmMIIBIjCBuwYIKwYBBQUHMAKGga5sZGFwOi8vL0NO
|
|
||||||
PVBSSU1FJTIwQ09OVFJPTFMlMjBST09UJTIwQ0EsQ049QUlBLENOPVB1YmxpYyUyMEtleSUyMFNl
|
|
||||||
cnZpY2VzLENOPVNlcnZpY2VzLENOPUNvbmZpZ3VyYXRpb24sREM9cHJpbWUsREM9bG9jYWw/Y0FD
|
|
||||||
ZXJ0aWZpY2F0ZT9iYXNlP29iamVjdENsYXNzPWNlcnRpZmljYXRpb25BdXRob3JpdHkwYgYIKwYB
|
|
||||||
BQUHMAKGVmh0dHA6Ly9jcmwucHJpbWUtY29udHJvbHMuY29tL0NlcnRFbnJvbGwvUFJJTUUtUk9P
|
|
||||||
VENBX1BSSU1FJTIwQ09OVFJPTFMlMjBST09UJTIwQ0EuY3J0MA0GCSqGSIb3DQEBCwUAA4ICAQAw
|
|
||||||
2SIuBMB8JWC/YGbh3LJDt9T/z1BwEniLwEKu4SyBMfW3qJoLR0Zps8xHlCIlUjisZBSilHDNW4uW
|
|
||||||
4891yqg104OR0dx94dQV2Y6Aw9V4tvlw+GGnWpHbUNwP3/JizlndR6ZdhdnJlIt6g7xCy4LwyXrw
|
|
||||||
f22CibC1EWUVnsYJ8ez+LxSprWiUaEME8+bEaVuajXRitYyoG0asvtESeBhR3lwaxuzBqyAuGEfc
|
|
||||||
P8pO/fgjigripPScXnp6opQRzZ12VSNsN8BUmtCfsqX8DK7iRsn/QLZtOKVf6kapPkorovetKwai
|
|
||||||
wasE2mpKJweKYhGwWXdHrXZfCp6biTzG0oX4DGke7tHxdA92ArgmPR4SsNnpekUGcbUgkdpOc0sC
|
|
||||||
mt+LXzRvFKJNd205u/FvpZlBzRZl+NQ++9OGAywyzOb4Lxli49G0yFQ5Fpq0UElqDKiLzUqUR2Ye
|
|
||||||
qDT9nMJNTZEyP6hEjQQ+tSD9XFIlFWD83AyBHhRNKKZfPiu4mJxNYtW1ltVu5Z1EvBfbcRwc0lQa
|
|
||||||
VKGQS23sSIx6heq2q5FqhnYZ15zSXqaAH680Up5mOkeGIfm45rMIFl/aMAJ2Fntl+Taebosf2rv5
|
|
||||||
M/QOJs494ePbXxCJ1kzCLaqgucDXtadJ7ZV2AKhu3B9OiDrS8sv990Yn95ItlpthpVetiXxLHg==
|
|
||||||
-----END CERTIFICATE-----
|
|
||||||
@@ -1,419 +0,0 @@
|
|||||||
"""LDAPS authentication against the Windows domain — D13, built in T10.1.
|
|
||||||
|
|
||||||
The suite stores no passwords. A sign-in is a **simple bind** to
|
|
||||||
`ldaps://prime.local:636` as `<sAMAccountName>@prime.local` using the password the
|
|
||||||
person typed; a successful bind IS the authentication. This module owns that
|
|
||||||
conversation and nothing else — it does not touch the database, does not issue
|
|
||||||
sessions, and does not decide what anyone is allowed to do. `server/auth.py` and
|
|
||||||
the `login` route in `server/app.py` do that.
|
|
||||||
|
|
||||||
WHY `prime.local` AND NOT A DC NAME OR AN IP (this is load-bearing, do not "fix" it):
|
|
||||||
every domain controller's certificate carries `prime.local` in its SAN alongside its
|
|
||||||
own hostname, and the domain name round-robins in DNS across all six DCs published
|
|
||||||
in `_ldap._tcp.prime.local`. So connecting to the domain name both passes hostname
|
|
||||||
validation and gives failover in one move. Connecting to `192.168.3.37` instead
|
|
||||||
fails with `hostname mismatch` — the DC certificates carry no IP SAN — and the only
|
|
||||||
way to "fix" that is to disable the check, which is the one thing that must not
|
|
||||||
happen here (see below).
|
|
||||||
|
|
||||||
THE CLIENT PRESENTS NO CERTIFICATE. This module is the TLS *client*; clients verify,
|
|
||||||
they do not present. What it needs is a trust anchor: `PRIME CONTROLS ROOT CA` plus
|
|
||||||
`PRIME CONTROLS ISSUING CA 1`, shipped as a PEM bundle at `server/certs/`. Those are
|
|
||||||
public certificates — no private key, nothing secret, nothing issued to this app.
|
|
||||||
|
|
||||||
TWO WAYS THIS GOES CATASTROPHICALLY WRONG, both guarded here:
|
|
||||||
|
|
||||||
1. An EMPTY PASSWORD. In LDAP a simple bind with an empty password is an
|
|
||||||
*anonymous* bind, and it SUCCEEDS. Without an explicit guard a blank password
|
|
||||||
authenticates as whatever username was submitted. `verify()` therefore rejects
|
|
||||||
an empty or whitespace-only password BEFORE `bind()` is ever called. If you are
|
|
||||||
refactoring this file and that check looks redundant, it is not.
|
|
||||||
|
|
||||||
2. `validate=ssl.CERT_NONE`. It still encrypts, so it fails silently — what it
|
|
||||||
loses is the ability to tell the real DC from someone who terminates the TLS
|
|
||||||
session, harvests the domain password and relays the bind onward. Domain
|
|
||||||
credentials cross this channel, so that turns an app compromise into a Windows
|
|
||||||
compromise. `CERT_REQUIRED` with an explicit CA file is the only mode here, and
|
|
||||||
the system trust store is deliberately NOT used: it currently trusts five other
|
|
||||||
self-signed CAs on this estate, any of which could issue a DC-shaped cert.
|
|
||||||
|
|
||||||
FAILED BINDS COUNT AGAINST THE DOMAIN LOCKOUT POLICY. That is why nothing in here
|
|
||||||
retries a rejected credential — only genuine network failures are retried, and a
|
|
||||||
`bind()` that returns False is final. The caller throttles before it gets this far
|
|
||||||
(T10.2); this module's job is not to make the problem worse.
|
|
||||||
|
|
||||||
Unconfigured is a first-class state, as it is for `MICRON_DB_URL` in `assets_db.py`:
|
|
||||||
with no CA bundle or no `ldap3` installed, `is_configured()` is False and `verify()`
|
|
||||||
returns `UNCONFIGURED` rather than raising. Since D13 leaves no local password
|
|
||||||
fallback, the caller must surface that state loudly — an unconfigured deploy and a
|
|
||||||
mistyped password look identical at the login box otherwise.
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import ssl
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
log = logging.getLogger("wpsuite.ldap")
|
|
||||||
|
|
||||||
# ldap3 is pinned in requirements.txt. The import is guarded rather than assumed so
|
|
||||||
# that this module — and the test suite that imports it — still loads on a checkout
|
|
||||||
# where the dependency has not been installed. `is_configured()` reports the truth,
|
|
||||||
# and the startup line from `describe()` says so out loud.
|
|
||||||
try:
|
|
||||||
from ldap3 import Server, Connection, Tls, SIMPLE, SUBTREE, NONE
|
|
||||||
from ldap3.core.exceptions import (
|
|
||||||
LDAPException,
|
|
||||||
LDAPSocketOpenError,
|
|
||||||
LDAPSessionTerminatedByServerError,
|
|
||||||
LDAPCertificateError,
|
|
||||||
)
|
|
||||||
from ldap3.utils.conv import escape_filter_chars
|
|
||||||
HAVE_LDAP3 = True
|
|
||||||
except ImportError: # pragma: no cover
|
|
||||||
HAVE_LDAP3 = False
|
|
||||||
LDAPException = LDAPSocketOpenError = Exception
|
|
||||||
LDAPSessionTerminatedByServerError = LDAPCertificateError = Exception
|
|
||||||
|
|
||||||
def escape_filter_chars(x, encoding=None): # type: ignore[misc]
|
|
||||||
raise RuntimeError("ldap3 is not installed")
|
|
||||||
|
|
||||||
|
|
||||||
# ── configuration ─────────────────────────────────────────────────────────────
|
|
||||||
# Module level, matching how server/auth.py reads AUTH_SESSION_HOURS. Tests patch
|
|
||||||
# these attributes directly rather than re-importing.
|
|
||||||
DOMAIN = os.getenv("LDAP_DOMAIN", "prime.local")
|
|
||||||
# Defaults to the domain name on purpose — see the module docstring. Override only
|
|
||||||
# if you have a reason, and never with an IP address.
|
|
||||||
HOST = os.getenv("LDAP_HOST", "") or DOMAIN
|
|
||||||
PORT = int(os.getenv("LDAP_PORT", "636"))
|
|
||||||
CA_FILE = os.getenv("LDAP_CA_FILE", "") or os.path.join(
|
|
||||||
os.path.dirname(os.path.abspath(__file__)), "certs", "prime-ca-chain.pem"
|
|
||||||
)
|
|
||||||
TIMEOUT_SECONDS = int(os.getenv("LDAP_TIMEOUT_SECONDS", "8"))
|
|
||||||
# Retries apply to CONNECT failures only, never to a rejected credential. Two extra
|
|
||||||
# attempts covers the realistic case: DNS round-robin handed out a DC that is
|
|
||||||
# rebooting for patching.
|
|
||||||
CONNECT_RETRIES = int(os.getenv("LDAP_CONNECT_RETRIES", "2"))
|
|
||||||
# The initial value and the fallback for the admin-console setting added in T10.5.
|
|
||||||
REQUIRED_GROUP = os.getenv("LDAP_REQUIRED_GROUP", "")
|
|
||||||
|
|
||||||
# AD's "member of, transitively" extensible match. Plain `memberOf` is DIRECT
|
|
||||||
# membership only and would wrongly refuse anyone who is in a nested child group,
|
|
||||||
# which is how most estates actually organise access.
|
|
||||||
NESTED_MEMBER_RULE = "1.2.840.113556.1.4.1941"
|
|
||||||
|
|
||||||
_USER_ATTRS = ["sAMAccountName", "mail", "displayName", "userPrincipalName"]
|
|
||||||
|
|
||||||
# Reason codes. Machine-readable, for logs and for the caller's branching — never
|
|
||||||
# for a response body, because several of them would leak whether an account exists.
|
|
||||||
OK = "ok"
|
|
||||||
EMPTY_INPUT = "empty_input"
|
|
||||||
UNCONFIGURED = "unconfigured"
|
|
||||||
UNREACHABLE = "unreachable"
|
|
||||||
UNTRUSTED = "untrusted"
|
|
||||||
BAD_CREDENTIALS = "bad_credentials"
|
|
||||||
NOT_IN_GROUP = "not_in_group"
|
|
||||||
NO_DIRECTORY_ENTRY = "no_directory_entry"
|
|
||||||
GROUP_NOT_FOUND = "group_not_found"
|
|
||||||
|
|
||||||
# AD returns these as `data <code>` inside an error-49 message. Kept for the log
|
|
||||||
# only: telling an unauthenticated caller "your password expired" confirms the
|
|
||||||
# account exists, which `login()` goes out of its way not to do.
|
|
||||||
_ERR49 = {
|
|
||||||
"525": "no such user",
|
|
||||||
"52e": "bad password",
|
|
||||||
"530": "not permitted at this time",
|
|
||||||
"531": "not permitted at this workstation",
|
|
||||||
"532": "password expired",
|
|
||||||
"533": "account disabled",
|
|
||||||
"701": "account expired",
|
|
||||||
"773": "must change password",
|
|
||||||
"775": "account locked out",
|
|
||||||
}
|
|
||||||
_ERR49_RE = re.compile(r"data\s+([0-9a-fA-F]{3})")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class LdapResult:
|
|
||||||
"""Outcome of a bind attempt.
|
|
||||||
|
|
||||||
`ok` is the only field the caller should branch on for allow/deny. `reason` and
|
|
||||||
`detail` are for logs. `sam`/`mail`/`full_name` are populated only on success and
|
|
||||||
are what T10.4 uses to match or provision the local account.
|
|
||||||
"""
|
|
||||||
ok: bool
|
|
||||||
reason: str = ""
|
|
||||||
detail: str = ""
|
|
||||||
sam: str = ""
|
|
||||||
mail: str = ""
|
|
||||||
full_name: str = ""
|
|
||||||
upn: str = ""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_config_problem(self) -> bool:
|
|
||||||
"""True when the failure is ours, not the user's. With no local password
|
|
||||||
fallback (D13) these must be logged as errors and surfaced to an operator —
|
|
||||||
otherwise a broken deploy is indistinguishable from a forgotten password."""
|
|
||||||
return self.reason in (UNCONFIGURED, UNREACHABLE, UNTRUSTED, GROUP_NOT_FOUND)
|
|
||||||
|
|
||||||
|
|
||||||
def base_dn(domain: Optional[str] = None) -> str:
|
|
||||||
"""`prime.local` -> `DC=prime,DC=local`."""
|
|
||||||
d = (domain or DOMAIN or "").strip().strip(".")
|
|
||||||
return ",".join(f"DC={part}" for part in d.split(".") if part)
|
|
||||||
|
|
||||||
|
|
||||||
def is_configured() -> bool:
|
|
||||||
"""Whether a bind could even be attempted. Deliberately does not touch the
|
|
||||||
network — `selftest()` does that."""
|
|
||||||
return bool(HAVE_LDAP3 and HOST and CA_FILE and os.path.isfile(CA_FILE))
|
|
||||||
|
|
||||||
|
|
||||||
def describe() -> str:
|
|
||||||
"""One line for the startup log. D13 removed the local password path, so an
|
|
||||||
operator needs to see this in `docker compose logs api` rather than discovering
|
|
||||||
it when the first person cannot sign in."""
|
|
||||||
from . import ldap_fake
|
|
||||||
if ldap_fake.is_active():
|
|
||||||
return ("*** FAKE DIRECTORY ACTIVE — passwords come from "
|
|
||||||
f"{ldap_fake.ENV_VAR}, NOT from the domain. Tests only. ***")
|
|
||||||
if not HAVE_LDAP3:
|
|
||||||
return "LDAP auth DISABLED — ldap3 is not installed. No one can sign in."
|
|
||||||
if not CA_FILE or not os.path.isfile(CA_FILE):
|
|
||||||
return (f"LDAP auth DISABLED — CA bundle not found at {CA_FILE!r}. "
|
|
||||||
f"No one can sign in. Set LDAP_CA_FILE.")
|
|
||||||
group = REQUIRED_GROUP or "(none configured — every domain account may sign in)"
|
|
||||||
return (f"LDAP auth enabled — ldaps://{HOST}:{PORT}, domain {DOMAIN}, "
|
|
||||||
f"CA {CA_FILE}, required group: {group}")
|
|
||||||
|
|
||||||
|
|
||||||
def _tls() -> "Tls":
|
|
||||||
"""The only TLS configuration in this module.
|
|
||||||
|
|
||||||
`CERT_REQUIRED` plus an explicit `ca_certs_file`. ldap3 performs the hostname
|
|
||||||
check against the certificate's SAN itself whenever validate is not CERT_NONE,
|
|
||||||
which is what makes connecting by IP fail instead of quietly succeeding.
|
|
||||||
"""
|
|
||||||
# No `version=` pin: ldap3's default negotiates the highest version both ends
|
|
||||||
# support, which against these DCs is TLS 1.3. Pinning PROTOCOL_TLSv1_2 here
|
|
||||||
# would silently DOWNGRADE every connection to 1.2.
|
|
||||||
return Tls(
|
|
||||||
ca_certs_file=CA_FILE,
|
|
||||||
validate=ssl.CERT_REQUIRED,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _server() -> "Server":
|
|
||||||
return Server(
|
|
||||||
HOST, port=PORT, use_ssl=True, tls=_tls(),
|
|
||||||
connect_timeout=TIMEOUT_SECONDS, get_info=NONE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _err49(result: Optional[dict]) -> str:
|
|
||||||
"""Human-readable AD sub-code from a bind failure, for the log only."""
|
|
||||||
msg = ((result or {}).get("message") or "")
|
|
||||||
m = _ERR49_RE.search(msg)
|
|
||||||
if not m:
|
|
||||||
return ((result or {}).get("description") or "invalid credentials")
|
|
||||||
code = m.group(1).lower()
|
|
||||||
return f"{code} ({_ERR49.get(code, 'unrecognised sub-code')})"
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_username(raw: str) -> str:
|
|
||||||
"""Reduce whatever was typed in the login box to a `sAMAccountName`.
|
|
||||||
|
|
||||||
People type their email address. The mail domain here (`prime-controls.com`) is
|
|
||||||
not the AD domain (`prime.local`), so an address is never a valid bind string —
|
|
||||||
the local part is used instead. This assumes the mail local part equals the
|
|
||||||
sAMAccountName, which is the norm but not guaranteed; where it differs the person
|
|
||||||
must type their short logon name, and we log it so that is diagnosable.
|
|
||||||
|
|
||||||
ONE candidate is produced, never a list to try in turn: every rejected bind
|
|
||||||
counts against the domain lockout policy, so guessing would let a handful of
|
|
||||||
login attempts lock a real account out of Windows.
|
|
||||||
"""
|
|
||||||
name = (raw or "").strip()
|
|
||||||
if not name:
|
|
||||||
return ""
|
|
||||||
# DOMAIN\user, as typed by anyone used to a Windows logon prompt.
|
|
||||||
if "\\" in name:
|
|
||||||
name = name.rsplit("\\", 1)[1].strip()
|
|
||||||
if "@" in name:
|
|
||||||
local, _, dom = name.partition("@")
|
|
||||||
log.info("login input %r looks like an address; binding as sAMAccountName %r "
|
|
||||||
"(mail domain %r is not the AD domain)", name, local.strip(), dom)
|
|
||||||
name = local.strip()
|
|
||||||
return name
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_group_dn(conn, group: str) -> Optional[str]:
|
|
||||||
"""Accept either a distinguished name or a plain group name, return a DN."""
|
|
||||||
g = (group or "").strip()
|
|
||||||
if not g:
|
|
||||||
return None
|
|
||||||
if "," in g and "=" in g:
|
|
||||||
return g # already a DN
|
|
||||||
esc = escape_filter_chars(g)
|
|
||||||
conn.search(base_dn(), f"(&(objectClass=group)(|(cn={esc})(sAMAccountName={esc})))",
|
|
||||||
search_scope=SUBTREE, attributes=["cn"], size_limit=2)
|
|
||||||
if not conn.entries:
|
|
||||||
return None
|
|
||||||
if len(conn.entries) > 1:
|
|
||||||
log.warning("group %r is ambiguous in the directory (%d matches); using %s",
|
|
||||||
g, len(conn.entries), conn.entries[0].entry_dn)
|
|
||||||
return conn.entries[0].entry_dn
|
|
||||||
|
|
||||||
|
|
||||||
def member_of(conn, sam: str, group: str) -> bool:
|
|
||||||
"""Nested-group-aware membership test for an already-bound connection."""
|
|
||||||
dn = _resolve_group_dn(conn, group)
|
|
||||||
if not dn:
|
|
||||||
log.error("required group %r does not resolve in %s — refusing the sign-in. "
|
|
||||||
"This is a configuration fault, not a bad password.", group, base_dn())
|
|
||||||
raise LookupError(GROUP_NOT_FOUND)
|
|
||||||
filt = (f"(&(sAMAccountName={escape_filter_chars(sam)})"
|
|
||||||
f"(memberOf:{NESTED_MEMBER_RULE}:={escape_filter_chars(dn)}))")
|
|
||||||
conn.search(base_dn(), filt, search_scope=SUBTREE,
|
|
||||||
attributes=["sAMAccountName"], size_limit=1)
|
|
||||||
return bool(conn.entries)
|
|
||||||
|
|
||||||
|
|
||||||
def verify(username: str, password: str, required_group: Optional[str] = None) -> LdapResult:
|
|
||||||
"""Authenticate against the domain. The only entry point the app should call.
|
|
||||||
|
|
||||||
`required_group` overrides the `LDAP_REQUIRED_GROUP` default so the admin-console
|
|
||||||
setting (T10.5) wins. Pass an empty string to mean "no group gate"; pass None to
|
|
||||||
use the configured default.
|
|
||||||
"""
|
|
||||||
sam = normalize_username(username)
|
|
||||||
|
|
||||||
# ── guard 1: no bind on empty input ──────────────────────────────────────
|
|
||||||
# An empty password makes the bind below an ANONYMOUS bind, which SUCCEEDS and
|
|
||||||
# would authenticate `sam` without proving anything at all. Must stay before
|
|
||||||
# every return path that reaches bind().
|
|
||||||
if not sam or not (password or "").strip():
|
|
||||||
return LdapResult(False, EMPTY_INPUT, "empty username or password")
|
|
||||||
|
|
||||||
group = REQUIRED_GROUP if required_group is None else required_group
|
|
||||||
|
|
||||||
# Test seam (T10.7). Deliberately placed AFTER the empty-input guard above, so
|
|
||||||
# the anonymous-bind guard covers the fake path too — a fake that re-implemented
|
|
||||||
# it would let the real one rot without any test noticing. `is_active()` refuses
|
|
||||||
# whenever a non-SQLite DATABASE_URL is configured; see server/ldap_fake.py.
|
|
||||||
from . import ldap_fake
|
|
||||||
if ldap_fake.is_active():
|
|
||||||
ok, reason, attrs = ldap_fake.lookup(sam, password, group)
|
|
||||||
if not ok:
|
|
||||||
log.info("fake directory refused %r: %s", sam, reason)
|
|
||||||
return LdapResult(False, reason, "fake directory")
|
|
||||||
return LdapResult(True, OK, "fake directory", **attrs)
|
|
||||||
|
|
||||||
if not is_configured():
|
|
||||||
return LdapResult(False, UNCONFIGURED, describe())
|
|
||||||
|
|
||||||
bind_user = f"{sam}@{DOMAIN}"
|
|
||||||
last_network_error = ""
|
|
||||||
|
|
||||||
# Retries cover CONNECT failures only: DNS round-robin across six DCs will
|
|
||||||
# eventually hand out one that is rebooting. A rejected credential returns
|
|
||||||
# immediately and is never retried — each attempt counts against AD lockout.
|
|
||||||
for attempt in range(1, max(1, CONNECT_RETRIES + 1) + 1):
|
|
||||||
conn = None
|
|
||||||
try:
|
|
||||||
conn = Connection(
|
|
||||||
_server(), user=bind_user, password=password,
|
|
||||||
authentication=SIMPLE, read_only=True,
|
|
||||||
receive_timeout=TIMEOUT_SECONDS, raise_exceptions=False,
|
|
||||||
)
|
|
||||||
if not conn.bind():
|
|
||||||
detail = _err49(conn.result)
|
|
||||||
log.info("bind refused for %r: %s", sam, detail)
|
|
||||||
return LdapResult(False, BAD_CREDENTIALS, detail)
|
|
||||||
|
|
||||||
# Bound as the user. AD lets an account read its own object, so no
|
|
||||||
# service account is needed for either of the next two steps.
|
|
||||||
conn.search(base_dn(),
|
|
||||||
f"(&(objectClass=user)(sAMAccountName={escape_filter_chars(sam)}))",
|
|
||||||
search_scope=SUBTREE, attributes=_USER_ATTRS, size_limit=1)
|
|
||||||
if not conn.entries:
|
|
||||||
log.error("bind succeeded for %r but the account has no readable "
|
|
||||||
"directory entry under %s", sam, base_dn())
|
|
||||||
return LdapResult(False, NO_DIRECTORY_ENTRY, "no readable directory entry")
|
|
||||||
e = conn.entries[0]
|
|
||||||
|
|
||||||
def one(attr: str) -> str:
|
|
||||||
v = getattr(e, attr, None)
|
|
||||||
return str(v.value) if v is not None and v.value else ""
|
|
||||||
|
|
||||||
if group:
|
|
||||||
try:
|
|
||||||
if not member_of(conn, sam, group):
|
|
||||||
log.info("bind succeeded for %r but the account is not in %r",
|
|
||||||
sam, group)
|
|
||||||
return LdapResult(False, NOT_IN_GROUP, f"not in {group}")
|
|
||||||
except LookupError:
|
|
||||||
return LdapResult(False, GROUP_NOT_FOUND, f"group {group!r} not found")
|
|
||||||
|
|
||||||
return LdapResult(
|
|
||||||
True, OK,
|
|
||||||
sam=one("sAMAccountName") or sam,
|
|
||||||
mail=one("mail"),
|
|
||||||
full_name=one("displayName"),
|
|
||||||
upn=one("userPrincipalName"),
|
|
||||||
)
|
|
||||||
|
|
||||||
except LDAPCertificateError as exc:
|
|
||||||
# NOT retried and NOT downgraded. Either the CA bundle is wrong or
|
|
||||||
# something is impersonating a DC; both need a human, and retrying with
|
|
||||||
# relaxed validation is exactly the wrong instinct.
|
|
||||||
log.error("LDAPS certificate validation FAILED against %s: %s. Refusing "
|
|
||||||
"to continue — check LDAP_CA_FILE, and never set CERT_NONE.",
|
|
||||||
HOST, exc)
|
|
||||||
return LdapResult(False, UNTRUSTED, str(exc))
|
|
||||||
except (LDAPSocketOpenError, LDAPSessionTerminatedByServerError) as exc:
|
|
||||||
last_network_error = str(exc)
|
|
||||||
log.warning("LDAPS connect to %s:%s failed (attempt %d): %s",
|
|
||||||
HOST, PORT, attempt, exc)
|
|
||||||
continue
|
|
||||||
except LDAPException as exc:
|
|
||||||
log.error("LDAP error for %r: %s", sam, exc)
|
|
||||||
return LdapResult(False, UNREACHABLE, str(exc))
|
|
||||||
finally:
|
|
||||||
if conn is not None:
|
|
||||||
try:
|
|
||||||
conn.unbind()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return LdapResult(False, UNREACHABLE,
|
|
||||||
last_network_error or f"no domain controller answered on {HOST}:{PORT}")
|
|
||||||
|
|
||||||
|
|
||||||
def selftest() -> LdapResult:
|
|
||||||
"""Open a TLS session to the domain and validate the certificate, WITHOUT binding.
|
|
||||||
|
|
||||||
Used by the startup check and by the admin console's diagnostics. Touches no
|
|
||||||
account, so it cannot contribute to a lockout. Proves the three things that
|
|
||||||
actually break a deploy: DNS resolves, a DC answers on 636, and the presented
|
|
||||||
certificate validates against our CA bundle.
|
|
||||||
"""
|
|
||||||
if not is_configured():
|
|
||||||
return LdapResult(False, UNCONFIGURED, describe())
|
|
||||||
conn = None
|
|
||||||
try:
|
|
||||||
conn = Connection(_server(), receive_timeout=TIMEOUT_SECONDS, raise_exceptions=True)
|
|
||||||
conn.open()
|
|
||||||
return LdapResult(True, OK, f"ldaps://{HOST}:{PORT} certificate validates")
|
|
||||||
except LDAPCertificateError as exc:
|
|
||||||
return LdapResult(False, UNTRUSTED, str(exc))
|
|
||||||
except LDAPException as exc:
|
|
||||||
return LdapResult(False, UNREACHABLE, str(exc))
|
|
||||||
finally:
|
|
||||||
if conn is not None:
|
|
||||||
try:
|
|
||||||
conn.unbind()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
"""A fake directory, for tests only — D13 / T10.7.
|
|
||||||
|
|
||||||
`server/ldap_auth.py` normally opens an LDAPS connection to a domain controller.
|
|
||||||
Tests cannot: CI has no domain, 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, which is what this module is.
|
|
||||||
|
|
||||||
Set `WP_LDAP_FAKE_DIRECTORY` to a JSON object and `ldap_auth.verify()` answers
|
|
||||||
from it instead of touching the network:
|
|
||||||
|
|
||||||
{"root": {"password": "…", "mail": "root@example.test",
|
|
||||||
"full_name": "Root", "groups": ["WP-Suite-Users"]}}
|
|
||||||
|
|
||||||
`groups` is a flat list of names the account is "in". Nested groups do not exist
|
|
||||||
here — the real `member_of` resolves a DN and uses AD's LDAP_MATCHING_RULE_IN_CHAIN,
|
|
||||||
and faking that faithfully would mean reimplementing AD. A test that cares about
|
|
||||||
nesting has to run against a real directory; this one is honest about being a
|
|
||||||
string comparison.
|
|
||||||
|
|
||||||
THE PRODUCTION GUARD IS THE POINT OF THIS FILE.
|
|
||||||
|
|
||||||
An environment variable that makes any password work is exactly the kind of thing
|
|
||||||
that escapes into production, and D13 removed every other way in — there is no
|
|
||||||
local password to fall back on and no break-glass, so a fake directory silently
|
|
||||||
active in production would be a total authentication bypass with nothing behind it.
|
|
||||||
|
|
||||||
So `is_active()` refuses whenever a real database is configured, using the same
|
|
||||||
test `auth._load_secret` uses to refuse an ephemeral signing key: a non-SQLite
|
|
||||||
`DATABASE_URL` means production, full stop. `ldap_auth.describe()` also shouts
|
|
||||||
when the fake is live, so the startup line can never be mistaken for a real one.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
log = logging.getLogger("wpsuite.ldap.fake")
|
|
||||||
|
|
||||||
ENV_VAR = "WP_LDAP_FAKE_DIRECTORY"
|
|
||||||
|
|
||||||
|
|
||||||
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 ldap_auth, 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 directory — this looks like production, and D13 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 like a bad password
|
|
||||||
log.error("%s is not valid JSON (%s); the fake directory is empty", ENV_VAR, exc)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def lookup(username: str, password: str, required_group: Optional[str]) -> tuple:
|
|
||||||
"""Return (ok, reason, attrs). Mirrors what ldap_auth.verify() needs.
|
|
||||||
|
|
||||||
Deliberately does NOT re-check for an empty password: `verify()` guards that
|
|
||||||
before it ever gets here, and duplicating the check in the fake would let the
|
|
||||||
real guard rot without any test noticing.
|
|
||||||
"""
|
|
||||||
people = directory()
|
|
||||||
who = people.get(username) or people.get(username.lower())
|
|
||||||
if not isinstance(who, dict) or password != who.get("password"):
|
|
||||||
return (False, "bad_credentials", {})
|
|
||||||
if required_group:
|
|
||||||
groups = who.get("groups") or []
|
|
||||||
if required_group not in groups:
|
|
||||||
return (False, "not_in_group", {})
|
|
||||||
return (True, "ok", {
|
|
||||||
"sam": who.get("sam") or username,
|
|
||||||
"mail": who.get("mail", ""),
|
|
||||||
"full_name": who.get("full_name", ""),
|
|
||||||
"upn": who.get("upn", ""),
|
|
||||||
})
|
|
||||||
@@ -1,224 +1,107 @@
|
|||||||
"""Command-line user management for the Work Package Suite.
|
"""Command-line user management for the Work Package Suite.
|
||||||
|
|
||||||
Accounts are not created here any more. D13 provisions them on first successful
|
There is no local password (D15) and no local account creation from here anymore
|
||||||
sign-in, so this tool exists to do the one thing the directory cannot decide:
|
(D16, T10.4) — accounts are created by signing in through Okta, which JIT-
|
||||||
assign the app's PERMISSIONS role. The directory supplies identity; this supplies
|
provisions a row at the lowest-privilege role (see server/okta_auth.py,
|
||||||
authorization.
|
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 promote alice --role admin
|
||||||
python -m server.manage_users list
|
python -m server.manage_users list
|
||||||
python -m server.manage_users promote alice # -> admin
|
|
||||||
python -m server.manage_users promote bob --role project_admin
|
|
||||||
python -m server.manage_users demote alice # -> project_user
|
|
||||||
python -m server.manage_users disable bob
|
python -m server.manage_users disable bob
|
||||||
python -m server.manage_users enable bob
|
python -m server.manage_users enable bob
|
||||||
|
|
||||||
Run from the PROJECT ROOT (same place you run uvicorn) so the package imports and
|
|
||||||
.env resolve the way the API does.
|
|
||||||
|
|
||||||
`create-admin` and `create` are GONE (D14). They were redundant once accounts
|
|
||||||
provision themselves, and removing them closes a whole class of problem: every
|
|
||||||
row now originates from a successful bind, so a username can no longer be typed
|
|
||||||
in wrong and end up orphaned from the directory identity it was meant to match.
|
|
||||||
|
|
||||||
BOOTSTRAPPING THE FIRST ADMIN is therefore two steps, in this order:
|
|
||||||
1. Sign in to the app once. That provisions your account at project_user.
|
|
||||||
2. Run `promote <your-sAMAccountName>` here.
|
|
||||||
|
|
||||||
EVERY COMMAND THAT CHANGES ANYTHING REQUIRES A DOMAIN BIND (D14). Shell access
|
|
||||||
alone is no longer enough to mint an admin. Be clear about what that is and is
|
|
||||||
not worth: anyone with a shell on this container can still write to the `users`
|
|
||||||
table directly with psql or sqlite3, so this is defence in depth and — mostly —
|
|
||||||
ACCOUNTABILITY. Before D14 every role change made from a shell was invisible in
|
|
||||||
the audit trail while the same change through the console was recorded. Now both
|
|
||||||
are recorded, and both name a person.
|
|
||||||
|
|
||||||
The bind here deliberately does NOT apply the login group gate. If a mistyped
|
|
||||||
required group locks everyone out of the console, this tool has to still work —
|
|
||||||
otherwise the only way to fix the lockout is the only thing the lockout prevents.
|
|
||||||
|
|
||||||
`list` needs no credential, so an outage stays diagnosable.
|
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import getpass
|
|
||||||
import sys
|
import sys
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from .db import SessionLocal, Base, engine
|
from .db import SessionLocal, Base, engine
|
||||||
from . import models, auth, ldap_auth
|
from . import models, auth
|
||||||
|
|
||||||
|
|
||||||
def _gen_id(prefix: str = "user") -> str:
|
|
||||||
return f"{prefix}_{uuid.uuid4().hex[:12]}"
|
|
||||||
|
|
||||||
|
|
||||||
def _audit(db, actor: str, action: str, user: "models.User", detail: dict) -> None:
|
|
||||||
"""Append an audit row in the caller's transaction.
|
|
||||||
|
|
||||||
Written by hand rather than via app.py's log_event: importing that would drag
|
|
||||||
FastAPI and the entire application into a CLI startup for one INSERT.
|
|
||||||
"""
|
|
||||||
db.add(models.AuditLog(
|
|
||||||
id=_gen_id("ev"), actor=actor, action=action, entity_type="user",
|
|
||||||
entity_id=user.id, summary=user.username, detail=detail,
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|
||||||
def authenticate_operator() -> str:
|
|
||||||
"""Prompt for a domain credential, bind, and return the operator's sAMAccountName.
|
|
||||||
|
|
||||||
Exits on failure — a command that changes a role must not proceed unauthenticated.
|
|
||||||
The password is only ever read from a hidden prompt: there is no --password flag,
|
|
||||||
because that would put a live domain password into shell history and into the
|
|
||||||
output of `ps` for every other user on the box.
|
|
||||||
"""
|
|
||||||
if not ldap_auth.is_configured():
|
|
||||||
sys.exit(f"Cannot authenticate: {ldap_auth.describe()}\n"
|
|
||||||
f"This command needs a domain bind. Fix the LDAP configuration first.")
|
|
||||||
who = input("Your domain username: ").strip()
|
|
||||||
if not who:
|
|
||||||
sys.exit("Cancelled.")
|
|
||||||
pw = getpass.getpass("Your domain password: ")
|
|
||||||
if not pw:
|
|
||||||
sys.exit("Cancelled.")
|
|
||||||
# required_group="" on purpose: see the module docstring. The login group must
|
|
||||||
# not be able to lock an operator out of the tool that fixes the login group.
|
|
||||||
result = ldap_auth.verify(who, pw, required_group="")
|
|
||||||
if not result.ok:
|
|
||||||
# SAY WHY. The /api/auth/login endpoint deliberately returns one generic
|
|
||||||
# message so an unauthenticated caller cannot enumerate accounts; that
|
|
||||||
# reasoning does NOT transfer here. This is a local tool, the operator is
|
|
||||||
# the account holder, and there is nobody to leak to — so withholding the
|
|
||||||
# AD sub-code only makes a failure undiagnosable. An earlier version of
|
|
||||||
# this function printed "Authentication failed." and nothing else.
|
|
||||||
print(f"Authentication failed: {result.reason} — {result.detail}", file=sys.stderr)
|
|
||||||
print(f" bind attempted as : {ldap_auth.normalize_username(who)}@{ldap_auth.DOMAIN}",
|
|
||||||
file=sys.stderr)
|
|
||||||
print(f" server : ldaps://{ldap_auth.HOST}:{ldap_auth.PORT}", file=sys.stderr)
|
|
||||||
print(f" password length : {len(pw)} characters", file=sys.stderr)
|
|
||||||
if result.reason == ldap_auth.BAD_CREDENTIALS:
|
|
||||||
print(" The sub-code above is AD's own reason: 52e = wrong password, "
|
|
||||||
"775 = account locked out, 532 = password expired, "
|
|
||||||
"533 = account disabled, 525 = no such user.", file=sys.stderr)
|
|
||||||
print(" A 525 with a password you know is correct means the BIND NAME is "
|
|
||||||
"wrong, not the password. This binds as <sAMAccountName>@LDAP_DOMAIN, "
|
|
||||||
"which only works where that matches your real UPN suffix — set "
|
|
||||||
"LDAP_DOMAIN to the UPN suffix if yours differs from the AD DNS name.",
|
|
||||||
file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
print(f"Authenticated as {result.sam}.")
|
|
||||||
return result.sam
|
|
||||||
|
|
||||||
|
|
||||||
def _load(db, username: str) -> "models.User":
|
|
||||||
u = auth.find_user(db, username)
|
|
||||||
if not u:
|
|
||||||
sys.exit(f"No account named '{username}'. Accounts are created on first "
|
|
||||||
f"sign-in — has this person signed in yet? `list` shows who exists.")
|
|
||||||
return u
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_list(args) -> None:
|
|
||||||
"""Read-only, and deliberately needs no credential: during an outage this is
|
|
||||||
how you find out what the app thinks the world looks like."""
|
|
||||||
with SessionLocal() as db:
|
|
||||||
rows = db.query(models.User).order_by(models.User.username).all()
|
|
||||||
if not rows:
|
|
||||||
print("No accounts yet. They are created on first successful sign-in.")
|
|
||||||
return
|
|
||||||
print(f"{'USERNAME':<24}{'ROLE':<20}{'ACTIVE':<8}{'LAST LOGIN':<22}{'NAME'}")
|
|
||||||
for u in rows:
|
|
||||||
last = u.last_login_at.strftime("%Y-%m-%d %H:%M") if u.last_login_at else "never"
|
|
||||||
print(f"{u.username:<24}{auth.normalize_role(u.role):<20}"
|
|
||||||
f"{('yes' if u.is_active else 'no'):<8}{last:<22}{u.full_name}")
|
|
||||||
|
|
||||||
|
|
||||||
def _set_role(username: str, role: str) -> None:
|
|
||||||
if role not in auth.ROLES:
|
|
||||||
sys.exit(f"role must be one of {', '.join(auth.ROLES)}")
|
|
||||||
operator = authenticate_operator()
|
|
||||||
with SessionLocal() as db:
|
|
||||||
u = _load(db, username)
|
|
||||||
old = auth.normalize_role(u.role)
|
|
||||||
if old == role:
|
|
||||||
print(f"{u.username} is already {role}. Nothing to do.")
|
|
||||||
return
|
|
||||||
# The last-admin guard, matching set_user_role in app.py: an app with no
|
|
||||||
# admin cannot be administered, and there is no password login left to
|
|
||||||
# recover through.
|
|
||||||
if old == auth.ROLE_ADMIN and role != auth.ROLE_ADMIN:
|
|
||||||
others = db.query(models.User).filter(
|
|
||||||
models.User.role == auth.ROLE_ADMIN,
|
|
||||||
models.User.id != u.id,
|
|
||||||
models.User.is_active.is_(True),
|
|
||||||
).count()
|
|
||||||
if not others:
|
|
||||||
sys.exit("Refusing: that is the last active admin account. Promote "
|
|
||||||
"someone else first.")
|
|
||||||
detail = {"from": old, "to": role, "via": "manage_users"}
|
|
||||||
u.role = role
|
|
||||||
# Mirrors set_user_role: an admin already reaches every project, so the
|
|
||||||
# default-member flag would sit there doing nothing and spring back to life
|
|
||||||
# on demotion.
|
|
||||||
if role == auth.ROLE_ADMIN and (u.auto_add_projects or u.auto_add_role):
|
|
||||||
u.auto_add_projects = False
|
|
||||||
u.auto_add_role = ""
|
|
||||||
detail["auto_add_cleared"] = True
|
|
||||||
# NOTE: unlike the API, this permits changing your OWN role. The endpoint
|
|
||||||
# forbids it to stop an admin locking themselves out of the console; here it
|
|
||||||
# is the entire bootstrap path — sign in, then promote yourself.
|
|
||||||
if u.username.lower() == operator.lower():
|
|
||||||
detail["self"] = True
|
|
||||||
_audit(db, operator, "role_changed", u, detail)
|
|
||||||
db.commit()
|
|
||||||
print(f"{u.username}: {old} -> {role}")
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_promote(args) -> None:
|
def cmd_promote(args) -> None:
|
||||||
_set_role(args.username, args.role)
|
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)}")
|
||||||
|
with SessionLocal() as db:
|
||||||
|
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"{u.username} is now {auth.ROLE_LABELS.get(role, role)}.")
|
||||||
|
|
||||||
|
|
||||||
def cmd_demote(args) -> None:
|
def cmd_list(args) -> None:
|
||||||
_set_role(args.username, auth.ROLE_PROJECT_USER)
|
with SessionLocal() as db:
|
||||||
|
rows = db.query(models.User).order_by(models.User.username).all()
|
||||||
|
if not rows:
|
||||||
|
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:
|
||||||
|
print(f"{u.username:<24}{auth.normalize_role(u.role):<20}"
|
||||||
|
f"{('yes' if u.is_active else 'no'):<8}{u.full_name}")
|
||||||
|
|
||||||
|
|
||||||
def _set_active(username: str, active: bool) -> None:
|
def _set_active(username: str, active: bool) -> None:
|
||||||
operator = authenticate_operator()
|
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
u = _load(db, username)
|
u = auth.find_user(db, username)
|
||||||
if bool(u.is_active) == active:
|
if not u:
|
||||||
print(f"{u.username} is already {'enabled' if active else 'disabled'}.")
|
sys.exit(f"No user named '{username}'.")
|
||||||
return
|
|
||||||
u.is_active = active
|
u.is_active = active
|
||||||
# Disabling has to take effect on sessions already issued, and role reads go
|
|
||||||
# through the database on every request — but token_version is what get_current_user
|
|
||||||
# checks, so bump it to sign them out now rather than at session expiry.
|
|
||||||
u.token_version = (u.token_version or 0) + 1
|
|
||||||
_audit(db, operator, "user_active_changed", u,
|
|
||||||
{"is_active": active, "via": "manage_users"})
|
|
||||||
db.commit()
|
db.commit()
|
||||||
print(f"{u.username} is now {'enabled' if active else 'disabled'}.")
|
print(f"{u.username} is now {'enabled' if active else 'disabled'}.")
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
# Ensure tables exist on a fresh local database (SQLite dev). Production owns
|
# Ensure the users table exists even on a fresh database.
|
||||||
# its schema through alembic.
|
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
p = argparse.ArgumentParser(
|
p = argparse.ArgumentParser(prog="manage_users", description="Work Package Suite user management")
|
||||||
prog="manage_users",
|
|
||||||
description="Work Package Suite user management. Accounts are created on "
|
|
||||||
"first sign-in (D13); this assigns roles.")
|
|
||||||
sub = p.add_subparsers(dest="cmd", required=True)
|
sub = p.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
sub.add_parser("list", help="list all accounts (no credential needed)")
|
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)")
|
||||||
|
|
||||||
pr = sub.add_parser("promote", help="raise an account's permissions role (needs a domain bind)")
|
sub.add_parser("list", help="list all accounts")
|
||||||
pr.add_argument("username", help="the person's sAMAccountName")
|
|
||||||
pr.add_argument("--role", default=auth.ROLE_ADMIN, choices=list(auth.ROLES),
|
|
||||||
help="target role (default: admin)")
|
|
||||||
|
|
||||||
dm = sub.add_parser("demote", help=f"set an account back to {auth.ROLE_PROJECT_USER}")
|
|
||||||
dm.add_argument("username", help="the person's sAMAccountName")
|
|
||||||
|
|
||||||
dp = sub.add_parser("disable", help="disable an account (blocks sign-in)")
|
dp = sub.add_parser("disable", help="disable an account (blocks sign-in)")
|
||||||
dp.add_argument("username")
|
dp.add_argument("username")
|
||||||
@@ -226,12 +109,10 @@ def main() -> None:
|
|||||||
ep.add_argument("username")
|
ep.add_argument("username")
|
||||||
|
|
||||||
args = p.parse_args()
|
args = p.parse_args()
|
||||||
if args.cmd == "list":
|
if args.cmd == "promote":
|
||||||
cmd_list(args)
|
|
||||||
elif args.cmd == "promote":
|
|
||||||
cmd_promote(args)
|
cmd_promote(args)
|
||||||
elif args.cmd == "demote":
|
elif args.cmd == "list":
|
||||||
cmd_demote(args)
|
cmd_list(args)
|
||||||
elif args.cmd == "disable":
|
elif args.cmd == "disable":
|
||||||
_set_active(args.username, False)
|
_set_active(args.username, False)
|
||||||
elif args.cmd == "enable":
|
elif args.cmd == "enable":
|
||||||
|
|||||||
@@ -142,14 +142,10 @@ class WorkPackage(Base):
|
|||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
"""A login account. NO PASSWORD IS STORED — D13 moved authentication to an
|
"""A login account. No password is stored here or anywhere else — identity is
|
||||||
LDAPS bind against the domain (see server/ldap_auth.py), and the
|
confirmed by Okta (OIDC), this app only decides what the account may do once
|
||||||
`password_hash` column was dropped. `username` is the sAMAccountName people
|
Okta has vouched for it (see server/okta_auth.py, server/auth.py, D15/D16).
|
||||||
sign in with; an account is created on first successful sign-in if it does not
|
`username` is what Okta's identity claim resolves to.
|
||||||
already exist.
|
|
||||||
|
|
||||||
`is_active` is LOCAL and overrides the directory: clearing it revokes access to
|
|
||||||
this app without touching the domain account.
|
|
||||||
|
|
||||||
Two independent notions of "role", deliberately separate:
|
Two independent notions of "role", deliberately separate:
|
||||||
• role the PERMISSIONS role — what the account may do in the app.
|
• role the PERMISSIONS role — what the account may do in the app.
|
||||||
@@ -187,14 +183,12 @@ class User(Base):
|
|||||||
# Online-guessing throttle (see login()): consecutive failures + a lockout window.
|
# Online-guessing throttle (see login()): consecutive failures + a lockout window.
|
||||||
failed_attempts: Mapped[int] = mapped_column(Integer, default=0)
|
failed_attempts: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
# Bumped to invalidate all existing sessions for this user. Password changes no
|
# Bumped to invalidate all existing sessions for this user (e.g. on a password
|
||||||
# longer exist (D13), but a role change or a deactivation still has to take
|
# change). The value is embedded in the JWT and re-checked on every request.
|
||||||
# effect on live sessions. The value is embedded in the JWT and re-checked on
|
|
||||||
# every request.
|
|
||||||
token_version: Mapped[int] = mapped_column(Integer, default=0)
|
token_version: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
"""Public view of a user."""
|
"""Public view of a user — NEVER includes the password hash."""
|
||||||
return {
|
return {
|
||||||
"id": self.id, "username": self.username, "email": self.email,
|
"id": self.id, "username": self.username, "email": self.email,
|
||||||
"full_name": self.full_name, "role": self.role,
|
"full_name": self.full_name, "role": self.role,
|
||||||
@@ -343,6 +337,45 @@ class AuditLog(Base):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class UsageEvent(Base):
|
||||||
|
"""CR-019: append-only record of who used the suite, when, and which tool —
|
||||||
|
navigation/session activity, not business mutations. Deliberately a SEPARATE
|
||||||
|
table from AuditLog rather than a new `action` value there: AuditLog answers
|
||||||
|
"who changed what" and is read by people auditing a specific record's
|
||||||
|
history; mixing in a `page_open` row for every authenticated page load
|
||||||
|
would make that trail noisy for its existing purpose. This table answers a
|
||||||
|
different question — "who is active, and on what" — and CR-019's admin
|
||||||
|
console reads from here, not from AuditLog.
|
||||||
|
|
||||||
|
Not a ForeignKey to `users`, matching AuditLog's own reasoning: a user who
|
||||||
|
is later removed should still show up in historical activity rather than
|
||||||
|
silently vanishing from it, and `D18`'s deprovisioning sync only ever sets
|
||||||
|
`is_active=False` — it never deletes a row — so this is defensive symmetry
|
||||||
|
rather than a live concern today.
|
||||||
|
|
||||||
|
Retention is indefinite (decided 2026-09-17, `decisions-2026-09-17.md`) —
|
||||||
|
nothing purges rows written here; that is a deliberate product decision,
|
||||||
|
not an oversight to fix later."""
|
||||||
|
__tablename__ = "usage_events"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||||
|
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(200), default="", index=True)
|
||||||
|
project_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
|
||||||
|
# creator | wizard | field_view | dashboard | admin | directory | ...
|
||||||
|
tool: Mapped[str] = mapped_column(String(40), default="", index=True)
|
||||||
|
# page_open | login
|
||||||
|
event: Mapped[str] = mapped_column(String(40), default="", index=True)
|
||||||
|
detail: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id, "at": _iso(self.at), "username": self.username,
|
||||||
|
"project_id": self.project_id, "tool": self.tool, "event": self.event,
|
||||||
|
"detail": self.detail or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class AppSetting(Base):
|
class AppSetting(Base):
|
||||||
"""Admin-editable application settings (feature flags, SMTP config, …) stored
|
"""Admin-editable application settings (feature flags, SMTP config, …) stored
|
||||||
as key -> JSON value. Read/written via /api/settings (admin only). Secrets like
|
as key -> JSON value. Read/written via /api/settings (admin only). Secrets like
|
||||||
|
|||||||
@@ -49,8 +49,7 @@ DEFAULTS = {
|
|||||||
|
|
||||||
# Settings the app needs before anyone is signed in, or that carry no secrets and
|
# Settings the app needs before anyone is signed in, or that carry no secrets and
|
||||||
# are safe for any authenticated user to read (feature flags + localization
|
# are safe for any authenticated user to read (feature flags + localization
|
||||||
# defaults). Self-service password reset is gone with D13 — the login page links to
|
# defaults + whether self-service password reset can work at all).
|
||||||
# Okta instead, so there is nothing left for the client to feature-detect.
|
|
||||||
PUBLIC_KEYS = ("bim_enabled", "default_locale", "default_timezone")
|
PUBLIC_KEYS = ("bim_enabled", "default_locale", "default_timezone")
|
||||||
|
|
||||||
|
|
||||||
@@ -84,9 +83,13 @@ def public_settings(db: Session) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def app_flags(db: Session) -> dict:
|
def app_flags(db: Session) -> dict:
|
||||||
"""Feature flags for any signed-in user (no secrets, no SMTP detail)."""
|
"""Feature flags for any signed-in user (no secrets, no SMTP detail).
|
||||||
|
`password_reset_enabled` tells the login page whether a self-service reset can
|
||||||
|
actually deliver mail — there's no point offering the link otherwise."""
|
||||||
s = get_settings(db)
|
s = get_settings(db)
|
||||||
return {k: s.get(k) for k in PUBLIC_KEYS}
|
out = {k: s.get(k) for k in PUBLIC_KEYS}
|
||||||
|
out["password_reset_enabled"] = bool(s.get("email_enabled")) and smtp_ready(s)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def smtp_ready(s: dict) -> bool:
|
def smtp_ready(s: dict) -> bool:
|
||||||
@@ -121,6 +124,22 @@ def send_email(s: dict, to_addr: str, subject: str, body: str) -> None:
|
|||||||
srv.send_message(msg)
|
srv.send_message(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def send_now(db: Session, to_addr: str, subject: str, body: str) -> bool:
|
||||||
|
"""Send one email immediately, outside the outbox. Used for password resets —
|
||||||
|
a reset link must never sit in a queue, and it must not be persisted in the
|
||||||
|
notifications table where an admin could read it and take over the account.
|
||||||
|
Returns True if it went out."""
|
||||||
|
s = get_settings(db)
|
||||||
|
if not (s.get("email_enabled") and smtp_ready(s) and to_addr):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
send_email(s, to_addr, subject, body)
|
||||||
|
return True
|
||||||
|
except Exception as e: # noqa: BLE001 — never surface SMTP detail to the caller
|
||||||
|
log.warning("password-reset email to %s failed: %s", to_addr, e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def enqueue(db: Session, *, user: "models.User", kind: str, subject: str, body: str,
|
def enqueue(db: Session, *, user: "models.User", kind: str, subject: str, body: str,
|
||||||
link: str = "", wp_id: Optional[str] = None, project_id: Optional[str] = None) -> "models.Notification":
|
link: str = "", wp_id: Optional[str] = None, project_id: Optional[str] = None) -> "models.Notification":
|
||||||
"""Record a notification. Marked 'pending' only if email is enabled + SMTP ready +
|
"""Record a notification. Marked 'pending' only if email is enabled + SMTP ready +
|
||||||
|
|||||||
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,10 +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
|
# MICRON_DB_URL to mssql+pyodbc://…?driver=ODBC+Driver+18+for+SQL+Server
|
||||||
pydantic==2.13.4
|
pydantic==2.13.4
|
||||||
python-dotenv==1.2.2
|
python-dotenv==1.2.2
|
||||||
bcrypt==5.0.0 # password hashing
|
|
||||||
PyJWT==2.13.0 # signed session tokens
|
PyJWT==2.13.0 # signed session tokens
|
||||||
ldap3==2.9.1 # D13: LDAPS simple bind against prime.local. Pure Python,
|
|
||||||
# so no system libldap/OpenLDAP headers in the image. The
|
|
||||||
# trust anchor is server/certs/prime-ca-chain.pem, NOT the
|
|
||||||
# system store — see server/ldap_auth.py.
|
|
||||||
starlette==1.3.1 # pinned transitive (cookie / CORS handling — security-relevant)
|
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
|
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
|
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.
|
does, reusing its opener AND its session-minting (not a second implementation).
|
||||||
Credentials come from the environment so the password never has to appear in a
|
|
||||||
command line or shell history:
|
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_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
|
…or pass --user. Use an admin account: seeding creates a project, and --clean
|
||||||
--clean deletes one, which needs Project Admin on it.
|
deletes one, which needs Project Admin on it.
|
||||||
|
|
||||||
USAGE
|
USAGE
|
||||||
python3 server/seed_demo.py https://wp-suite.company.local --insecure
|
python3 server/seed_demo.py https://wp-suite.company.local --insecure
|
||||||
@@ -48,16 +50,19 @@ import urllib.error
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
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
|
# 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
|
# jar implementation, one session-minting flow, one place to fix. Importing is
|
||||||
# module does its work under `if __name__ == "__main__"`.
|
# safe — that module does its work under `if __name__ == "__main__"`.
|
||||||
from smoketest import build_opener # noqa: E402
|
from smoketest import build_opener, seed_session_cookie # noqa: E402
|
||||||
|
|
||||||
BASE = ""
|
BASE = ""
|
||||||
CTX = None
|
CTX = None
|
||||||
# Carries the cookie jar holding the session issued by /api/auth/login. This
|
# Carries the cookie jar holding the minted session (see AUTHENTICATION above).
|
||||||
# script used to call urllib.request.urlopen() directly, which has no cookie
|
# 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).
|
# support, so the session was dropped and every data route answered 401 (S13).
|
||||||
OPENER = None
|
OPENER = None
|
||||||
DEMO_NUMBER = "DEMO-001" # project number prefix used to find/clean demo data
|
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("--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("--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", ""),
|
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). "
|
help="existing account to sign in as (default: $WP_SEED_USER, then "
|
||||||
"Use an admin account.")
|
"$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)")
|
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
BASE = args.base_url.rstrip("/")
|
BASE = args.base_url.rstrip("/")
|
||||||
if args.insecure:
|
if args.insecure:
|
||||||
CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE
|
CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE
|
||||||
OPENER = build_opener(CTX)
|
OPENER = build_opener(CTX)
|
||||||
|
|
||||||
if not args.user or not args.password:
|
if not args.user:
|
||||||
missing = " and ".join(n for n, v in (("WP_SEED_USER", args.user),
|
|
||||||
("WP_SEED_PASSWORD", args.password)) if not v)
|
|
||||||
return abort(
|
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"
|
" 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"
|
" this can seed without one. Set it and re-run:\n\n"
|
||||||
" export WP_SEED_USER=<admin-account>\n"
|
" export WP_SEED_USER=<admin-account>\n\n"
|
||||||
" export WP_SEED_PASSWORD='…'\n\n"
|
" Or pass --user. WP_SMOKE_USER is accepted too, so one account name serves\n"
|
||||||
" Or pass --user/--password. WP_SMOKE_USER / WP_SMOKE_PASSWORD are accepted\n"
|
" this and smoketest.py.")
|
||||||
" too, so one set of credentials serves this and smoketest.py.")
|
|
||||||
|
|
||||||
# health gate
|
# health gate
|
||||||
try:
|
try:
|
||||||
@@ -148,18 +146,25 @@ def main():
|
|||||||
if st != 200:
|
if st != 200:
|
||||||
print(f"ABORT: /api/health returned {st}"); return 1
|
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
|
# "Sign in" — mint a session directly (see AUTHENTICATION above) and seed it
|
||||||
# request after this one.
|
# into OPENER's jar, so it rides every request after this one.
|
||||||
st, body = call("POST", "/api/auth/login",
|
try:
|
||||||
{"username": args.user, "password": args.password})
|
from server import auth as srv_auth
|
||||||
if st != 200:
|
from server.db import SessionLocal
|
||||||
detail = body.get("detail") if isinstance(body, dict) else body
|
except ImportError as e:
|
||||||
hint = (" The account may be locked: the API locks an account for a while after a\n"
|
return abort(f"cannot import the server package to mint a session: {e}",
|
||||||
" few consecutive failures, so retrying with the wrong password makes this\n"
|
" This needs to run where server/ is importable and AUTH_SECRET_KEY /\n"
|
||||||
" worse. Check the password, then wait out the lockout window."
|
" DATABASE_URL match the target server's — see AUTHENTICATION above.")
|
||||||
if st in (401, 403, 423, 429) else
|
with SessionLocal() as db:
|
||||||
" Unexpected status from the login endpoint — check the API logs.")
|
user = srv_auth.find_user(db, args.user)
|
||||||
return abort(f"could not sign in as '{args.user}' (HTTP {st}): {detail}", hint)
|
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
|
logged_in = True
|
||||||
print(f"Signed in as {args.user}.")
|
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.
|
release gate, metrics, cascade delete) behaves. Stdlib only — no pip, no jq.
|
||||||
|
|
||||||
AUTHENTICATION
|
AUTHENTICATION
|
||||||
Every /api/ route except /api/health requires a session (auth_gate in
|
There is no local password anymore (D15/D16, T10.4) — identity is Okta's job,
|
||||||
server/app.py), so the script signs in first and keeps the session cookie for
|
and Okta requires a real browser to complete, which this stdlib script cannot
|
||||||
the rest of the run. Credentials come from the environment by preference, so a
|
do. So instead of signing in over HTTP the way the front end does, this script
|
||||||
password never has to appear in a command line or shell history:
|
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_USER=smoketest
|
||||||
export WP_SMOKE_PASSWORD='…'
|
|
||||||
python3 server/smoketest.py https://wp-suite.company.local
|
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
|
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);
|
end, and deleting one takes Project Admin on that project (require_project_admin);
|
||||||
@@ -24,26 +31,29 @@ AUTHENTICATION
|
|||||||
discover it in the cleanup step.
|
discover it in the cleanup step.
|
||||||
|
|
||||||
USAGE
|
USAGE
|
||||||
# Against the deployed site (through the NGINX proxy):
|
# From inside the api container (has AUTH_SECRET_KEY and DATABASE_URL; hits
|
||||||
python3 server/smoketest.py https://wp-suite.company.local
|
# FastAPI directly):
|
||||||
|
docker compose exec -e WP_SMOKE_USER api \
|
||||||
# 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 \
|
|
||||||
python /app/server/smoketest.py http://localhost:8000
|
python /app/server/smoketest.py http://localhost:8000
|
||||||
|
|
||||||
|
# Local dev, against the app you're running yourself:
|
||||||
|
export AUTH_SECRET_KEY=... DATABASE_URL=... WP_SMOKE_USER=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:
|
# 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
|
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
|
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
|
could not start (unreachable host, missing credentials, or no account by that
|
||||||
distinct on purpose: "I could not test this" is not the same answer as "this is
|
username). 2 is kept distinct on purpose: "I could not test this" is not the
|
||||||
broken", and conflating them is what made an unauthenticated version of this
|
same answer as "this is broken", and conflating them is what made an
|
||||||
script report a wall of failures against a perfectly healthy stack.
|
unauthenticated version of this script report a wall of failures against a
|
||||||
|
perfectly healthy stack.
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import http.cookiejar
|
import http.cookiejar
|
||||||
@@ -53,6 +63,12 @@ import ssl
|
|||||||
import sys
|
import sys
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
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 ─────────────────────────────────────────────────────
|
# ── tiny colored reporter ─────────────────────────────────────────────────────
|
||||||
_PASS, _FAIL = [], []
|
_PASS, _FAIL = [], []
|
||||||
@@ -66,19 +82,40 @@ def check(name, cond, detail=""):
|
|||||||
|
|
||||||
BASE = ""
|
BASE = ""
|
||||||
CTX = None
|
CTX = None
|
||||||
# One opener for the whole run, carrying the cookie jar that holds the session
|
# 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
|
# urlopen() has no cookie support, which is why the session used to be dropped on
|
||||||
# session used to be dropped on the floor and every data route answered 401.
|
# the floor and every data route answered 401.
|
||||||
OPENER = None
|
OPENER = None
|
||||||
|
COOKIE_JAR = None
|
||||||
|
|
||||||
|
|
||||||
def build_opener(ctx=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:
|
if ctx is not None:
|
||||||
handlers.append(urllib.request.HTTPSHandler(context=ctx))
|
handlers.append(urllib.request.HTTPSHandler(context=ctx))
|
||||||
return urllib.request.build_opener(*handlers)
|
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):
|
def call(method, path, body=None):
|
||||||
"""Returns (status_code, parsed_body). Never raises on HTTP status."""
|
"""Returns (status_code, parsed_body). Never raises on HTTP status."""
|
||||||
url = BASE + path
|
url = BASE + path
|
||||||
@@ -116,10 +153,7 @@ def main():
|
|||||||
ap.add_argument("--insecure", action="store_true", help="skip TLS verification")
|
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("--keep", action="store_true", help="keep the demo project (don't delete)")
|
||||||
ap.add_argument("--user", default=os.getenv("WP_SMOKE_USER", ""),
|
ap.add_argument("--user", default=os.getenv("WP_SMOKE_USER", ""),
|
||||||
help="account to sign in as (default: $WP_SMOKE_USER). Use an admin account.")
|
help="existing 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)")
|
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
BASE = args.base_url.rstrip("/")
|
BASE = args.base_url.rstrip("/")
|
||||||
if args.insecure:
|
if args.insecure:
|
||||||
@@ -128,17 +162,14 @@ def main():
|
|||||||
|
|
||||||
print(f"\nWork Package Suite — API smoke test\nTarget: {BASE}\n")
|
print(f"\nWork Package Suite — API smoke test\nTarget: {BASE}\n")
|
||||||
|
|
||||||
# Refuse to start without credentials rather than running headlong into 401s.
|
# Refuse to start without a username rather than running headlong into 401s.
|
||||||
if not args.user or not args.password:
|
if not args.user:
|
||||||
missing = " and ".join(
|
|
||||||
n for n, v in (("WP_SMOKE_USER", args.user), ("WP_SMOKE_PASSWORD", args.password)) if not v)
|
|
||||||
return abort(
|
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"
|
" 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"
|
" meaningful to test without one. Set it and re-run:\n\n"
|
||||||
" export WP_SMOKE_USER=<admin-account>\n"
|
" export WP_SMOKE_USER=<admin-account>\n\n"
|
||||||
" export WP_SMOKE_PASSWORD='…'\n\n"
|
" Or pass --user. Use an admin account: the run creates a project\n"
|
||||||
" Or pass --user/--password. Use an admin account: the run creates a project\n"
|
|
||||||
" and deletes it again, and the delete needs Project Admin on it.")
|
" and deletes it again, and the delete needs Project Admin on it.")
|
||||||
|
|
||||||
project_id = None
|
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,
|
check("health endpoint returns ok", st == 200 and isinstance(body, dict) and body.get("ok") is True,
|
||||||
f"status={st} body={body}")
|
f"status={st} body={body}")
|
||||||
|
|
||||||
# 2) Sign in. The cookie the response sets is held by OPENER's jar and rides
|
# 2) "Sign in" — mint a session directly (see AUTHENTICATION above) and seed
|
||||||
# every request after this one.
|
# it into OPENER's jar, so it rides every request after this one exactly the
|
||||||
st, body = call("POST", "/api/auth/login",
|
# way a real Set-Cookie response would have.
|
||||||
{"username": args.user, "password": args.password})
|
try:
|
||||||
if st != 200:
|
from server import auth as srv_auth
|
||||||
detail = body.get("detail") if isinstance(body, dict) else body
|
from server.db import SessionLocal
|
||||||
hint = (" The account may be locked: the API locks an account for a while after\n"
|
except ImportError as e:
|
||||||
" a few consecutive failures (AUTH_MAX_ATTEMPTS / AUTH_LOCKOUT_MINUTES),\n"
|
return abort(f"cannot import the server package to mint a session: {e}",
|
||||||
" so re-running with the wrong password makes this worse, not better.\n"
|
" This script now needs to run where server/ is importable and\n"
|
||||||
" Check the password, then wait out the lockout window."
|
" AUTH_SECRET_KEY / DATABASE_URL match the target server's — see\n"
|
||||||
if st in (401, 403, 423, 429) else
|
" AUTHENTICATION above.")
|
||||||
" Unexpected status from the login endpoint — check the API logs.")
|
with SessionLocal() as db:
|
||||||
return abort(f"could not sign in as '{args.user}' (HTTP {st}): {detail}", hint)
|
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
|
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
|
# 3) Prove the session actually travels — this is the check whose absence let
|
||||||
# an unauthenticated version of this script look like a broken stack.
|
# an unauthenticated version of this script look like a broken stack.
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|||||||
|
|
||||||
import cdp # noqa: E402
|
import cdp # noqa: E402
|
||||||
|
|
||||||
PW = "CorrectHorseBattery9"
|
|
||||||
_PASS, _FAIL = [], []
|
_PASS, _FAIL = [], []
|
||||||
|
|
||||||
|
|
||||||
@@ -151,30 +150,29 @@ def seed(db_path):
|
|||||||
for u in db.query(models.User).all()}
|
for u in db.query(models.User).all()}
|
||||||
|
|
||||||
|
|
||||||
# D13 / T10.7. The app authenticates by binding to a domain controller, which no
|
# T10.7. The app authenticates through Okta, which no test can reach, and
|
||||||
# test can reach, and start_server launches it as a SUBPROCESS — so a monkeypatch
|
# start_server launches it as a SUBPROCESS — so a monkeypatch here would never
|
||||||
# here would never reach the code doing the authenticating. server/ldap_fake.py
|
# reach the code doing the authenticating. server/okta_fake.py reads this
|
||||||
# reads this instead, and refuses to work against a non-SQLite database.
|
# instead, and refuses to work against a non-SQLite database.
|
||||||
#
|
#
|
||||||
# Most checks never sign in (seed() mints tokens with auth.create_token and sets
|
# Almost no check ever signs in (seed() mints tokens with auth.create_token and
|
||||||
# the cookie directly), so this matters only where the login FORM is driven —
|
# sets the cookie directly), so this matters only where the sign-in ROUND TRIP
|
||||||
# url_state_check's deep-link-through-login case. It is set for every server here
|
# 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.
|
# 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({
|
FAKE_DIRECTORY = json.dumps({
|
||||||
u: {"password": PW, "mail": f"{u}@example.test", "full_name": u.title(),
|
u: {"email": f"{u}@example.test", "name": u.title()}
|
||||||
"groups": ["WP-Suite-Users"]}
|
|
||||||
for u in ("root", "sue", "pat", "mix", "bob", "sam", "legacy", "new")
|
for u in ("root", "sue", "pat", "mix", "bob", "sam", "legacy", "new")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
def start_server(port, db_path):
|
def start_server(port, db_path, extra_env=None):
|
||||||
env = dict(os.environ)
|
env = dict(os.environ)
|
||||||
env["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/")
|
env["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/")
|
||||||
env.setdefault("AUTH_SECRET_KEY", "browser-check-secret-not-for-production")
|
env.setdefault("AUTH_SECRET_KEY", "browser-check-secret-not-for-production")
|
||||||
env["WP_LDAP_FAKE_DIRECTORY"] = FAKE_DIRECTORY
|
env["WP_OKTA_FAKE_DIRECTORY"] = FAKE_DIRECTORY
|
||||||
# No required group: the fake grants "WP-Suite-Users" to everyone, and a test
|
if extra_env:
|
||||||
# asserting the group gate belongs in ldap_auth_check where it can be explicit.
|
env.update(extra_env)
|
||||||
env.pop("LDAP_REQUIRED_GROUP", None)
|
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
[sys.executable, "-m", "uvicorn", "server.app:app", "--host", "127.0.0.1",
|
[sys.executable, "-m", "uvicorn", "server.app:app", "--host", "127.0.0.1",
|
||||||
"--port", str(port), "--log-level", "warning"],
|
"--port", str(port), "--log-level", "warning"],
|
||||||
@@ -457,7 +455,10 @@ def main():
|
|||||||
finally:
|
finally:
|
||||||
if args.keep_server:
|
if args.keep_server:
|
||||||
print(f"\n --keep-server: still up at {base}, database at {db_path}")
|
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:
|
else:
|
||||||
if server:
|
if server:
|
||||||
# Wait for it to actually exit before deleting the database out from
|
# 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
|
self-injecting component (guarded so the creator's inline copy still wins on
|
||||||
its own page).
|
its own page).
|
||||||
|
|
||||||
Static half greps the counts; browser half drives the password-reset prompt on
|
Static half greps the counts; browser half drives the users console's dialogs
|
||||||
the users console with natives poisoned, and proves validate() answers AT the
|
with natives poisoned and proves they still work end to end.
|
||||||
input while the server round-trip completes 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.
|
Boots its own throwaway SQLite + uvicorn + headless browser; run it alone.
|
||||||
Exit 0 all passed, 1 a failure, 2 could not run.
|
Exit 0 all passed, 1 a failure, 2 could not run.
|
||||||
|
|||||||
@@ -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__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
import cdp # noqa: E402
|
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 = """
|
STUB = """
|
||||||
window.__dialogs = [];
|
window.__dialogs = [];
|
||||||
|
|||||||
@@ -1,267 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Does domain authentication hold its guarantees? — D13 / T10.7.
|
|
||||||
|
|
||||||
Covers the four things `docs/waves/decisions-2026-08-21.md` calls non-negotiable,
|
|
||||||
plus the two provisioning rules that decide whether an existing admin survives the
|
|
||||||
switch. These are the failure modes where the app still *looks* fine:
|
|
||||||
|
|
||||||
1. An empty password must not reach bind(). In LDAP a simple bind with an empty
|
|
||||||
password is an ANONYMOUS bind and it SUCCEEDS — so without the guard, a blank
|
|
||||||
password authenticates as whatever username was submitted.
|
|
||||||
2. TLS must be CERT_REQUIRED with an explicit CA file. CERT_NONE still encrypts,
|
|
||||||
so it fails silently; what it loses is the ability to tell a real DC from
|
|
||||||
someone harvesting domain passwords.
|
|
||||||
3. Group membership must be evaluated through AD's nested-group matching rule.
|
|
||||||
Plain memberOf is direct membership only and wrongly refuses real people.
|
|
||||||
4. The fake directory must be impossible to select against a real database.
|
|
||||||
5. A refused sign-in must create no account.
|
|
||||||
6. An existing admin must still be an admin afterwards.
|
|
||||||
|
|
||||||
Self-contained: throwaway SQLite + its own uvicorn. No browser, no domain — the
|
|
||||||
fake directory (server/ldap_fake.py) stands in for the DC.
|
|
||||||
Exit 0 all passed, 1 a failure, 2 could not run.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import ssl
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
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 = [], []
|
|
||||||
PW = "CorrectHorseBattery9"
|
|
||||||
GROUP = "WP-Suite-Users"
|
|
||||||
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 post(base, path, payload):
|
|
||||||
req = urllib.request.Request(base + path, method="POST",
|
|
||||||
data=json.dumps(payload).encode(),
|
|
||||||
headers={"Content-Type": "application/json"})
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=10) as r:
|
|
||||||
return r.status, json.loads(r.read().decode() or "{}")
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
try:
|
|
||||||
return e.code, json.loads(e.read().decode() or "{}")
|
|
||||||
except Exception:
|
|
||||||
return e.code, {}
|
|
||||||
|
|
||||||
|
|
||||||
def start(port, db_path, fake, required_group=""):
|
|
||||||
env = dict(os.environ)
|
|
||||||
env["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/")
|
|
||||||
env["AUTH_SECRET_KEY"] = "ldap-auth-check-not-for-production"
|
|
||||||
env["WP_LDAP_FAKE_DIRECTORY"] = json.dumps(fake)
|
|
||||||
if required_group:
|
|
||||||
env["LDAP_REQUIRED_GROUP"] = required_group
|
|
||||||
else:
|
|
||||||
env.pop("LDAP_REQUIRED_GROUP", 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 users_in(db_path):
|
|
||||||
"""Read the users table straight out of the given file.
|
|
||||||
|
|
||||||
Deliberately NOT via server.db.SessionLocal: that engine is bound from
|
|
||||||
DATABASE_URL when the module is first imported, so setting the env var later
|
|
||||||
keeps reading whichever database was configured first. Two assertions in this
|
|
||||||
file passed against the wrong file before that was noticed.
|
|
||||||
"""
|
|
||||||
import sqlite3
|
|
||||||
con = sqlite3.connect(db_path)
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
return {r[0]: r[1] for r in con.execute("select username, role from users")}
|
|
||||||
except sqlite3.OperationalError:
|
|
||||||
return {} # no users table yet
|
|
||||||
finally:
|
|
||||||
con.close()
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
print("1. the guards that do not need a server")
|
|
||||||
|
|
||||||
os.environ["DATABASE_URL"] = "sqlite:///./_ldapcheck_unit.db"
|
|
||||||
os.environ["WP_LDAP_FAKE_DIRECTORY"] = json.dumps(
|
|
||||||
{"root": {"password": PW, "groups": [GROUP]}})
|
|
||||||
from server import ldap_auth
|
|
||||||
|
|
||||||
# 1 — the anonymous-bind guard. Connection is nulled so ANY call to bind()
|
|
||||||
# would raise: this proves the guard returns before the transport is touched,
|
|
||||||
# rather than merely that the result is a failure.
|
|
||||||
saved, ldap_auth.Connection = ldap_auth.Connection, None
|
|
||||||
try:
|
|
||||||
for label, u, p in [("an empty password", "root", ""),
|
|
||||||
("a whitespace-only password", "root", " "),
|
|
||||||
("an empty username", "", PW)]:
|
|
||||||
r = ldap_auth.verify(u, p, required_group="")
|
|
||||||
chk(f"{label} is refused without reaching bind()",
|
|
||||||
(not r.ok) and r.reason == ldap_auth.EMPTY_INPUT, r.reason)
|
|
||||||
finally:
|
|
||||||
ldap_auth.Connection = saved
|
|
||||||
|
|
||||||
# 2 — TLS configuration.
|
|
||||||
tls = ldap_auth._tls()
|
|
||||||
chk("TLS validate is CERT_REQUIRED", tls.validate == ssl.CERT_REQUIRED, tls.validate)
|
|
||||||
chk("...with an explicit CA file, not the system store",
|
|
||||||
bool(tls.ca_certs_file) and os.path.isfile(tls.ca_certs_file), tls.ca_certs_file)
|
|
||||||
src = open(os.path.join(ROOT, "server", "ldap_auth.py"), encoding="utf-8").read()
|
|
||||||
# Parse the module rather than grep it: the docstring names validate=ssl.CERT_NONE
|
|
||||||
# in order to explain why it must never be used, and a text search cannot tell
|
|
||||||
# that apart from an actual call. Walk every keyword argument called `validate`
|
|
||||||
# and check what it is really set to.
|
|
||||||
import ast as _ast
|
|
||||||
bad = []
|
|
||||||
for node in _ast.walk(_ast.parse(src)):
|
|
||||||
if isinstance(node, _ast.Call):
|
|
||||||
for kw in node.keywords:
|
|
||||||
if kw.arg == "validate":
|
|
||||||
name = (kw.value.attr if isinstance(kw.value, _ast.Attribute)
|
|
||||||
else getattr(kw.value, "id", ""))
|
|
||||||
if name != "CERT_REQUIRED":
|
|
||||||
bad.append(f"line {node.lineno}: validate={name or '?'}")
|
|
||||||
chk("every validate= in the module is CERT_REQUIRED (AST, not grep)",
|
|
||||||
not bad, "; ".join(bad))
|
|
||||||
|
|
||||||
# 3 — nested groups. The fake cannot model AD nesting (it is a string list), so
|
|
||||||
# what is asserted is that the REAL path builds AD's transitive matching rule
|
|
||||||
# into its filter. A test that truly exercises nesting needs a real directory.
|
|
||||||
chk("membership uses AD's nested matching rule, not plain memberOf",
|
|
||||||
ldap_auth.NESTED_MEMBER_RULE == "1.2.840.113556.1.4.1941"
|
|
||||||
and "memberOf:{NESTED_MEMBER_RULE}:=" in src,
|
|
||||||
"the transitive matching rule is not in the search filter")
|
|
||||||
|
|
||||||
# 4 — the production guard on the fake, in a subprocess because DATABASE_URL is
|
|
||||||
# read at import time.
|
|
||||||
out = subprocess.run(
|
|
||||||
[sys.executable, "-c",
|
|
||||||
"from server import ldap_fake; print(ldap_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_LDAP_FAKE_DIRECTORY": json.dumps({"root": {"password": PW}})})
|
|
||||||
chk("the fake directory REFUSES to work against a non-SQLite database",
|
|
||||||
out.stdout.strip() == "False", out.stdout.strip() or out.stderr[-200:])
|
|
||||||
|
|
||||||
print("\n2. sign-in, against a server")
|
|
||||||
db_fd, db_path = tempfile.mkstemp(suffix=".db"); os.close(db_fd)
|
|
||||||
port = free_port()
|
|
||||||
fake = {"root": {"password": PW, "mail": "root@example.test",
|
|
||||||
"full_name": "Root Person", "groups": [GROUP]},
|
|
||||||
"outsider": {"password": PW, "mail": "outsider@example.test",
|
|
||||||
"full_name": "Out Sider", "groups": ["SomeOtherGroup"]}}
|
|
||||||
server = start(port, db_path, fake, required_group=GROUP)
|
|
||||||
if server is None:
|
|
||||||
print("the test server would not start.")
|
|
||||||
return 2
|
|
||||||
base = f"http://127.0.0.1:{port}"
|
|
||||||
try:
|
|
||||||
st, _ = post(base, "/api/auth/login", {"username": "root", "password": PW})
|
|
||||||
chk("a correct password signs in", st == 200, st)
|
|
||||||
chk("...and provisioned the account at project_user",
|
|
||||||
users_in(db_path).get("root") == "project_user", users_in(db_path))
|
|
||||||
|
|
||||||
st, body = post(base, "/api/auth/login", {"username": "root", "password": "wrong-" + PW})
|
|
||||||
chk("a wrong password is refused", st == 401, st)
|
|
||||||
chk("...with a message that does not say why",
|
|
||||||
"password" in (body.get("detail") or "").lower()
|
|
||||||
and "expired" not in (body.get("detail") or "").lower(), body)
|
|
||||||
|
|
||||||
st, _ = post(base, "/api/auth/login", {"username": "root", "password": ""})
|
|
||||||
chk("a blank password is refused at the endpoint too", st == 401, st)
|
|
||||||
|
|
||||||
before = set(users_in(db_path))
|
|
||||||
st, _ = post(base, "/api/auth/login", {"username": "outsider", "password": PW})
|
|
||||||
chk("a correct password OUTSIDE the required group is refused", st == 401, st)
|
|
||||||
chk("...and no account was created for them",
|
|
||||||
set(users_in(db_path)) == before, set(users_in(db_path)) - before)
|
|
||||||
|
|
||||||
st, _ = post(base, "/api/auth/login", {"username": "nobody", "password": PW})
|
|
||||||
chk("an unknown account is refused and creates nothing",
|
|
||||||
st == 401 and "nobody" not in users_in(db_path), st)
|
|
||||||
finally:
|
|
||||||
server.kill()
|
|
||||||
try:
|
|
||||||
server.wait(timeout=10)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
pass
|
|
||||||
|
|
||||||
print("\n3. an existing admin survives the switch")
|
|
||||||
db_fd2, db2 = tempfile.mkstemp(suffix=".db"); os.close(db_fd2)
|
|
||||||
# Build the schema with a NEW engine bound to this file — see users_in().
|
|
||||||
import sqlalchemy as _sa
|
|
||||||
from server.db import Base
|
|
||||||
from server import models # noqa: F401
|
|
||||||
eng2 = _sa.create_engine("sqlite:///" + db2.replace("\\", "/"))
|
|
||||||
Base.metadata.create_all(bind=eng2)
|
|
||||||
with eng2.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'))"))
|
|
||||||
eng2.dispose()
|
|
||||||
port2 = free_port()
|
|
||||||
server2 = start(port2, db2, fake, required_group=GROUP)
|
|
||||||
if server2 is None:
|
|
||||||
print("the second test server would not start.")
|
|
||||||
return 2
|
|
||||||
try:
|
|
||||||
st, body = post(f"http://127.0.0.1:{port2}", "/api/auth/login",
|
|
||||||
{"username": "root", "password": PW})
|
|
||||||
chk("the pre-existing admin signs in", st == 200, st)
|
|
||||||
chk("...and is STILL an admin (D13 criterion 4)",
|
|
||||||
users_in(db2).get("root") == "admin", users_in(db2))
|
|
||||||
chk("...and their locally-set name was not overwritten by the directory",
|
|
||||||
(body.get("user") or {}).get("full_name") == "Set By Hand", body.get("user"))
|
|
||||||
chk("...and no duplicate account appeared",
|
|
||||||
len(users_in(db2)) == 1, 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)
|
|
||||||
@@ -323,6 +323,13 @@ def run(page, base, tok):
|
|||||||
{"text": "Probe Building One,Probe Level 1,Probe Sector B"})
|
{"text": "Probe Building One,Probe Level 1,Probe Sector B"})
|
||||||
chk("the import reports it as reactivated, not created or duplicate",
|
chk("the import reports it as reactivated, not created or duplicate",
|
||||||
len(again["body"]["reactivated"]) == 1 and not again["body"]["created"], again["body"])
|
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",
|
same = [n for n in api(page, "GET",
|
||||||
"/api/projects/projA/locations?include_inactive=true")["body"]["nodes"]
|
"/api/projects/projA/locations?include_inactive=true")["body"]["nodes"]
|
||||||
if n["path"] == sec["path"]]
|
if n["path"] == sec["path"]]
|
||||||
|
|||||||
@@ -54,6 +54,20 @@ def main():
|
|||||||
chk("locations and materials are both instances of it - not a copy beside it",
|
chk("locations and materials are both instances of it - not a copy beside it",
|
||||||
"locList = WPListImport(" in suite and "matList = WPListImport(" in suite
|
"locList = WPListImport(" in suite and "matList = WPListImport(" in suite
|
||||||
and "function locImport(dryRun){ locList.importText" in suite)
|
and "function locImport(dryRun){ locList.importText" in suite)
|
||||||
|
# The 2026-08-21 outage, pinned: a Boolean server_default of sa.text('1')
|
||||||
|
# passes on SQLite (which coerces 1) and crash-loops Postgres at deploy
|
||||||
|
# (DatatypeMismatch). Every migration must say sa.true()/sa.false().
|
||||||
|
import re as _re
|
||||||
|
bad = []
|
||||||
|
vdir = os.path.join(ROOT, "server", "alembic", "versions")
|
||||||
|
for fn in sorted(os.listdir(vdir)):
|
||||||
|
if not fn.endswith(".py"):
|
||||||
|
continue
|
||||||
|
for ln in open(os.path.join(vdir, fn), encoding="utf-8"):
|
||||||
|
if "Boolean" in ln and "server_default" in ln and not _re.search(r"server_default=sa\.(true|false)\(\)", ln):
|
||||||
|
bad.append("%s: %s" % (fn, ln.strip()[:90]))
|
||||||
|
chk("no migration gives a Boolean a non-portable server_default "
|
||||||
|
"(sa.true()/sa.false() only)", not bad, ascii_(bad[:3]))
|
||||||
model = open(os.path.join(ROOT, "server", "models.py"), encoding="utf-8").read()
|
model = open(os.path.join(ROOT, "server", "models.py"), encoding="utf-8").read()
|
||||||
mat_block = model[model.find("class MaterialItem"):model.find("class WpFile")]
|
mat_block = model[model.find("class MaterialItem"):model.find("class WpFile")]
|
||||||
cols = re.findall(r"^\s+(\w+): Mapped", mat_block, re.M)
|
cols = re.findall(r"^\s+(\w+): Mapped", mat_block, re.M)
|
||||||
@@ -91,6 +105,20 @@ def main():
|
|||||||
_, listing = api(base, "/api/projects/projA/materials", root)
|
_, listing = api(base, "/api/projects/projA/materials", root)
|
||||||
chk("...and nothing was written", listing["items"] == [])
|
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",
|
code, rep = api(base, "/api/projects/projA/materials/import", root, "POST",
|
||||||
{"text": text, "dry_run": False})
|
{"text": text, "dry_run": False})
|
||||||
_, listing = api(base, "/api/projects/projA/materials", root)
|
_, 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__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
import cdp # noqa: E402
|
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, " \
|
READY = "!!document.querySelector('#pipeline-strip .pipe-cell, #pipeline-strip .pipe-empty, " \
|
||||||
"#pipeline-strip .pipe-error')"
|
"#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__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
import cdp # noqa: E402
|
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__)))
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
HTML = os.path.join(ROOT, "html")
|
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.
|
will rest on.
|
||||||
|
|
||||||
1. a URL identifying a work package opens that work package
|
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
|
3. refresh preserves project, package, tab and view
|
||||||
4. Back and Forward move through states without a reload or a broken 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
|
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__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
import cdp # noqa: E402
|
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):
|
def settle(page, seconds=1.4):
|
||||||
@@ -168,7 +174,7 @@ def main():
|
|||||||
finally:
|
finally:
|
||||||
page2.close()
|
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.clear_cookies()
|
||||||
page.goto(deep)
|
page.goto(deep)
|
||||||
settle(page, 1.6)
|
settle(page, 1.6)
|
||||||
@@ -177,23 +183,44 @@ def main():
|
|||||||
nxt = page.eval("new URLSearchParams(location.search).get('next')||''")
|
nxt = page.eval("new URLSearchParams(location.search).get('next')||''")
|
||||||
chk("...carrying the requested target, package id and all",
|
chk("...carrying the requested target, package id and all",
|
||||||
"wp-creation-index.html" in nxt and "wp=wpA1" in nxt, "next=%r" % nxt)
|
"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)
|
# Drive the actual button, not a shortcut to it — its href already
|
||||||
page.eval("document.querySelector('form').requestSubmit"
|
# carries ?next= (login.js's safeNext()); this is the same click a
|
||||||
"? document.querySelector('form').requestSubmit()"
|
# person makes.
|
||||||
": document.querySelector('form').submit()")
|
signin_href = page.eval(
|
||||||
for _ in range(40):
|
"(document.getElementById('okta-signin')||{}).getAttribute('href')||''")
|
||||||
if "wp-creation-index.html" in page.eval("location.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
|
break
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
settle(page, 1.2)
|
chk("the app hands off to the (fake) Okta provider",
|
||||||
# NOT `"wp-creation-index.html" in location.href` — that string is in the
|
"_fake_provider" in page.eval("location.href"), page.eval("location.href"))
|
||||||
# ?next= parameter too, so the check passed while still sitting on
|
|
||||||
# login.html with the sign-in rejected. Assert we actually LEFT the
|
# The fake provider's own picker page — a real page, not a shortcut.
|
||||||
# login page (D13/T10.7: it caught nothing when the bind started failing).
|
# 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")
|
href = page.eval("location.href")
|
||||||
chk("signing in continues to the requested page, not the home page",
|
chk("signing in continues to the requested page, not the home page",
|
||||||
"login.html" not in href and "wp-creation-index.html" in href, href)
|
"login.html" not in href and "wp-creation-index.html" in href
|
||||||
|
and "wp=wpA1" in href, href)
|
||||||
for _ in range(30):
|
for _ in range(30):
|
||||||
if page.eval("!!window.wpCreatorReady"):
|
if page.eval("!!window.wpCreatorReady"):
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -1,192 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Is there exactly one analytics implementation, reported from admin? — D5, T7.10.
|
|
||||||
|
|
||||||
Usage analytics existed twice (creator + wizard), five of the nine colliding
|
|
||||||
globals `creator-frame.md` counted, and the wizard's copy had no caller. One
|
|
||||||
core survives in wp-usage.js; the pages keep thin track() wrappers; the report
|
|
||||||
and its downloads live on the admin console behind the same role gate as the
|
|
||||||
rest of that page. Keys are unchanged, so pre-move data still reads.
|
|
||||||
|
|
||||||
Self-contained: throwaway SQLite, its own uvicorn, headless Edge or Chrome.
|
|
||||||
Exit 0 all passed, 1 a failure, 2 could not run.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
|
|
||||||
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 # noqa: E402
|
|
||||||
from sections_check import set_sop # noqa: E402
|
|
||||||
from stepper_check import dismiss_dialogs # noqa: E402
|
|
||||||
|
|
||||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
HTML = os.path.join(ROOT, "html")
|
|
||||||
|
|
||||||
|
|
||||||
def ascii_(v, n=280):
|
|
||||||
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
|
|
||||||
|
|
||||||
|
|
||||||
def settle(seconds=0.6):
|
|
||||||
time.sleep(seconds)
|
|
||||||
|
|
||||||
|
|
||||||
def strip_js(src):
|
|
||||||
src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
|
|
||||||
return "\n".join(re.sub(r"(?<![:'\"])//.*$", "", ln) for ln in src.split("\n"))
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
exe = cdp.find_browser()
|
|
||||||
if not exe:
|
|
||||||
print("no headless-capable browser found; set WP_BROWSER.")
|
|
||||||
return 2
|
|
||||||
|
|
||||||
# ── 1. the grep half: one implementation, no leftover controls ───────────
|
|
||||||
print("\n1. grep: one implementation, nothing unreferenced")
|
|
||||||
files = {}
|
|
||||||
for name in os.listdir(HTML):
|
|
||||||
if name.endswith((".js", ".html")):
|
|
||||||
files[name] = strip_js(open(os.path.join(HTML, name), encoding="utf-8").read())
|
|
||||||
|
|
||||||
core_defs = [n for n, src in files.items() if "window.WPUsage" in src]
|
|
||||||
chk("the WPUsage core is defined in wp-usage.js and only there",
|
|
||||||
core_defs == ["wp-usage.js"], ascii_(core_defs))
|
|
||||||
chk("the pages record THROUGH it - no page touches the storage keys directly",
|
|
||||||
all("wp_iwp_analytics_v1" not in src and "wp_suite_analytics_v1" not in src
|
|
||||||
for n, src in files.items()
|
|
||||||
if n not in ("wp-usage.js",) and n.endswith(".js")))
|
|
||||||
leftovers = {n: re.findall(r"analyticsLoad|analyticsSave|downloadAnalytics|showAnalytics"
|
|
||||||
r"|ANALYTICS_KEY|USAGE_KEY|usageLoad|downloadUsage", src)
|
|
||||||
for n, src in files.items() if n != "wp-usage.js"}
|
|
||||||
leftovers = {n: v for n, v in leftovers.items() if v}
|
|
||||||
chk("none of the five colliding globals survives anywhere; grep confirms",
|
|
||||||
not leftovers, ascii_(leftovers))
|
|
||||||
chk("no analytics control remains on the creator or the wizard; grep confirms",
|
|
||||||
"Usage data" not in files["wp-creation-index.html"]
|
|
||||||
and "showAnalytics" not in files["work-package-suite.html"])
|
|
||||||
chk("both storage keys survive, verbatim, in the core (data continuity)",
|
|
||||||
"wp_iwp_analytics_v1" in files["wp-usage.js"]
|
|
||||||
and "wp_suite_analytics_v1" in files["wp-usage.js"])
|
|
||||||
|
|
||||||
tmpdir = tempfile.mkdtemp(prefix="wpsuite-usage-")
|
|
||||||
db_path = os.path.join(tmpdir, "check.db")
|
|
||||||
server = None
|
|
||||||
browser = None
|
|
||||||
try:
|
|
||||||
tok = seed(db_path)
|
|
||||||
set_sop(db_path, {})
|
|
||||||
port = cdp.free_port()
|
|
||||||
base = "http://127.0.0.1:%d" % port
|
|
||||||
server = start_server(port, db_path)
|
|
||||||
|
|
||||||
browser = cdp.Browser(exe)
|
|
||||||
page = browser.page()
|
|
||||||
page.clear_cookies()
|
|
||||||
page.set_cookie("wp_session", tok["root"])
|
|
||||||
page.viewport(1440, 900)
|
|
||||||
|
|
||||||
# ── 2. recording still works from both tools ─────────────────────────
|
|
||||||
print("\n2. the tools still record")
|
|
||||||
page.goto(base + "/wp-creation-index.html?project=projA")
|
|
||||||
dismiss_dialogs(page)
|
|
||||||
settle(2.0)
|
|
||||||
# Plant a LEGACY-format event under the pre-move key: the done-when is
|
|
||||||
# that data recorded before this task is still readable after it.
|
|
||||||
page.eval("""(() => {
|
|
||||||
const d = JSON.parse(localStorage.getItem('wp_iwp_analytics_v1')) || {events: []};
|
|
||||||
d.events.unshift({ts: '2026-07-01T10:00:00Z', session: 's_legacy',
|
|
||||||
event: 'legacy_probe_event', detail: null});
|
|
||||||
localStorage.setItem('wp_iwp_analytics_v1', JSON.stringify(d));
|
|
||||||
})()""")
|
|
||||||
n0 = page.eval("WPUsage.load(WPUsage.KEYS.creator).events.length")
|
|
||||||
page.eval("track('probe_event')")
|
|
||||||
chk("the creator's track() still lands events under the old key",
|
|
||||||
page.eval("WPUsage.load(WPUsage.KEYS.creator).events.length") == n0 + 1)
|
|
||||||
|
|
||||||
page.goto(base + "/work-package-suite.html?tab=sop")
|
|
||||||
dismiss_dialogs(page)
|
|
||||||
settle(2.0)
|
|
||||||
n0 = page.eval("WPUsage.load(WPUsage.KEYS.wizard).events.length")
|
|
||||||
page.eval("track('probe_event_wizard')")
|
|
||||||
chk("the wizard's track() still lands events under its old key",
|
|
||||||
page.eval("WPUsage.load(WPUsage.KEYS.wizard).events.length") == n0 + 1)
|
|
||||||
wiz_errors = [e for e in page.js_errors() if "beforeunload" not in e]
|
|
||||||
chk("...and the wizard page throws no errors without its old globals "
|
|
||||||
"(the blocked-beforeunload console line is BL-020, filtered not hidden)",
|
|
||||||
not wiz_errors, ascii_(wiz_errors[:2]))
|
|
||||||
|
|
||||||
# ── 3. the report, on admin, behind the admin gate ───────────────────
|
|
||||||
print("\n3. the admin report")
|
|
||||||
page.goto(base + "/admin.html")
|
|
||||||
dismiss_dialogs(page)
|
|
||||||
settle(2.0)
|
|
||||||
page.eval("loadUsage()")
|
|
||||||
settle(0.5)
|
|
||||||
report = page.eval("(document.getElementById('usage-admin')||{textContent:''}).textContent")
|
|
||||||
chk("usage data is reachable from admin.html, both tools reported",
|
|
||||||
"Work package creator" in report and "SOP wizard" in report, ascii_(report, 160))
|
|
||||||
chk("the pre-move legacy event is readable in the report",
|
|
||||||
"legacy_probe_event" in report)
|
|
||||||
chk("this session's fresh events are in it too",
|
|
||||||
"probe_event" in report and "probe_event_wizard" in report)
|
|
||||||
chk("each tool offers its download from the report",
|
|
||||||
page.eval("[...document.querySelectorAll('#usage-admin button')].length") >= 2)
|
|
||||||
|
|
||||||
page.viewport(390, 844, mobile=True)
|
|
||||||
settle(0.6)
|
|
||||||
fits = page.eval("""(() => {
|
|
||||||
const box = document.getElementById('usage-admin');
|
|
||||||
return box && box.scrollWidth <= box.clientWidth + 2
|
|
||||||
&& document.documentElement.scrollWidth <= 392;
|
|
||||||
})()""")
|
|
||||||
chk("the report is usable at 390px - no sideways scrolling", bool(fits))
|
|
||||||
page.viewport(1440, 900)
|
|
||||||
settle(0.4)
|
|
||||||
|
|
||||||
# The same role gate as the rest of the console: a non-admin sees the
|
|
||||||
# denied card and no cards, this one included.
|
|
||||||
page.clear_cookies()
|
|
||||||
page.set_cookie("wp_session", tok["pat"])
|
|
||||||
page.goto(base + "/admin.html")
|
|
||||||
dismiss_dialogs(page)
|
|
||||||
settle(2.0)
|
|
||||||
chk("a non-administrator gets the denied notice, not the usage report",
|
|
||||||
page.eval("""(() => {
|
|
||||||
const denied = document.getElementById('admin-denied');
|
|
||||||
const usage = document.getElementById('usage-admin');
|
|
||||||
const visible = el => !!el && el.offsetParent !== null;
|
|
||||||
return visible(denied) && !visible(usage);
|
|
||||||
})()"""))
|
|
||||||
|
|
||||||
js_errors = [e for e in page.js_errors() if "beforeunload" not in e]
|
|
||||||
chk("no JavaScript errors anywhere in this run", not js_errors,
|
|
||||||
ascii_(js_errors[:2]))
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if browser is not None:
|
|
||||||
try:
|
|
||||||
browser.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if server is not None:
|
|
||||||
try:
|
|
||||||
server.terminate()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
print("\n" + "-" * 54)
|
|
||||||
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
|
|
||||||
for f in _FAIL:
|
|
||||||
print(" - " + f)
|
|
||||||
return 1 if _FAIL else 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||