Compare commits
24 Commits
docs/bl-02
...
feat/ldaps
| Author | SHA1 | Date | |
|---|---|---|---|
| c99666796a | |||
| 60b5b0c1f2 | |||
| c6a100405d | |||
| 25bc5bcd3f | |||
| b97ccd7ad8 | |||
| 3f4cd7ac92 | |||
| 332b74e5de | |||
| eecd724e02 | |||
| 24fff382c8 | |||
| d261024139 | |||
| 79787b3e9f | |||
| e0008670fc | |||
| c47b2ae210 | |||
| dc0cee240e | |||
| 8d49fb9248 | |||
| 495d87dd72 | |||
| c5540ce6da | |||
| 9f91e9e26b | |||
| ab7bce9f5b | |||
| 8cfb4c1008 | |||
| 0de746bc62 | |||
| 2a3c4a83fa | |||
| 0577660c86 | |||
| 3c9343dfc8 |
29
CLAUDE.md
29
CLAUDE.md
@@ -70,6 +70,31 @@ 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
|
||||
`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
|
||||
|
||||
Approved Aug 14, 2026 (C1). Any component you rebuild ships accessible or it is not done:
|
||||
@@ -90,10 +115,12 @@ 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:
|
||||
|
||||
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
|
||||
gloved-hands surface and is where the worst rendering was found.
|
||||
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).
|
||||
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.
|
||||
|
||||
If a done-when check cannot be verified, do not mark the task complete. Say which check
|
||||
failed and why.
|
||||
|
||||
@@ -1,103 +1,154 @@
|
||||
# Deploy: Work Package Suite — login portal update
|
||||
# Deploy: Work Package Suite — domain sign-in (D13)
|
||||
|
||||
Instructions for the **Portainer admin** to take the new secure login portal live.
|
||||
Instructions for the **Portainer admin** to take domain authentication live.
|
||||
No prior context needed.
|
||||
|
||||
**Repo:** `Project-SDE-WP-Suite` (primegit) — changes are merged to **`main`**.
|
||||
|
||||
**What changed:** the app now has a username/password login. Going live needs:
|
||||
1. one new environment variable,
|
||||
2. a **rebuild** of the stack (not just a restart), and
|
||||
3. creating the first admin account.
|
||||
> **This document replaced an earlier one.** Until Aug 24 2026 it described taking a
|
||||
> **username/password login portal** live: bcrypt hashes, an `AUTH_SECRET_KEY`, and a
|
||||
> first admin created with `manage_users create-admin <user> --password …`. All of
|
||||
> that is gone. The app no longer stores a password of any kind, `create-admin` no
|
||||
> 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
|
||||
> bake the code in at build time (`COPY html/` and `COPY server/` in their
|
||||
> Dockerfiles). A plain restart will **not** pick up the new code — the images must
|
||||
> be **rebuilt** from the latest `main`.
|
||||
> bake the code in at build time (`COPY html/` and `COPY server/`). A plain restart
|
||||
> will **not** pick up the new code — the images must be **rebuilt** from latest `main`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Add an environment variable to the stack
|
||||
## ⚠ Read this before you start
|
||||
|
||||
In the stack's **Environment variables** section, add:
|
||||
**There is no break-glass account.** If the directory is unreachable, the CA bundle
|
||||
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 |
|
||||
|------|-------|-------|
|
||||
| `AUTH_SECRET_KEY` | a long random string | **Required.** Signs the login session cookies. |
|
||||
| `AUTH_SESSION_HOURS` | `12` | *Optional.* Hours a login lasts before re-auth (defaults to 12). |
|
||||
| `AUTH_SECRET_KEY` | a long random string | **Required.** Unchanged — still signs the session cookies. Keep the existing value; changing it signs everyone out. |
|
||||
| `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.* Unchanged. |
|
||||
|
||||
Generate the secret on the host with:
|
||||
You do **not** need to set `LDAP_HOST`, `LDAP_DOMAIN` or `LDAP_CA_FILE`. Their
|
||||
defaults are correct for this estate, and the CA bundle ships inside the image.
|
||||
|
||||
```bash
|
||||
openssl rand -base64 48
|
||||
```
|
||||
**Do not point `LDAP_HOST` at a domain controller's name or at an IP address.** It is
|
||||
set to `prime.local` on purpose: every DC's certificate carries that name in its SAN,
|
||||
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**.
|
||||
|
||||
> If `AUTH_SECRET_KEY` is **not** set, the app still starts but falls back to a random
|
||||
> 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.
|
||||
`AUTH_RESET_MINUTES` and `AUTH_RESET_COOLDOWN_SECONDS` can be deleted if present.
|
||||
They configured the password-reset email, which no longer exists.
|
||||
|
||||
---
|
||||
|
||||
## 2. Pull latest `main`, rebuild, and redeploy
|
||||
|
||||
- Pull the latest commit on `main` and redeploy the stack **with image rebuild enabled**
|
||||
(e.g. "Re-pull and redeploy" / force rebuild). This rebuilds both the `webserver` and
|
||||
`api` images.
|
||||
- New Python dependencies (`bcrypt`, `PyJWT`) are in `requirements.txt` and install
|
||||
automatically during the rebuild.
|
||||
- The `users` table is created automatically on API startup — **no DB migration needed.**
|
||||
- Pull the latest commit on `main` and redeploy **with image rebuild enabled**.
|
||||
- The new Python dependency (`ldap3`) is in `requirements.txt` and installs during
|
||||
the rebuild.
|
||||
- A database migration drops the `users.password_hash` column. It runs automatically
|
||||
at container start. **Every account, role and project membership is preserved** —
|
||||
it removes one column, not any rows.
|
||||
|
||||
---
|
||||
|
||||
## 3. Verify the containers
|
||||
## 3. Verify BEFORE announcing it
|
||||
|
||||
- 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
|
||||
warning — it won't crash — but please confirm it's set.)
|
||||
|
||||
---
|
||||
|
||||
## 4. Create the first admin account
|
||||
|
||||
The login system needs one admin user in the production (Postgres) database. Open the
|
||||
**`wp_api`** container's **Console** (`/bin/sh`) and run:
|
||||
**a. Did the API start at all?**
|
||||
|
||||
```bash
|
||||
python -m server.manage_users create-admin <username> --name "<Full Name>"
|
||||
docker compose logs api | grep -i "LDAP auth"
|
||||
```
|
||||
|
||||
It prompts for a password (minimum 8 characters) and prints `Created admin: <username>`.
|
||||
You want:
|
||||
|
||||
Non-interactive alternative:
|
||||
```
|
||||
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
|
||||
python -m server.manage_users create-admin <username> --name "<Full Name>" --password "<password>"
|
||||
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"
|
||||
```
|
||||
|
||||
Other CLI commands (run the same way): `list`, `create <user> --role user`,
|
||||
`reset-password <user>`, `disable <user>`, `enable <user>`.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 5. Confirm it works
|
||||
## 4. Promote the first admin
|
||||
|
||||
1. Load the site's normal URL — it should redirect to a **login page**.
|
||||
2. Sign in with the admin account from step 4.
|
||||
3. That admin can then add all other users from the in-app **Admin → User
|
||||
administration** page (top-right **Admin** link), so no further shell access is needed.
|
||||
Roles are stored locally and are not read from AD, so someone has to be made an admin
|
||||
once. Sign in first — that creates your account — then:
|
||||
|
||||
```bash
|
||||
docker compose exec api python -m server.manage_users promote <your-sAMAccountName>
|
||||
```
|
||||
|
||||
It asks for **your** domain username and password, binds to confirm who you are, and
|
||||
prints `<user>: project_user -> admin`.
|
||||
|
||||
Other commands: `list` (needs no credential), `demote`, `disable`, `enable`.
|
||||
`create-admin`, `create` and `reset-password` no longer exist.
|
||||
|
||||
After that, admins manage everyone else from the in-app **Admin → User
|
||||
administration** page. No further shell access needed.
|
||||
|
||||
---
|
||||
|
||||
## What people will notice
|
||||
|
||||
- They sign in with their **Windows password**, not an app password.
|
||||
- **"Forgot password?"** now goes to `https://primecontrols.okta.com/`. The app cannot
|
||||
reset a password it does not hold.
|
||||
- The **Change password** item is gone from the top-right menu.
|
||||
- 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
|
||||
|
||||
- `server/auth.py` — bcrypt password hashing, JWT session cookie, the request gate.
|
||||
- `server/app.py` — `/api/auth/*` endpoints + middleware that refuses every `/api` data
|
||||
route without a valid session.
|
||||
- `server/manage_users.py` — the CLI used in step 4.
|
||||
- `html/login.html`, `html/auth-guard.js` — login page and per-page guard.
|
||||
- `html/admin.html` / `admin.js` — Admin Console gated on the admin role, with the user
|
||||
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).
|
||||
- `server/ldap_auth.py` — the LDAPS client: bind, nested-group check, certificate validation.
|
||||
- `server/app.py` — `login()` binds instead of comparing a hash; password endpoints removed.
|
||||
- `server/auth.py` — sessions and roles only; no hashing, no reset tokens.
|
||||
- `server/certs/prime-ca-chain.pem` — the CA bundle that validates the DC certificate.
|
||||
- `server/manage_users.py` — `promote` / `demote`, each requiring a domain bind.
|
||||
- `html/login.html`, `login.js` — one view; "Forgot password?" points at Okta.
|
||||
- Migration `b7e4f1a20c93` — drops `users.password_hash`.
|
||||
|
||||
207
DEPLOYMENT.md
207
DEPLOYMENT.md
@@ -69,10 +69,17 @@ BACKUP_ENC_PASSPHRASE=<strong-random-passphrase>
|
||||
# details.
|
||||
# SMTP_PASSWORD=<smtp-app-password>
|
||||
|
||||
# OPTIONAL — password-reset link lifetime (minutes) and the per-account send
|
||||
# cooldown (seconds). Defaults shown; both only matter once email is enabled.
|
||||
# AUTH_RESET_MINUTES=60
|
||||
# AUTH_RESET_COOLDOWN_SECONDS=120
|
||||
# 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_*`
|
||||
@@ -84,13 +91,34 @@ ignored whenever the three `POSTGRES_*` values are present.
|
||||
|
||||
Generate a strong password with `openssl rand -base64 32`.
|
||||
|
||||
> **Portainer note:** for a Git-based stack these go in the stack's
|
||||
> **Environment variables** section (Portainer doesn't read a local `.env`).
|
||||
> Set `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` / `AUTH_SECRET_KEY` /
|
||||
> `BACKUP_ENC_PASSPHRASE` (and `SMTP_PASSWORD`, if you enable email) there.
|
||||
> **Portainer note:** for a Git-based stack the stack's **Environment variables**
|
||||
> section is not merely an alternative to `.env` — it is the ONLY route, because
|
||||
> Portainer does not read a local `.env` at all. Every value the compose file
|
||||
> references as `${VAR}` has to be set there or it arrives empty.
|
||||
>
|
||||
> The full list, and what an empty one costs you:
|
||||
>
|
||||
> | Variable | Required? | If unset |
|
||||
> |---|---|---|
|
||||
> | `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` | **yes** | the stack will not start |
|
||||
> | `AUTH_SECRET_KEY` | **yes** | compose fails fast; the API refuses to start |
|
||||
> | `LDAP_REQUIRED_GROUP` | **effectively yes** | no group gate — **every account in the domain may sign in**. Silent: sign-in works, so nothing looks wrong. |
|
||||
> | `BACKUP_ENC_PASSPHRASE` | before real data | dumps are written unencrypted |
|
||||
> | `SMTP_PASSWORD` | only with email on | notifications are recorded and never sent |
|
||||
> | `MICRON_DB_URL` | optional | the asset picker degrades to manual entry |
|
||||
>
|
||||
> Paste values raw — it is a form field, not a shell, so no surrounding quotes.
|
||||
> Quotes are not stripped and become part of the value: a quoted
|
||||
> `LDAP_REQUIRED_GROUP` will not resolve, and a quoted `MICRON_DB_URL` will not
|
||||
> parse.
|
||||
>
|
||||
> **`MICRON_DB_URL` must be URL-encoded** (`@` → `%40`, `#` → `%23`, `/` → `%2F`)
|
||||
> because it is a full connection URL. `LDAP_REQUIRED_GROUP` must NOT be encoded —
|
||||
> it is an LDAP distinguished name, and its spaces and commas are legal as they are.
|
||||
|
||||
These are the only credentials in the system, and they never appear in the
|
||||
compose file or in git.
|
||||
`POSTGRES_PASSWORD`, `AUTH_SECRET_KEY`, `BACKUP_ENC_PASSPHRASE`, `SMTP_PASSWORD` and
|
||||
the password inside `MICRON_DB_URL` are the only credentials in the system, and none
|
||||
of them appears in the compose file or in git.
|
||||
|
||||
## 3. Point your reverse proxy at the nginx container
|
||||
|
||||
@@ -257,7 +285,7 @@ users on the same project see the same server-stored SOP and Work Packages.
|
||||
| `sops` | project SOP baselines | `project_id` → projects, `name`, `number`, `complete`, `data` (full SOP JSON) |
|
||||
| `work_packages` | individual IWPs | `project_id` → projects, `sop_id` → sops, `parent_id` (split instances), `number`, `subject`, `type`, `status`, `assignee_id` (owner), `issued_at`, `archived_at`, `data` (full WP JSON) |
|
||||
| `comments` | feedback from any page | `source`, `sop_id`, `wp_id`, `step`, `author`, `text`, `extra` |
|
||||
| `users` | login accounts | `username`, `password_hash` (bcrypt), `role`, `full_name`, `email`, `is_active`, `auto_add_projects` + `auto_add_role` (default membership on new projects), login-lockout + `token_version` fields |
|
||||
| `users` | login accounts (no password — D13) | `username` (sAMAccountName), `role`, `full_name`, `email`, `is_active`, `auto_add_projects` + `auto_add_role` (default membership on new projects), login-lockout + `token_version` fields |
|
||||
| `project_members` | per-project access control | `user_id` → users, `project_id` → projects |
|
||||
| `audit_log` | append-only activity trail | `actor`, `action`, `entity_type`, `entity_id`, `project_id`, `summary`, `detail` |
|
||||
| `notifications` | in-app record + email outbox | `user_id`, `kind`, `wp_id`, `subject`, `status` (pending / sent / failed / skipped) |
|
||||
@@ -296,6 +324,56 @@ docker compose up -d --build webserver # front-end change (html/) — rebuild
|
||||
docker compose up -d --build api # backend change (server/)
|
||||
```
|
||||
|
||||
## Before deploying the D13 auth change — take a backup first
|
||||
|
||||
**Timing is the whole point.** The container's start command is
|
||||
`alembic upgrade head && exec gunicorn`, so migrations run *seconds after you
|
||||
redeploy*. Migration `b7e4f1a20c93` drops `users.password_hash`. There is no window
|
||||
afterwards: back up **before** you redeploy, not after.
|
||||
|
||||
```bash
|
||||
# 1. Record what you are rolling back TO. Do this first; it is easy to forget
|
||||
# and impossible to reconstruct under pressure.
|
||||
git -C /path/to/repo rev-parse --short HEAD
|
||||
|
||||
# 2. One-shot dump, using the sidecar that is already running.
|
||||
docker compose exec backup sh /scripts/db-backup.sh
|
||||
# -> ./backups/wpsuite-<UTC timestamp>.sql.gz[.enc] on the host
|
||||
|
||||
# 3. Take it OUT of the rotation. The scheduled job prunes to the newest
|
||||
# BACKUP_KEEP (default 14) files matching wpsuite-*.sql.gz*, so on a daily
|
||||
# cadence this dump is deleted in a fortnight. A prefix that does not match
|
||||
# the glob is enough to protect it.
|
||||
docker compose exec backup sh -c 'cd /backups && cp "$(ls -1t wpsuite-*.sql.gz* | head -1)" "pre-d13-$(ls -1t wpsuite-*.sql.gz* | head -1)"'
|
||||
|
||||
# 4. Prove it is readable BEFORE you deploy. An untested dump is not a backup.
|
||||
docker compose exec backup sh -c 'openssl enc -d -aes-256-cbc -pbkdf2 -pass env:BACKUP_ENC_PASSPHRASE -in /backups/pre-d13-*.sql.gz.enc | gunzip -c | grep -c "INSERT INTO public.users"'
|
||||
# (drop the openssl stage for an unencrypted .sql.gz)
|
||||
```
|
||||
|
||||
### Two things about this particular dump
|
||||
|
||||
**It is the last copy of every password hash that will ever exist.** After the
|
||||
migration the column is gone; this file is where those bcrypt hashes live from then
|
||||
on. Make sure `BACKUP_ENC_PASSPHRASE` is set before step 2 — the script warns loudly
|
||||
if it is not, and writes plaintext — and decide deliberately how long to keep the
|
||||
file. bcrypt is not plaintext, but it is crackable offline given time and a copy.
|
||||
|
||||
**Restoring the database is not, by itself, a rollback.** The new code has no
|
||||
`password_hash` in its model and the old code requires it, so a restore without a
|
||||
matching code rollback leaves you with a schema and an application that disagree. A
|
||||
real rollback is both, in this order:
|
||||
|
||||
```bash
|
||||
# redeploy the commit from step 1 (Portainer: point the stack back and rebuild)
|
||||
docker compose exec backup sh /scripts/db-restore.sh /backups/pre-d13-wpsuite-<ts>.sql.gz.enc
|
||||
```
|
||||
|
||||
`db-restore.sh` dumps are taken with `--clean --if-exists`, so restoring **drops and
|
||||
recreates** objects before loading. It overwrites whatever is currently there.
|
||||
|
||||
---
|
||||
|
||||
## Backups & retention
|
||||
|
||||
A **`backup` sidecar** (in `docker-compose.yml`) runs `pg_dump` on a schedule and
|
||||
@@ -350,27 +428,96 @@ TLS / From address and flips the master toggle.
|
||||
package contents — so customer IP stays behind the login.
|
||||
- Use the card's **Send test email** button to confirm SMTP before enabling.
|
||||
|
||||
### Self-service password reset
|
||||
### Password reset — there isn't one
|
||||
|
||||
Turning email on also enables **Forgot password** on the login page. Until then the
|
||||
link explains that an admin must reset it (`server/manage_users.py`, or the Admin
|
||||
console's **Reset password** button).
|
||||
D13 removed local passwords entirely. **Turning email on no longer affects sign-in.**
|
||||
The login page's "Forgot password?" links to `https://primecontrols.okta.com/`, which
|
||||
is the only self-service route; the app cannot reset a credential it does not hold.
|
||||
|
||||
- The emailed link carries a short-lived signed token — `AUTH_RESET_MINUTES`
|
||||
(default 60). It is **single-use**: completing a reset bumps the account's
|
||||
`token_version`, which both burns the link and signs out that user's other
|
||||
sessions. A completed reset also clears any login lockout.
|
||||
- `/api/auth/forgot-password` answers **identically for unknown accounts**, so it
|
||||
can't be used to discover usernames. Misses are recorded in the audit log
|
||||
(`password_reset_miss`) instead.
|
||||
- One reset mail per account+client per `AUTH_RESET_COOLDOWN_SECONDS` (default 120)
|
||||
so the form can't be used to flood someone's inbox. The throttle is per worker
|
||||
and in-memory; the token expiry is the real control.
|
||||
- Reset mails are sent **immediately, not through the notifications outbox** — a
|
||||
reset link must never be persisted where an admin could read it and take over an
|
||||
account.
|
||||
- Set `app_base_url` in the admin card, or the emailed link will be relative and
|
||||
therefore useless.
|
||||
Email still carries WP-assignment notifications and the critical-reopen mail.
|
||||
|
||||
---
|
||||
|
||||
## Domain authentication (D13)
|
||||
|
||||
Sign-in is an **LDAPS simple bind** as `<sAMAccountName>@prime.local`. There is no
|
||||
password in the database and **no break-glass account**. If the domain is
|
||||
unreachable, `LDAP_CA_FILE` is wrong, or the required group is misconfigured,
|
||||
**nobody can sign in, including admins.**
|
||||
|
||||
**First thing to check on any sign-in problem** — the API logs one line at startup
|
||||
saying whether LDAP is configured, and `/api/health` stays unauthenticated so the
|
||||
stack is diagnosable while nobody can log in:
|
||||
|
||||
```bash
|
||||
docker compose logs api | grep -i "LDAP auth"
|
||||
# LDAP auth enabled — ldaps://prime.local:636, domain prime.local, …
|
||||
# LDAP auth DISABLED — CA bundle not found at '…'. No one can sign in.
|
||||
curl https://wp-suite.company.local/api/health # → {"ok": true}
|
||||
```
|
||||
|
||||
Then prove the certificate path, without binding — this touches no account and so
|
||||
cannot contribute to a lockout:
|
||||
|
||||
```bash
|
||||
docker compose exec api openssl s_client -connect prime.local:636 -CAfile /app/server/certs/prime-ca-chain.pem </dev/null 2>&1 | grep "Verify return"
|
||||
# want: Verify return code: 0 (ok)
|
||||
```
|
||||
|
||||
### Three things that are not obvious
|
||||
|
||||
**Connect to the domain name, never a DC or an IP.** Every DC's certificate carries
|
||||
`prime.local` in its SAN, so the domain name both passes hostname validation and
|
||||
round-robins across all six DCs published in `_ldap._tcp.prime.local`. An IP gives
|
||||
`Verify return code: 62 (hostname mismatch)` because there is no IP SAN — and the
|
||||
only way to force it through is to disable validation. Do not. Domain passwords
|
||||
cross this link, and an unvalidated one can be terminated by anyone on the network
|
||||
who then harvests them.
|
||||
|
||||
**The CA bundle is not a certificate issued to this app.** The API is the TLS
|
||||
*client*; clients verify, they do not present. `server/certs/prime-ca-chain.pem`
|
||||
contains `PRIME CONTROLS ROOT CA` (valid to 2051) and `PRIME CONTROLS ISSUING CA 1`
|
||||
(2036) — public certificates with no private key. There is nothing to request from
|
||||
IT, no CSR and no enrollment. Rebuild it from any domain-joined machine with:
|
||||
|
||||
```powershell
|
||||
Get-ChildItem Cert:\LocalMachine\Root, Cert:\LocalMachine\CA |
|
||||
Where-Object { $_.Thumbprint -in
|
||||
'C371E91C430A12051029527C443B1EF683675CF3', # PRIME CONTROLS ROOT CA
|
||||
'4F7506105228C73DF64181ACA20AD9783437EC8B' } # PRIME CONTROLS ISSUING CA 1
|
||||
```
|
||||
|
||||
exporting each as Base-64 and concatenating them into one file.
|
||||
|
||||
**The `outbound` network is required.** `internal` has no default gateway, which
|
||||
blocks the LAN and the VPN as well as the internet, so the `api` container cannot
|
||||
reach `prime.local:636` without it. Its comment used to say it was optional if you
|
||||
were not using the Micron asset picker; detaching it now breaks every sign-in.
|
||||
|
||||
### Accounts
|
||||
|
||||
Accounts are **created on first successful sign-in**, at `project_user` with **no
|
||||
project access** — the person signs in and sees nothing until an admin grants it.
|
||||
Roles are local and never read from AD, so an existing admin keeps admin.
|
||||
|
||||
The first admin is bootstrapped in two steps: sign in once, then
|
||||
|
||||
```bash
|
||||
docker compose exec api python -m server.manage_users promote <sAMAccountName>
|
||||
```
|
||||
|
||||
which prompts for *your* domain credential. `list`, `demote`, `disable` and `enable`
|
||||
are the other commands; `create-admin` and `create` no longer exist.
|
||||
|
||||
### The lockout arithmetic
|
||||
|
||||
`AUTH_MAX_ATTEMPTS` defaults to **2**, and that is a safety limit rather than a
|
||||
preference. Failures are now domain binds, so they count against the **AD account
|
||||
lockout policy** (5 on this estate). The throttle is per-process and the API runs 2
|
||||
gunicorn workers, so a local limit of N allows up to 2N binds to reach a DC: 2 × 2 = 4,
|
||||
one under the threshold. **Raising this, or adding a worker, means redoing that
|
||||
arithmetic** — otherwise `/api/auth/login` becomes a way for anyone, unauthenticated,
|
||||
to lock a colleague out of Windows.
|
||||
|
||||
## Permissions roles
|
||||
|
||||
|
||||
@@ -89,6 +89,11 @@ 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` |
|
||||
| 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` |
|
||||
| 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
|
||||
roughly the first third of the effort. It is called out here because the Micron team is
|
||||
|
||||
@@ -35,6 +35,18 @@ services:
|
||||
# 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.
|
||||
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,
|
||||
# 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).
|
||||
@@ -46,10 +58,15 @@ services:
|
||||
condition: service_healthy # waits for postgres to accept connections
|
||||
networks:
|
||||
- internal
|
||||
# Reaching the Micron database means leaving this compose project, and
|
||||
# `internal` is deliberately egress-free. `outbound` is attached to the api
|
||||
# container ONLY — the database and backup containers stay sealed. Detach it
|
||||
# again if you are not using the Micron asset picker.
|
||||
# Reaching the Micron database — and, since D13, the domain controllers —
|
||||
# means leaving this compose project, and `internal` is deliberately
|
||||
# egress-free. `outbound` is attached to the api container ONLY; the database
|
||||
# and backup containers stay sealed.
|
||||
#
|
||||
# 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
|
||||
|
||||
db:
|
||||
@@ -113,7 +130,7 @@ networks:
|
||||
# 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
|
||||
# the LAN and the VPN too — so the api container needs this second network to
|
||||
# reach the Micron asset database. Attached to `api` alone: `db` and `backup`
|
||||
# remain on `internal` only and still have no way off the host.
|
||||
# Detach it from api if you are not using the Micron asset picker.
|
||||
# reach the domain controllers (LDAPS, D13) and the Micron asset database.
|
||||
# Attached to `api` alone: `db` and `backup` remain on `internal` only and
|
||||
# still have no way off the host. Required — see the note on the api service.
|
||||
driver: bridge
|
||||
BIN
docs/reference/baseline/after-wave10/login-1440.png
Normal file
BIN
docs/reference/baseline/after-wave10/login-1440.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
docs/reference/baseline/after-wave10/login-390.png
Normal file
BIN
docs/reference/baseline/after-wave10/login-390.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
BIN
docs/reference/baseline/before-wave10/login-1440.png
Normal file
BIN
docs/reference/baseline/before-wave10/login-1440.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
BIN
docs/reference/baseline/before-wave10/login-390.png
Normal file
BIN
docs/reference/baseline/before-wave10/login-390.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -554,3 +554,98 @@ deliberately deferred.
|
||||
entry rather than a drive-by.
|
||||
- **Suggested wave or follow-up:** next housekeeping pass, with the check
|
||||
widened so it cannot recur.
|
||||
|
||||
### BL-026 — CLOSED 2026-08-21 (removed; nothing referenced it)
|
||||
|
||||
- **Found during:** `T10.3` (D13), stripping the password code paths
|
||||
- **Where:** `server/notify.py`, `send_now()`
|
||||
- **What:** `send_now` sends one message immediately, outside the outbox queue. Its
|
||||
only caller was `forgot_password`, because a reset link must not sit in a queue.
|
||||
`T10.3` deleted that endpoint, so the function now has no callers anywhere in
|
||||
`server/` or `tests/` — verified by grep, not assumed.
|
||||
- **Resolution:** deleted. Raised as a judgement call between "remove it" and "keep
|
||||
it as the documented immediate-send path"; answered on Aug 21 — remove it. Nothing
|
||||
in `server/` or `tests/` referenced it, and its docstring explained itself entirely
|
||||
in terms of password resets, which no longer exist. Keeping an unused sender that
|
||||
bypasses the outbox is a liability, not an asset: the next person to need immediate
|
||||
mail should write it against the requirement they actually have.
|
||||
- **Note:** `send_email` (the raw SMTP call it wrapped) is untouched and still used by
|
||||
the outbox.
|
||||
|
||||
### BL-027 — Okta exists on this estate; OIDC is a live alternative to the LDAPS bind
|
||||
|
||||
- **Found during:** `T10.6` (D13), repointing "Forgot password?" at
|
||||
`https://primecontrols.okta.com/`
|
||||
- **Where:** authentication as a whole — `server/ldap_auth.py`, `server/app.py` `login()`
|
||||
- **What:** D13 chose an LDAPS simple bind, decided before it was known that the company
|
||||
runs an Okta tenant. Okta presumably federates to `prime.local` (which is why the
|
||||
Windows password is still the one that binds), but its existence means an OIDC
|
||||
authorization-code flow is available in principle. That would be strictly better on
|
||||
three counts the LDAPS design cannot match: this app would never see a password at all,
|
||||
MFA would come for free, and the domain-lockout hazard that forced
|
||||
`AUTH_MAX_ATTEMPTS` down to 2 would disappear entirely, because failed attempts would
|
||||
land on Okta rather than on a bind this endpoint makes.
|
||||
- **Why not now:** D13 was decided and reaffirmed, T10.1–T10.4 are built and verified
|
||||
against the live domain, and swapping the mechanism mid-wave is exactly the reordering
|
||||
`CLAUDE.md` forbids. Recording it is not the same as reopening it.
|
||||
- **Suggested wave or follow-up:** its own item and its own decision, with Nick and
|
||||
whoever administers the Okta tenant. Not a widening of D13.
|
||||
|
||||
### BL-028 — `assets_check` fails on any machine that has `MICRON_DB_URL` set
|
||||
|
||||
- **Found during:** `T10.7` (D13), running the full suite
|
||||
- **Where:** `tests/assets_check.py`, the "no `MICRON_DB_URL`" case
|
||||
- **What:** the check asserts `/api/assets` answers `configured:false` when the catalog
|
||||
is not configured, but `start_server` passes the ambient environment through. On a
|
||||
developer machine whose `.env` sets `MICRON_DB_URL` — which is the normal state for
|
||||
anyone who has ever used the asset picker — the API is genuinely configured, returns
|
||||
real Micron tags, and three checks fail. Nothing is wrong with the app; the test's
|
||||
premise is violated by the environment it runs in.
|
||||
- **Fix:** SET `MICRON_DB_URL` empty for that server — do not pop it. `server/db.py`
|
||||
calls `load_dotenv()` at import, and python-dotenv only skips keys already present in
|
||||
`os.environ`, so a *popped* variable is restored from the developer's `.env` inside the
|
||||
subprocess and the test runs against the real catalog anyway. An empty string counts as
|
||||
present and therefore wins. `start_server` does exactly this for `LDAP_REQUIRED_GROUP`
|
||||
(`T10.7`), after the pop-based version was caught doing the wrong thing.
|
||||
- **Why not now:** it is not this wave's defect and the fix belongs with whoever owns
|
||||
the asset picker's tests. Recorded so the failure is not mistaken for D13 fallout.
|
||||
- **Suggested wave or follow-up:** next housekeeping pass.
|
||||
|
||||
### BL-029 — `generalinfo_check` flags a pre-existing `rgba()` in the creator stylesheet
|
||||
|
||||
- **Found during:** `T10.7` (D13), running the full suite
|
||||
- **Where:** `html/wp-creation-styles.css:929` — `box-shadow:0 8px 24px rgba(20,30,50,.18)`
|
||||
- **What:** `generalinfo_check`'s token-rule check reports "no colour literal was added
|
||||
to the creator's stylesheet" and fails on `rgba(`. The literal predates this wave —
|
||||
last touched by `8efe624` (F6) — and `git diff main...HEAD` shows the file untouched
|
||||
by the LDAPS branch.
|
||||
- **The real question is which is wrong.** `C4`'s recorded exception allows rgba
|
||||
**alphas** as opacity recipes, which is arguably what a shadow is; if so the check is
|
||||
too strict and should match a colour literal rather than the `rgba(` token. If not,
|
||||
the shadow needs a token. Either way it is a one-line change plus a decision, and
|
||||
the decision is not this wave's to make.
|
||||
- **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.
|
||||
- **Suggested wave or follow-up:** next housekeeping pass, with `C4` re-read first.
|
||||
|
||||
### BL-030 — `token_version` is now near-vestigial; decide whether it earns its place
|
||||
|
||||
- **Found during:** `T10.7` (D13), closing the done-when that assumed a role change bumps it
|
||||
- **Where:** `server/models.py` (`User.token_version`), `server/auth.py` (`create_token`,
|
||||
`get_current_user`), `server/manage_users.py` (`_set_active`)
|
||||
- **What:** `token_version` existed to invalidate live sessions when a password changed.
|
||||
D13 removed passwords, and **nothing in `app.py` bumps it any more** — not
|
||||
`set_user_role`, not `set_user_active`. Nor do they need to: `get_current_user` loads
|
||||
the account from the database on every request, so a role change or a deactivation
|
||||
takes effect on the next request regardless. `T10.3`'s note that "role changes and
|
||||
deactivation should bump it" describes an intention, not the code.
|
||||
- **Its one remaining trigger** is the bump added to `manage_users._set_active` in
|
||||
`T10.9`, which is belt-and-braces rather than load-bearing — `is_active` alone already
|
||||
refuses the request.
|
||||
- **The question:** is there still a case for invalidating a live cookie *without* also
|
||||
disabling the account? If yes, wire it to something (a "sign out everywhere" control is
|
||||
the usual shape) and say so. If no, the column, the claim, and the check are three
|
||||
places carrying a mechanism nothing triggers.
|
||||
- **Why not now:** it is a design question about session handling, not an auth-wave bug,
|
||||
and the mechanism works correctly — it is exercised in `ldap_auth_check`.
|
||||
- **Suggested wave or follow-up:** next housekeeping pass.
|
||||
|
||||
210
docs/waves/decisions-2026-08-21.md
Normal file
210
docs/waves/decisions-2026-08-21.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# 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.
|
||||
399
docs/waves/wave-10.md
Normal file
399
docs/waves/wave-10.md
Normal file
@@ -0,0 +1,399 @@
|
||||
# Wave 10 — Domain authentication over LDAPS
|
||||
|
||||
**Items:** `D13`, `D14`
|
||||
**Depends on:** wave 9 merged (it is — `a8e28bf`)
|
||||
|
||||
One item, eight tasks. The item is stated in `docs/waves/decisions-2026-08-21.md`; read it
|
||||
before starting, particularly the **Non-negotiables** section, which is where this change
|
||||
goes wrong if it goes wrong.
|
||||
|
||||
**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
|
||||
disappears, and admins stop issuing passwords. Everything else is invisible — which means
|
||||
the done-when checks are the only evidence the wave worked.
|
||||
|
||||
> **BEFORE PRODUCTION: `LDAP_REQUIRED_GROUP` must be set.** It is empty by default,
|
||||
> and empty means *no group gate* — every account in `prime.local` may sign in. The
|
||||
> successful live sign-in on Aug 24 was made without it, so it proved the bind, the
|
||||
> certificate chain and JIT provisioning, but **not** the group check: that path never
|
||||
> executed. The group gate is covered against the fake directory in `ldap_auth_check`
|
||||
> and has never run against the real directory. The startup line says which state you
|
||||
> are in — `required group: (none configured — every domain account may sign in)`.
|
||||
|
||||
**Build order is task order** for once. `T10.1` is a standalone module with no callers and
|
||||
should merge first; nothing else can be tested until it exists.
|
||||
|
||||
---
|
||||
|
||||
### T10.1 — The LDAPS client
|
||||
|
||||
- **Items:** `D13` (1)
|
||||
- **Depends on:** nothing
|
||||
- **Blocks:** T10.2, T10.4, T10.5, T10.7
|
||||
- **Surface:** `server/`
|
||||
- **Files:** new `server/ldap_auth.py`, `server/requirements.txt`, `docker-compose.yml`,
|
||||
`Dockerfile`, `server/.env.example`
|
||||
|
||||
**Problem:** There is no directory client in the repo. `ldap3` is not a dependency, the API
|
||||
container has no CA bundle, and the `api` service sits on the `internal` network, which has
|
||||
no default gateway and therefore no route to `192.168.3.x` at all.
|
||||
|
||||
**Do:** Add `ldap3` (pinned, per the convention at the top of `requirements.txt`). Write
|
||||
`server/ldap_auth.py` exposing two functions and nothing else:
|
||||
|
||||
- `verify(username, password) -> LdapResult | None` — simple bind as
|
||||
`f"{username}@{domain}"` against `ldaps://{host}:636`.
|
||||
- `member_of(conn, group) -> bool` — nested-group aware, via
|
||||
`LDAP_MATCHING_RULE_IN_CHAIN` (`1.2.840.113556.1.4.1941`). `memberOf` alone is direct
|
||||
membership only and will wrongly refuse anyone in a nested group.
|
||||
|
||||
Configuration by environment: `LDAP_HOST` (default `prime.local`), `LDAP_DOMAIN` (default
|
||||
`prime.local`), `LDAP_CA_FILE`, `LDAP_REQUIRED_GROUP`, `LDAP_TIMEOUT_SECONDS`. Attach the
|
||||
`outbound` network to `api` in `docker-compose.yml` — the same reason `assets_db.py` needed
|
||||
it, and the comment there already explains the gateway-less `internal` network. Mount or
|
||||
`COPY` the PEM bundle and point `LDAP_CA_FILE` at it.
|
||||
|
||||
Connect to **`prime.local`**, never a DC hostname and never an IP. See the decision doc for
|
||||
why: SAN coverage plus round-robin across six DCs in one move. Wrap the bind in a retry
|
||||
across resolved addresses, because round-robin will hand out a rebooting DC's address.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [x] `ldap3` is pinned to an exact version in `requirements.txt`
|
||||
- [x] an empty or whitespace-only password returns failure **without calling `bind()`** — proven with `Connection` nulled, so any call to `bind()` would raise
|
||||
- [x] an empty username returns failure without calling `bind()`
|
||||
- [x] `Tls` is constructed with `validate=ssl.CERT_REQUIRED` and an explicit `ca_certs_file`
|
||||
- [x] no code path sets `CERT_NONE`, and none falls back to the system trust store — asserted by an AST walk in `ldap_auth_check`, not a grep: the docstring names it to explain the ban
|
||||
- [~] `member_of` returns true for an account in a **nested** child of the required group — **partially closed Aug 24.** The transitive matching rule was exercised against the live directory for a DIRECT member (`CN=Prime Employees`, 2,003 members) and returned a match, so the rule and the filter are correct on this estate. A genuinely NESTED case (member of a group that is a member of the required group) still has no test, because no such account was to hand. `member_of` now also warns loudly if the transitive query returns nothing where a direct check succeeds, which is how a broken nested lookup would announce itself rather than refusing real members silently.
|
||||
- [x] a bind against `192.168.3.37` (raw IP) fails hostname validation rather than silently passing — `selftest()` to the raw IP returns `untrusted`; to `prime.local`, `0 (ok)`
|
||||
- [ ] `docker compose exec api openssl s_client -connect prime.local:636 -CAfile $LDAP_CA_FILE` reports `Verify return code: 0 (ok)`
|
||||
- [x] the module imports cleanly with no LDAP env set (unconfigured is a first-class state, as with `MICRON_DB_URL`)
|
||||
|
||||
---
|
||||
|
||||
### T10.2 — The login path binds instead of hashing
|
||||
|
||||
- **Items:** `D13` (1)
|
||||
- **Depends on:** T10.1
|
||||
- **Blocks:** T10.3, T10.4
|
||||
- **Surface:** `server/`
|
||||
- **Files:** `server/app.py` (`login`), `server/auth.py`
|
||||
|
||||
**Problem:** `login()` at `server/app.py:681` calls `auth.verify_password` against
|
||||
`user.password_hash`. The lockout counter it maintains is about to start counting *domain*
|
||||
bind failures, which changes what that counter is for.
|
||||
|
||||
**Do:** Replace the credential check with `ldap_auth.verify`. Keep the surrounding shape —
|
||||
the deliberate timing equalisation, the generic `401`, the `403` for a disabled local
|
||||
account, `last_login_at`, the session cookie. Rework the throttle so the local counter trips
|
||||
**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.
|
||||
Log directory error-49 sub-codes for diagnosis; return the same generic message regardless.
|
||||
|
||||
**Break-glass: none — decided Aug 21, see the decision doc.** LDAPS is the only way in, so
|
||||
this task adds no fallback path. What it must add instead is *visibility*: a startup log line
|
||||
stating whether LDAP is configured and whether the DC answered, because with no fallback a
|
||||
misconfigured deploy is indistinguishable from a forgotten password at the login box.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [x] the API logs one line at startup saying whether LDAP is configured and which group gates sign-in — configuration only, so an unreachable DC cannot hang startup. This is what `DEPLOYMENT.md`, `DEPLOY-login-portal.md` and `server/README.md` all send people to first; it was specified in this task on Aug 21 and not actually written until Aug 24.
|
||||
- [x] a correct domain password signs in and sets the session cookie — confirmed against the live domain Aug 24, and in `ldap_auth_check`
|
||||
- [x] a wrong password is refused with the generic message
|
||||
- [x] a blank password is refused (guards `T10.1` from the caller's side too)
|
||||
- [x] a user not in the required group is refused even though the bind succeeded
|
||||
- [x] `is_active = false` locally still refuses, independent of the directory — a disabled account is refused 403 even though the bind succeeds
|
||||
- [x] the local throttle trips below the domain lockout threshold and stops calling the DC — proved by presenting a CORRECT password once the budget is spent: a 429 for a credential that would otherwise work is only possible if the throttle runs before the directory is consulted
|
||||
- [x] no response body distinguishes "no such user" from "wrong password"
|
||||
- [x] error-49 sub-codes appear in the log and nowhere in any response — asserted both ways: `_err49` parses a real AD message, and no sub-code appears in any response body
|
||||
|
||||
---
|
||||
|
||||
### T10.3 — Drop `password_hash`
|
||||
|
||||
- **Items:** `D13` (1)
|
||||
- **Depends on:** T10.2
|
||||
- **Blocks:** T10.6, T10.8
|
||||
- **Surface:** `server/`
|
||||
- **Files:** `server/models.py`, new migration, `server/app.py`, `server/auth.py`,
|
||||
`server/manage_users.py`
|
||||
|
||||
**Problem:** With binds doing the work, every password code path is dead weight and a
|
||||
liability. It is also the criterion that makes this item irreversible, so it lands on its own
|
||||
commit.
|
||||
|
||||
**Do:** Remove `password_hash` from `models.User` and drop the column in a new Alembic
|
||||
revision with `down_revision = 'a1b8c6d4e2f9'` (the current head — confirm with
|
||||
`alembic heads` rather than trusting this line). Remove from `auth.py`: `hash_password`,
|
||||
`verify_password`, `password_problem`, `MIN_PASSWORD_LEN`, `_COMMON_PASSWORDS`,
|
||||
`create_reset_token`, `decode_reset_token`, `RESET_MINUTES`. Remove from `app.py`:
|
||||
`/api/auth/forgot-password`, `/api/auth/reset-password`, `/api/auth/reset-available`,
|
||||
`/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
|
||||
from `create` / `create-admin`.
|
||||
|
||||
`token_version` **stays.** It is still the session-revocation mechanism — role changes and
|
||||
deactivation should bump it even though password changes no longer exist.
|
||||
|
||||
**Do not** remove the account-management endpoints themselves. `/api/auth/users/{id}/role`
|
||||
is criterion 4 and must keep working.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [x] `grep -rn "password_hash\|hash_password\|verify_password\|password_problem" server/` returns nothing outside the migration — only the migration and one docstring naming the dropped column
|
||||
- [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`)
|
||||
- [x] the migration's `downgrade()` recreates the column nullable, not `NOT NULL` — there are no hashes to put back — verified on SQLite and Postgres
|
||||
- [x] `token_version` still invalidates an already-issued session — but **not** via a role change, which was the wrong premise. Nothing in `app.py` bumps it any more: `get_current_user` re-reads the account every request, so `role` and `is_active` changes take effect immediately without it. Its one remaining trigger is `manage_users` on disable. Tested by bumping it directly: the old cookie 401s and every other session is untouched. See `BL-030`.
|
||||
- [x] `manage_users.py list`, `disable`, `enable` still work; `reset-password` is gone
|
||||
- [x] no CLI command prompts for a password — superseded by `T10.9`, which removed `create-admin` and `create` outright; the first admin is now bootstrapped by signing in and then `promote`
|
||||
|
||||
---
|
||||
|
||||
### T10.4 — Just-in-time provisioning, without trampling existing accounts
|
||||
|
||||
- **Items:** `D13` (2, 4)
|
||||
- **Depends on:** T10.2
|
||||
- **Blocks:** T10.7
|
||||
- **Surface:** `server/`
|
||||
- **Files:** `server/app.py` (`login`), `server/auth.py`
|
||||
|
||||
**Problem:** Criteria 2 and 4 pull in opposite directions. Provisioning on first login must
|
||||
create accounts that do not exist, and must not touch the role of accounts that do — an
|
||||
existing `admin` signing in for the first time after this wave must still be an admin
|
||||
afterwards.
|
||||
|
||||
**Do:** On a successful bind that passes the group check, look the account up with
|
||||
`auth.find_user` (already case-insensitive across username **and** email). If it exists,
|
||||
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
|
||||
`ROLE_PROJECT_USER` with `full_name`/`email` from the directory.
|
||||
|
||||
**A JIT account gets NO project access, and that is deliberate.** An earlier draft of this
|
||||
task said to honour the `auto_add_projects` machinery so a new account "lands in the right
|
||||
projects" — that was wrong about how the flag works. `auto_add_projects` is evaluated when a
|
||||
**project** is created (`app.py:245`), marking accounts that should join every *new* job; it
|
||||
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
|
||||
grants access. That is a real UX cliff — a successful sign-in into an empty app — so it has
|
||||
to be visible to admins rather than silent, which is what the `AuditLog` row is for.
|
||||
|
||||
Note the flush-order warning in the `models.py` docstring: `create_user` in `app.py` handles
|
||||
account-then-membership correctly in one flush — follow it if you add rows.
|
||||
|
||||
Write an `AuditLog` row for each JIT creation. An account appearing without an administrator
|
||||
creating it is exactly the kind of event that record exists for.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [x] an unknown username with a valid bind and group membership gets a `users` row at `project_user`
|
||||
- [x] `full_name` and `email` are populated from the directory on creation
|
||||
- [x] an existing `admin` signing in is still `admin` afterwards — asserted, not assumed — asserted in `ldap_auth_check` against a real server
|
||||
- [x] an existing account with a locally-set `full_name` does not have it overwritten
|
||||
- [x] a JIT account has NO `ProjectMember` rows and sees no projects
|
||||
- [x] the new account appears in the Admin console user list so access can be granted — asserted through `GET /api/auth/users`, the request the console makes
|
||||
- [x] each JIT creation writes an `AuditLog` row
|
||||
- [x] a failed bind creates **no** row
|
||||
- [x] a bind that succeeds but fails the group check creates **no** row
|
||||
|
||||
---
|
||||
|
||||
### T10.5 — CLOSED, NOT BUILT (Aug 24 2026): the required group stays an env var
|
||||
|
||||
- **Items:** `D13` (3)
|
||||
- **Status:** **won't build.** `LDAP_REQUIRED_GROUP` in the environment is the answer.
|
||||
|
||||
**Do not build this later by reading the original task and assuming it was skipped.**
|
||||
It was proposed, examined and rejected on purpose, and the reasoning is below.
|
||||
|
||||
**What it was going to be:** the required group moved out of the environment into an
|
||||
Admin console setting, with a validate-on-save guard that resolved the group in the
|
||||
directory and confirmed the saving admin was a member.
|
||||
|
||||
**Why it is not being built:**
|
||||
|
||||
1. **The console requirement was invented here, not asked for.** D13 criterion 3 says
|
||||
*"An AD group is configured"* — not "configurable from the console". The env var
|
||||
satisfies the criterion as written.
|
||||
2. **The validate-on-save guard existed only to defend against a risk the console
|
||||
itself introduced.** A feature whose complexity exists to defend against itself is
|
||||
usually the wrong feature.
|
||||
3. **The lockout scenario it defended against is already handled.** A group that does
|
||||
not resolve raises `LookupError` in `member_of`, which `verify()` maps to
|
||||
`GROUP_NOT_FOUND`, which `is_config_problem` classifies as ours — so `login()`
|
||||
answers **503**, not 401, and the log says *"required group 'X' does not resolve in
|
||||
DC=prime,DC=local — refusing the sign-in. This is a configuration fault, not a bad
|
||||
password."* A genuine non-member still gets 401. The two are already distinguishable
|
||||
in both the log and the response.
|
||||
4. **A redeploy is deliberate and reviewable; a text box is not.** The group is set
|
||||
once and effectively never changes — it is not SMTP configuration.
|
||||
5. **The console version creates a circular failure.** Fixing a lockout would require
|
||||
the console the lockout prevents you from reaching. Editing the env var does not.
|
||||
|
||||
**What is genuinely lost, and accepted:**
|
||||
|
||||
- **Discoverability.** An app admin cannot see which group is required without
|
||||
Portainer or shell access. A read-only line in the Admin console diagnostics would
|
||||
give the useful half without the dangerous half; it was offered and declined on
|
||||
Aug 24 as not needed.
|
||||
- **Deploy-time validation.** Nothing confirms the group resolves until the first
|
||||
sign-in attempt. This is not fixable: resolving a group needs an authenticated
|
||||
search, anonymous bind is disabled on this estate, and there is no service account
|
||||
by design. The first-attempt 503 is the earliest possible detection.
|
||||
|
||||
### 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:**
|
||||
|
||||
- [x] `grep -rn "forgot\|reset-password\|new-password" html/` returns nothing but prose
|
||||
- [x] "Forgot password?" opens `https://primecontrols.okta.com/` in a new tab — by inspection of the markup: `target="_blank"` with `rel="noopener noreferrer"`
|
||||
- [x] `#forgot-link` has NO click handler (a `preventDefault()` would swallow the navigation)
|
||||
- [x] a 503 from the login endpoint says sign-in is unavailable, not that the password is wrong
|
||||
- [x] the sign-in form still submits, and a failure still announces through `role="alert"` (`login.html` already does this correctly — do not regress it) — `url_state_check` drives the real form end to end; the `role="alert"` region is untouched
|
||||
- [x] the password field says which password to enter — see the screenshots
|
||||
- [x] no dead `<a href="#">` or handler remains for a removed view
|
||||
- [x] granting admin to an existing user still works from the console — asserted through `POST /api/auth/users/{id}/role`, the request `users.js` sends, and the role really changes
|
||||
- [x] exercised at 390px and at 1440px, screenshots in the PR — `docs/reference/baseline/before-wave10/` and `after-wave10/`, captured Aug 24 with `tests/baseline_shots.py`. "Before" comes from a detached worktree at `main` (`a8e28bf`) so each half was shot against its own server.
|
||||
- [x] no raw hex added to any stylesheet (the token rule) — no CSS was added at all — the hint reuses the `.hint` class the page already had
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
@@ -60,63 +60,6 @@
|
||||
.then(function () { window.location.replace('login.html'); });
|
||||
};
|
||||
|
||||
// Change-password dialog (uses POST /api/auth/password, which requires the
|
||||
// current password). Available from the top-right pill on any page.
|
||||
window.wpChangePassword = function () {
|
||||
if (document.getElementById('wp-pw-modal')) return;
|
||||
var ov = document.createElement('div');
|
||||
ov.id = 'wp-pw-modal';
|
||||
ov.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;' +
|
||||
'justify-content:center;z-index:10002;padding:20px;font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
|
||||
var inp = 'width:100%;padding:9px 10px;margin-bottom:12px;border:1px solid var(--cds-border-strong);border-radius:4px;font-size:14px;';
|
||||
var lbl = 'display:block;font-size:12px;color:var(--cds-text-secondary);margin-bottom:4px;';
|
||||
ov.innerHTML =
|
||||
'<div style="background:var(--cds-layer);color:var(--cds-text-primary);border-radius:10px;max-width:380px;width:100%;box-shadow:0 12px 40px rgba(20,30,50,.3);overflow:hidden;">' +
|
||||
'<div style="padding:14px 18px;border-bottom:1px solid var(--cds-border-subtle);font-weight:700;">Change password</div>' +
|
||||
'<div style="padding:16px 18px;">' +
|
||||
'<div id="wp-pw-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin-bottom:12px;"></div>' +
|
||||
'<label style="' + lbl + '">Current password</label>' +
|
||||
'<input id="wp-pw-cur" type="password" autocomplete="current-password" style="' + inp + '">' +
|
||||
'<label style="' + lbl + '">New password (at least 12 characters)</label>' +
|
||||
'<input id="wp-pw-new" type="password" autocomplete="new-password" style="' + inp + '">' +
|
||||
'<label style="' + lbl + '">Confirm new password</label>' +
|
||||
'<input id="wp-pw-new2" type="password" autocomplete="new-password" style="' + inp + 'margin-bottom:0;">' +
|
||||
'</div>' +
|
||||
'<div style="padding:12px 18px;border-top:1px solid var(--cds-border-subtle);display:flex;gap:8px;justify-content:flex-end;">' +
|
||||
'<button type="button" id="wp-pw-cancel" style="padding:8px 14px;border:1px solid var(--cds-border-strong);background:var(--cds-layer);border-radius:6px;cursor:pointer;font-weight:600;">Cancel</button>' +
|
||||
'<button type="button" id="wp-pw-save" style="padding:8px 14px;border:none;background:var(--cds-interactive-01);color:var(--cds-text-on-color);border-radius:6px;cursor:pointer;font-weight:600;">Update password</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
function close() { var m = document.getElementById('wp-pw-modal'); if (m) m.remove(); }
|
||||
function msg(text, ok) {
|
||||
var el = document.getElementById('wp-pw-msg');
|
||||
el.style.display = 'block'; el.textContent = text;
|
||||
el.style.background = ok ? 'var(--wp-status-success-bg)' : 'var(--wp-status-error-bg)'; el.style.color = ok ? 'var(--wp-status-success-text)' : 'var(--cds-support-error)';
|
||||
}
|
||||
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
|
||||
document.body.appendChild(ov);
|
||||
document.getElementById('wp-pw-cancel').onclick = close;
|
||||
document.getElementById('wp-pw-cur').focus();
|
||||
document.getElementById('wp-pw-save').onclick = function () {
|
||||
var cur = document.getElementById('wp-pw-cur').value;
|
||||
var n1 = document.getElementById('wp-pw-new').value;
|
||||
var n2 = document.getElementById('wp-pw-new2').value;
|
||||
if (!cur || !n1) { msg('Please fill in every field.', false); return; }
|
||||
if (n1.length < 12) { msg('New password must be at least 12 characters.', false); return; }
|
||||
if (n1 !== n2) { msg('New passwords do not match.', false); return; }
|
||||
fetch('/api/auth/password', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ current_password: cur, new_password: n1 })
|
||||
})
|
||||
.then(function (r) { return r.json().catch(function () { return null; }).then(function (j) { return { ok: r.ok, status: r.status, j: j }; }); })
|
||||
.then(function (res) {
|
||||
if (res.ok) { msg('Password updated.', true); setTimeout(close, 1200); }
|
||||
else { msg((res.j && res.j.detail) || ('Could not update (HTTP ' + res.status + ').'), false); }
|
||||
})
|
||||
.catch(function () { msg('Could not reach the server.', false); });
|
||||
};
|
||||
};
|
||||
|
||||
// ── permissions helpers ────────────────────────────────────────────────────
|
||||
// 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.
|
||||
|
||||
@@ -112,47 +112,16 @@
|
||||
<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>
|
||||
<p class="center"><a href="#" id="forgot-link" class="link">Forgot password?</a></p>
|
||||
</section>
|
||||
|
||||
<!-- FORGOT PASSWORD (email reset) -->
|
||||
<section id="view-forgot" style="display:none">
|
||||
<h1>Reset password</h1>
|
||||
<p class="sub">We'll email you a link to set a new one.</p>
|
||||
<div id="forgot-unavailable" class="note" style="display:none">
|
||||
Password reset by email isn't switched on yet. Contact your project admin and
|
||||
they'll set a new password for you. Once you're signed in you can change it
|
||||
yourself from the menu in the top-right corner.
|
||||
</div>
|
||||
<form id="forgot-form" autocomplete="on">
|
||||
<div class="field">
|
||||
<label for="forgot-username">Username or email</label>
|
||||
<input id="forgot-username" type="text" autocomplete="username" required>
|
||||
</div>
|
||||
<button id="forgot-submit" type="submit">Email me a reset link</button>
|
||||
</form>
|
||||
<p class="center"><a href="#" id="back-to-login" class="link">← Back to sign in</a></p>
|
||||
</section>
|
||||
|
||||
<!-- SET A NEW PASSWORD (arrived from the emailed link) -->
|
||||
<section id="view-reset" style="display:none">
|
||||
<h1>Set a new password</h1>
|
||||
<p class="sub">Choose a password you don't use anywhere else.</p>
|
||||
<form id="reset-form" autocomplete="on">
|
||||
<div class="field">
|
||||
<label for="new-password">New password</label>
|
||||
<input id="new-password" type="password" autocomplete="new-password" autofocus required>
|
||||
</div>
|
||||
<div class="hint">At least 12 characters.</div>
|
||||
<div class="field">
|
||||
<label for="new-password2">Confirm new password</label>
|
||||
<input id="new-password2" type="password" autocomplete="new-password" required>
|
||||
</div>
|
||||
<button id="reset-submit" type="submit">Set password & sign in</button>
|
||||
</form>
|
||||
<p class="center"><a href="#" id="reset-to-login" class="link">← Back to sign in</a></p>
|
||||
<!-- 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>
|
||||
|
||||
149
html/login.js
149
html/login.js
@@ -1,26 +1,20 @@
|
||||
/* Login page logic for the Work Package Suite.
|
||||
|
||||
Three views on one page:
|
||||
• sign in posts to /api/auth/login. On success the server sets an
|
||||
HttpOnly session cookie (not readable here — that's the
|
||||
point) and we redirect to ?next= or the home page.
|
||||
• forgot password posts to /api/auth/forgot-password, which emails a
|
||||
single-use link. Only offered when the server reports
|
||||
email is actually configured (/api/auth/reset-available);
|
||||
otherwise we say to ask an admin.
|
||||
• set a new password shown when the page is opened as login.html?reset=<token>
|
||||
from that email. Posts to /api/auth/reset-password.
|
||||
One view. Sign in posts to /api/auth/login, the server authenticates by binding
|
||||
to the domain over LDAPS (D13), and on success sets an HttpOnly session cookie —
|
||||
not readable from here, which is the point — after which we redirect to ?next=
|
||||
or the home page. The password entered is the person's WINDOWS password.
|
||||
|
||||
The reset token stays in the URL only until it's used; on success we strip it
|
||||
from the address bar so it isn't left in history or copied out of the bar. */
|
||||
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 () {
|
||||
'use strict';
|
||||
|
||||
var errorBox = document.getElementById('error');
|
||||
var okBox = document.getElementById('ok');
|
||||
|
||||
function show(el) { if (el) el.style.display = ''; }
|
||||
function hide(el) { if (el) el.style.display = 'none'; }
|
||||
function byId(id) { return document.getElementById(id); }
|
||||
|
||||
function showError(msg) {
|
||||
@@ -28,11 +22,6 @@
|
||||
errorBox.textContent = msg;
|
||||
errorBox.classList.add('show');
|
||||
}
|
||||
function showOk(msg) {
|
||||
errorBox.classList.remove('show');
|
||||
okBox.textContent = msg;
|
||||
okBox.classList.add('show');
|
||||
}
|
||||
function clearBanners() {
|
||||
errorBox.classList.remove('show');
|
||||
okBox.classList.remove('show');
|
||||
@@ -49,10 +38,6 @@
|
||||
return 'index.html';
|
||||
}
|
||||
|
||||
function resetToken() {
|
||||
try { return new URLSearchParams(location.search).get('reset') || ''; } catch (e) { return ''; }
|
||||
}
|
||||
|
||||
function postJson(url, payload) {
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
@@ -70,18 +55,11 @@
|
||||
return (typeof d === 'string' && d) ? d : fallback;
|
||||
}
|
||||
|
||||
function view(which) {
|
||||
clearBanners();
|
||||
['login', 'forgot', 'reset'].forEach(function (v) {
|
||||
(which === v ? show : hide)(byId('view-' + v));
|
||||
});
|
||||
}
|
||||
|
||||
// ── sign in ────────────────────────────────────────────────────────────────
|
||||
var form = byId('login-form');
|
||||
var submitBtn = byId('submit');
|
||||
// Guarded because a cached older login.html may not have the reset views; an
|
||||
// unguarded addEventListener on null would break sign-in itself.
|
||||
// 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();
|
||||
@@ -98,6 +76,10 @@
|
||||
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';
|
||||
@@ -109,107 +91,4 @@
|
||||
});
|
||||
});
|
||||
|
||||
// ── forgot password ────────────────────────────────────────────────────────
|
||||
var resetAvailable = null; // null = not checked yet
|
||||
|
||||
function checkResetAvailable() {
|
||||
if (resetAvailable !== null) return Promise.resolve(resetAvailable);
|
||||
return fetch('/api/auth/reset-available')
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (j) { resetAvailable = !!(j && j.enabled); return resetAvailable; })
|
||||
.catch(function () { resetAvailable = false; return false; });
|
||||
}
|
||||
|
||||
(byId('forgot-link') || {addEventListener: function(){}}).addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
view('forgot');
|
||||
// Prefill from the sign-in box so nobody types their username twice.
|
||||
var u = byId('username').value.trim();
|
||||
if (u) byId('forgot-username').value = u;
|
||||
checkResetAvailable().then(function (enabled) {
|
||||
// With email off there's nothing to submit — say so and hide the form.
|
||||
(enabled ? hide : show)(byId('forgot-unavailable'));
|
||||
(enabled ? show : hide)(byId('forgot-form'));
|
||||
if (enabled) byId('forgot-username').focus();
|
||||
});
|
||||
});
|
||||
|
||||
(byId('back-to-login') || {addEventListener: function(){}}).addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
view('login');
|
||||
});
|
||||
|
||||
var forgotForm = byId('forgot-form') || document.createElement('form');
|
||||
var forgotBtn = byId('forgot-submit') || document.createElement('button');
|
||||
forgotForm.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
clearBanners();
|
||||
var who = byId('forgot-username').value.trim();
|
||||
if (!who) { showError('Enter your username or email.'); return; }
|
||||
forgotBtn.disabled = true;
|
||||
forgotBtn.textContent = 'Sending…';
|
||||
postJson('/api/auth/forgot-password', { username: who })
|
||||
.then(function (res) {
|
||||
if (res.status === 503) {
|
||||
showError(detail(res, "Password reset by email isn't available. Ask an administrator."));
|
||||
} else if (res.ok) {
|
||||
// Deliberately the same message whether or not the account exists.
|
||||
showOk('If that account exists, a reset link is on its way. The link expires in an hour.');
|
||||
hide(forgotForm);
|
||||
} else {
|
||||
showError(detail(res, 'Could not send the reset email (HTTP ' + res.status + ').'));
|
||||
}
|
||||
forgotBtn.disabled = false;
|
||||
forgotBtn.textContent = 'Email me a reset link';
|
||||
})
|
||||
.catch(function () {
|
||||
showError('Could not reach the server. Check your connection and try again.');
|
||||
forgotBtn.disabled = false;
|
||||
forgotBtn.textContent = 'Email me a reset link';
|
||||
});
|
||||
});
|
||||
|
||||
// ── set a new password (from the emailed link) ──────────────────────────────
|
||||
(byId('reset-to-login') || {addEventListener: function(){}}).addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
view('login');
|
||||
});
|
||||
|
||||
var resetForm = byId('reset-form') || document.createElement('form');
|
||||
var resetBtn = byId('reset-submit') || document.createElement('button');
|
||||
resetForm.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
clearBanners();
|
||||
var token = resetToken();
|
||||
var pw = byId('new-password').value;
|
||||
var pw2 = byId('new-password2').value;
|
||||
if (!token) { showError('This reset link is incomplete. Request a new one.'); return; }
|
||||
if (pw !== pw2) { showError('The two passwords do not match.'); return; }
|
||||
if (pw.length < 12) { showError('Password must be at least 12 characters.'); return; }
|
||||
|
||||
resetBtn.disabled = true;
|
||||
resetBtn.textContent = 'Saving…';
|
||||
postJson('/api/auth/reset-password', { token: token, new_password: pw })
|
||||
.then(function (res) {
|
||||
if (res.ok) {
|
||||
// Take the token out of the URL before anything else — it's spent.
|
||||
try { history.replaceState(null, '', 'login.html'); } catch (err) {}
|
||||
view('login');
|
||||
showOk('Password updated. Sign in with your new password.');
|
||||
byId('username').focus();
|
||||
return;
|
||||
}
|
||||
showError(detail(res, 'Could not set your password (HTTP ' + res.status + ').'));
|
||||
resetBtn.disabled = false;
|
||||
resetBtn.textContent = 'Set password & sign in';
|
||||
})
|
||||
.catch(function () {
|
||||
showError('Could not reach the server. Check your connection and try again.');
|
||||
resetBtn.disabled = false;
|
||||
resetBtn.textContent = 'Set password & sign in';
|
||||
});
|
||||
});
|
||||
|
||||
// Arriving from the reset email opens straight into the new-password view.
|
||||
if (resetToken()) view('reset');
|
||||
})();
|
||||
|
||||
@@ -26,9 +26,8 @@
|
||||
room for "Assistant Project Manager" without pushing Actions off screen. */
|
||||
#users-table table td:nth-child(3){ max-width:230px; overflow:hidden; text-overflow:ellipsis; }
|
||||
#users-banner:not(:empty), #scope-banner:not(:empty){ margin-bottom:var(--s3); }
|
||||
/* The create form is a lot of fields; give the password one room to breathe and
|
||||
/* The create form is a lot of fields; let them wrap and
|
||||
let the project picker take a full row of its own. */
|
||||
#nu-password{ flex:1 1 200px; }
|
||||
#nu-projects{ margin-top:var(--s2); }
|
||||
#nu-projects .pickrow{ padding:var(--s1) var(--s1); }
|
||||
/* A manager with one project doesn't need a scrolling picker; a manager with
|
||||
@@ -88,7 +87,6 @@
|
||||
<input id="nu-email" placeholder="Email" autocomplete="off">
|
||||
<select id="nu-role" title="Permissions — what this account may do"></select>
|
||||
<select id="nu-project-role" title="Job function on the project"></select>
|
||||
<input id="nu-password" type="password" placeholder="Password (min 12)" autocomplete="new-password">
|
||||
</div>
|
||||
<div id="nu-projects">
|
||||
<div class="note" id="nu-projects-label" style="margin-bottom:var(--s1)"></div>
|
||||
|
||||
@@ -183,11 +183,12 @@ function managerRow(u){
|
||||
: projRoleReadonly(u, can, why);
|
||||
|
||||
const actions = [];
|
||||
if(can && !me) actions.push('<button class="mini" onclick="resetPw(\''+uid+'\',\''+uname+'\')">Reset password</button>');
|
||||
if(can && !me) actions.push('<button class="mini" onclick="toggleActive(\''+uid+'\','+(!u.is_active)+')">'+
|
||||
(u.is_active?'Disable':'Enable')+'</button>');
|
||||
if(can && !me) actions.push('<button class="mini danger" onclick="deleteUser(\''+uid+'\',\''+uname+'\')">Delete</button>');
|
||||
if(me) actions.push('<button class="mini" disabled title="Use the Password link in the top bar to change your own">—</button>');
|
||||
// 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>');
|
||||
|
||||
return '<tr'+(can||me ? '' : ' class="is-locked"')+'>'+
|
||||
@@ -254,19 +255,6 @@ function projAccessCell(u){
|
||||
// ── row actions ───────────────────────────────────────────────────────────────
|
||||
// Each one reloads on failure so a control can never sit there showing a value the
|
||||
// server refused.
|
||||
async function resetPw(id, username){
|
||||
// The min-12 rule was stated in the prompt label and enforced only by the
|
||||
// server round-trip; the kit's validate() answers AT the input instead.
|
||||
const pw = await wpPromptDialog({title:'Reset password',
|
||||
message:'Set a new password for "'+username+'". Their existing sessions are signed out.',
|
||||
label:'New password (min 12 characters)',
|
||||
validate:v => (v && v.length >= 12) ? '' : 'At least 12 characters.'});
|
||||
if(pw === null) return;
|
||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/password',{new_password:pw});
|
||||
if(status === 200) toast('Password reset for '+username+'. Their existing sessions are signed out.');
|
||||
else wpAlertDialog({title:'Reset failed', message:'Could not reset the password: '+apiError(status, json)});
|
||||
}
|
||||
|
||||
async function toggleActive(id, makeActive){
|
||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/active',{is_active:makeActive});
|
||||
if(status === 200) loadUsers();
|
||||
@@ -345,24 +333,22 @@ async function createUser(){
|
||||
const msg = document.getElementById('users-create-msg');
|
||||
const val = id => (document.getElementById(id)||{}).value || '';
|
||||
const username = val('nu-username').trim();
|
||||
const password = val('nu-password');
|
||||
const project_ids = [...document.querySelectorAll('#nu-project-list input[type=checkbox]:checked')]
|
||||
.map(c => c.value);
|
||||
const say = (color, text) => { msg.style.color = color; msg.textContent = text; };
|
||||
if(!username){ say('var(--red)','Username is required.'); return; }
|
||||
if(password.length < 12){ say('var(--red)','Password must be at least 12 characters.'); return; }
|
||||
if(_scope.scope !== 'all' && !project_ids.length){
|
||||
say('var(--red)','Pick at least one project — you administer users per project.'); return;
|
||||
}
|
||||
say('var(--muted)','Creating…');
|
||||
const { status, json } = await api('POST','/api/auth/users',{
|
||||
username, password, project_ids,
|
||||
username, project_ids,
|
||||
full_name: val('nu-fullname').trim(), email: val('nu-email').trim(),
|
||||
role: val('nu-role'), project_role: val('nu-project-role'),
|
||||
});
|
||||
if(status === 200){
|
||||
say('var(--green)','✓ Created '+username+'.');
|
||||
['nu-username','nu-fullname','nu-email','nu-password'].forEach(id => document.getElementById(id).value = '');
|
||||
['nu-username','nu-fullname','nu-email'].forEach(id => document.getElementById(id).value = '');
|
||||
loadUsers();
|
||||
} else {
|
||||
say('var(--red)','✕ '+apiError(status, json, 'Could not create the account'));
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
{ section: 'Account' },
|
||||
{ action: 'wpPreferences', icon: '◷', label: 'Language & time',
|
||||
sub: 'Dates, numbers and time zone' },
|
||||
{ action: 'wpChangePassword', icon: '⚿', label: 'Password', sub: 'Change your password' },
|
||||
];
|
||||
|
||||
function esc(v) {
|
||||
|
||||
@@ -21,6 +21,40 @@ AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
|
||||
# How long a login lasts before re-authentication (hours). Default 12.
|
||||
# AUTH_SESSION_HOURS=12
|
||||
|
||||
# ── Domain authentication, D13 (REQUIRED — there is no fallback) ───────────────
|
||||
# The suite stores no passwords. Sign-in is an LDAPS simple bind against the
|
||||
# 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
|
||||
# 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 not happen — domain passwords cross this link.
|
||||
# LDAP_DOMAIN=prime.local
|
||||
# LDAP_HOST=prime.local
|
||||
# LDAP_PORT=636
|
||||
|
||||
# Trust anchor: PRIME CONTROLS ROOT CA + PRIME CONTROLS ISSUING CA 1 as a PEM
|
||||
# bundle. These are PUBLIC certificates — no private key, nothing issued to this
|
||||
# app, nothing to request from IT. The repo ships a verified copy and the default
|
||||
# points at it, so you only set this to override with a mounted file.
|
||||
# LDAP_CA_FILE=/app/server/certs/prime-ca-chain.pem
|
||||
|
||||
# An AD group required to sign in. Empty means every domain account may sign in.
|
||||
# This is the INITIAL value and the fallback; the live value is set in the Admin
|
||||
# console, which refuses to save a group that does not resolve or that the saving
|
||||
# admin is not a member of. Nested groups count.
|
||||
# LDAP_REQUIRED_GROUP=WP-Suite-Users
|
||||
|
||||
# Bind/connect timeout, and how many extra CONNECT attempts to make. Retries never
|
||||
# apply to a rejected password — each failed bind counts against the domain lockout
|
||||
# policy, so guessing would lock real accounts out of Windows.
|
||||
# LDAP_TIMEOUT_SECONDS=8
|
||||
# LDAP_CONNECT_RETRIES=2
|
||||
|
||||
# ── Email notifications (optional) ─────────────────────────────────────────────
|
||||
# WP-assignment emails are OFF by default and are turned on from the Admin
|
||||
# console (Notifications & email card), where the SMTP host/port/from-address
|
||||
|
||||
118
server/README.md
118
server/README.md
@@ -14,12 +14,12 @@ browser → NGINX ──serves──> static site (index.html, …)
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| GET | `/api/health` | liveness check (unauthenticated) |
|
||||
| POST | `/api/auth/login` | sign in (`{username, password}`) — sets the session cookie |
|
||||
| POST | `/api/auth/login` | sign in (`{username, password}`) — binds against the domain, sets the session cookie |
|
||||
| POST | `/api/auth/logout` | clear the session cookie |
|
||||
| GET | `/api/auth/me` | the logged-in user |
|
||||
| POST | `/api/auth/password` | change your own password |
|
||||
| GET | `/api/auth/users` | list accounts (**admin**) |
|
||||
| POST | `/api/auth/users` | create an account (**admin**) |
|
||||
| POST | `/api/auth/users` | pre-create an account (**admin**) — optional; accounts self-provision on first sign-in |
|
||||
| POST | `/api/auth/users/{id}/role` | change an account's permissions role (**admin**) |
|
||||
| DELETE | `/api/auth/users/{id}` | delete an account (**admin**) |
|
||||
| POST | `/api/sops` | create/update a SOP (upsert by `id`) |
|
||||
| GET | `/api/sops` | list SOP summaries |
|
||||
@@ -40,9 +40,14 @@ fields (name, number, status, …) are promoted to columns for listing/filtering
|
||||
|
||||
---
|
||||
|
||||
## Login portal (user accounts)
|
||||
## Sign-in (domain authentication, D13)
|
||||
|
||||
The suite is gated by a username/password login. Sign-in issues a signed JWT
|
||||
**The suite stores no passwords.** Signing in performs an LDAPS **simple bind** to
|
||||
`ldaps://prime.local:636` as `<sAMAccountName>@prime.local` using the password the
|
||||
person typed — their Windows password. A successful bind is the authentication.
|
||||
See `server/ldap_auth.py`; the schema has no `password_hash` column.
|
||||
|
||||
Sign-in issues a signed JWT
|
||||
that rides in an **HttpOnly, SameSite=Lax** cookie (`wp_session`); the cookie is
|
||||
marked **Secure** automatically whenever the request arrives over HTTPS (via
|
||||
NGINX's `X-Forwarded-Proto`). There is no server-side session store — each
|
||||
@@ -53,8 +58,43 @@ with `401` unless a valid session cookie is present (see `auth_gate` in
|
||||
`app.py`). The static pages additionally include `auth-guard.js`, which redirects
|
||||
to `login.html` when there's no session — that's for UX, not protection.
|
||||
|
||||
Passwords are stored only as **bcrypt** hashes (`server/auth.py`). Roles are
|
||||
`admin` (may manage users) and `user`.
|
||||
**The directory supplies identity; this app supplies authorization.** Roles live in
|
||||
the local `users` table and are never read from AD — so an existing admin stays an
|
||||
admin. Roles are `admin`, `project_super_user`, `project_admin`, `project_user`.
|
||||
|
||||
**Accounts are created on first successful sign-in.** Anyone who binds successfully
|
||||
and is in the required group gets a `users` row at `project_user` with **no project
|
||||
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
|
||||
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
|
||||
`https://primecontrols.okta.com/` for password self-service. If the domain is
|
||||
unreachable, or `LDAP_CA_FILE` is wrong, or the required group is misconfigured,
|
||||
**nobody can sign in, including admins** — the API logs one line at startup saying
|
||||
whether LDAP is configured and reachable, so check `docker compose logs api` first.
|
||||
|
||||
**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
|
||||
|
||||
@@ -65,30 +105,70 @@ without it the API uses a random per-process key, so logins reset on restart.
|
||||
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
```
|
||||
|
||||
### Create the first admin
|
||||
### Bootstrap the first admin
|
||||
|
||||
The `/api/auth/users` endpoint needs an existing admin, so bootstrap one from a
|
||||
shell (run from the **project root**, like uvicorn):
|
||||
Two steps, in this order. There is no `create-admin` any more — there is no password
|
||||
to set and no account to create.
|
||||
|
||||
```bash
|
||||
python -m server.manage_users create-admin alice --name "Alice Smith"
|
||||
# prompts for a password (min 8 chars)
|
||||
# 1. Sign in to the app once. That provisions your account at project_user.
|
||||
# 2. Promote it:
|
||||
docker compose exec api python -m server.manage_users promote alice
|
||||
```
|
||||
|
||||
In Docker:
|
||||
It prompts for **your** domain username and password, binds to confirm who you are,
|
||||
and prints `alice: project_user -> admin`.
|
||||
|
||||
```bash
|
||||
docker compose exec api python -m server.manage_users create-admin alice --name "Alice Smith"
|
||||
```
|
||||
Other commands: `list`, `promote <user> [--role …]`, `demote <user>`,
|
||||
`disable <user>`, `enable <user>`. After that, admins manage accounts from the Admin
|
||||
console.
|
||||
|
||||
Other commands: `create <user> --role user`, `list`, `reset-password <user>`,
|
||||
`disable <user>`, `enable <user>`. After that, admins can add users through the
|
||||
API (or you can keep using the CLI).
|
||||
**Every command that changes anything requires a domain bind** (D14), prompted —
|
||||
there is deliberately no `--password` flag, which would put a live domain password
|
||||
into shell history and `ps` output. `list` needs no credential so an outage stays
|
||||
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
|
||||
`users` table with `psql`. It is defence in depth and, mostly, **accountability** —
|
||||
every role change now writes an audit row naming a person, which shell changes
|
||||
previously did not.
|
||||
|
||||
---
|
||||
|
||||
## Local dev
|
||||
|
||||
> ### A SQLite database created before D13 will reject new sign-ins
|
||||
>
|
||||
> `Base.metadata.create_all()` creates missing tables; it never alters existing ones.
|
||||
> So a `wpsuite.db` built before D13 still has `users.password_hash` declared
|
||||
> `NOT NULL` with no default, while the current model has no such column — and an
|
||||
> INSERT that omits it is rejected:
|
||||
>
|
||||
> ```
|
||||
> IntegrityError: NOT NULL constraint failed: users.password_hash
|
||||
> ```
|
||||
>
|
||||
> Accounts already in the file keep working, so **you** can sign in and nothing looks
|
||||
> wrong. It breaks the moment a *new* person signs in, because provisioning them is an
|
||||
> INSERT — and it surfaces as an HTTP **500**, not a 401, so it reads as a server fault
|
||||
> rather than anything to do with the schema.
|
||||
>
|
||||
> Such a database also has no `alembic_version` table, so `alembic upgrade head` would
|
||||
> try to replay the baseline against tables that already exist. Stamp it first:
|
||||
>
|
||||
> ```bash
|
||||
> python -m alembic -c server/alembic.ini stamp a1b8c6d4e2f9 # the revision before the drop
|
||||
> python -m alembic -c server/alembic.ini upgrade head # runs only the drop
|
||||
> ```
|
||||
>
|
||||
> That keeps whatever is in the file. Deleting the database also works and
|
||||
> `create_all()` rebuilds a correct schema, but it throws away your test data.
|
||||
>
|
||||
> Production is unaffected: it runs Postgres and the container applies migrations at
|
||||
> start, so the column is dropped properly there.
|
||||
|
||||
|
||||
```bash
|
||||
cd server
|
||||
python -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||
|
||||
@@ -32,7 +32,12 @@ def upgrade() -> None:
|
||||
sa.Column('code', sa.String(length=80), 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('active', sa.Boolean(), nullable=False, server_default=sa.text('1')),
|
||||
# sa.true(), NOT sa.text('1'). sa.text() emits raw SQL, and Postgres refuses
|
||||
# an integer default on a boolean column: "column active is of type boolean
|
||||
# but default expression is of type integer". SQLite accepts 1 happily, so
|
||||
# this passed every local test and failed only on the real engine. The
|
||||
# sibling migration e2a4c7d91b30 does the identical column correctly.
|
||||
sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column('sort', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""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))
|
||||
398
server/app.py
398
server/app.py
@@ -9,6 +9,8 @@ Run (prod): gunicorn -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 serve
|
||||
Interactive docs: http://<host>/api/docs
|
||||
"""
|
||||
import base64
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
@@ -26,7 +28,11 @@ from sqlalchemy import select, delete, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .db import Base, engine, get_db
|
||||
from . import models, auth, notify, assets_db
|
||||
from . import models, auth, notify, ldap_auth, assets_db
|
||||
|
||||
# Same naming as the other modules' loggers (wpsuite.auth / .ldap / .notify), so a
|
||||
# deployment can raise the level on one subsystem without raising it on all of them.
|
||||
log = logging.getLogger("wpsuite.api")
|
||||
|
||||
# Schema management:
|
||||
# • Local dev (SQLite) auto-creates tables for a zero-config run.
|
||||
@@ -39,7 +45,30 @@ if engine.dialect.name == "sqlite":
|
||||
# Interactive docs are handy in dev but hand an attacker the full API map in prod,
|
||||
# so enable them only on the SQLite dev fallback (production runs on Postgres).
|
||||
_docs_enabled = engine.dialect.name == "sqlite"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(_app):
|
||||
"""Say, once, whether anyone can sign in at all.
|
||||
|
||||
D13 removed the local password path and left no break-glass, so a broken LDAP
|
||||
configuration and a forgotten password look identical at the login box. This
|
||||
line is what tells an operator which one they have, and DEPLOYMENT.md,
|
||||
DEPLOY-login-portal.md and server/README.md all send people here first:
|
||||
|
||||
docker compose logs api | grep -i "LDAP auth"
|
||||
|
||||
Configuration only — it opens no connection and binds nothing, so startup stays
|
||||
fast and cannot be made to hang by an unreachable domain controller. Use
|
||||
`ldap_auth.selftest()` for a reachability check; it validates the certificate
|
||||
without binding, so it cannot contribute to a lockout either.
|
||||
"""
|
||||
log.info("%s", ldap_auth.describe())
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
lifespan=_lifespan,
|
||||
title="Work Package Suite API",
|
||||
docs_url="/api/docs" if _docs_enabled else None,
|
||||
redoc_url=None,
|
||||
@@ -611,8 +640,10 @@ class LoginIn(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
|
||||
password: str
|
||||
full_name: str = ""
|
||||
email: str = ""
|
||||
role: str = auth.ROLE_PROJECT_USER # permissions role — see auth.ROLES
|
||||
@@ -634,24 +665,6 @@ class PreferencesIn(BaseModel):
|
||||
timezone: Optional[str] = None
|
||||
|
||||
|
||||
class ForgotPasswordIn(BaseModel):
|
||||
username: str = "" # username or email
|
||||
|
||||
|
||||
class ResetPasswordIn(BaseModel):
|
||||
token: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class PasswordChangeIn(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class AdminPasswordIn(BaseModel):
|
||||
new_password: str
|
||||
|
||||
|
||||
class ActiveIn(BaseModel):
|
||||
is_active: bool
|
||||
|
||||
@@ -674,42 +687,214 @@ class AutoAddIn(BaseModel):
|
||||
role: str = ""
|
||||
|
||||
|
||||
LOGIN_MAX_ATTEMPTS = int(os.getenv("AUTH_MAX_ATTEMPTS", "5"))
|
||||
# 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)):
|
||||
"""Verify credentials and, on success, set the HttpOnly session cookie.
|
||||
Throttles online password guessing: after LOGIN_MAX_ATTEMPTS consecutive
|
||||
failures an account is locked for LOGIN_LOCKOUT_MINUTES."""
|
||||
user = auth.find_user(db, body.username)
|
||||
"""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()
|
||||
# Always run the hash comparison first — even for missing or locked accounts —
|
||||
# so response timing doesn't leak which usernames exist. verify_password
|
||||
# tolerates an empty hash.
|
||||
valid = auth.verify_password(body.password, user.password_hash if user else "")
|
||||
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; normalize to UTC
|
||||
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.")
|
||||
if not user or not valid:
|
||||
|
||||
# ── 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:
|
||||
# WARNING, not INFO: this is the line an operator needs when someone
|
||||
# cannot sign in, and nothing configures the root logger — under plain
|
||||
# uvicorn an INFO record from wpsuite.* goes nowhere, so the reason was
|
||||
# invisible in exactly the situation it exists for.
|
||||
log.warning("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})
|
||||
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()}
|
||||
@@ -721,115 +906,6 @@ def logout(response: Response):
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Self-service password reset (needs email switched on) ──────────────────────
|
||||
RESET_COOLDOWN_SECONDS = int(os.getenv("AUTH_RESET_COOLDOWN_SECONDS", "120"))
|
||||
# In-process throttle: one reset mail per (account, client) per cooldown. Enough to
|
||||
# stop someone using the form to spam a colleague's inbox. Per-worker and lost on
|
||||
# restart — deliberately simple; the token expiry is the real control.
|
||||
_reset_last: dict[str, float] = {}
|
||||
|
||||
|
||||
def _reset_throttled(request: Request, username: str) -> bool:
|
||||
now = monotonic()
|
||||
key = f"{(username or '').strip().lower()}|{request.client.host if request.client else ''}"
|
||||
prev = _reset_last.get(key)
|
||||
if prev is not None and (now - prev) < RESET_COOLDOWN_SECONDS:
|
||||
return True
|
||||
_reset_last[key] = now
|
||||
if len(_reset_last) > 5000: # bound the dict on a long-lived worker
|
||||
cutoff = now - RESET_COOLDOWN_SECONDS
|
||||
for k in [k for k, t in _reset_last.items() if t < cutoff]:
|
||||
_reset_last.pop(k, None)
|
||||
return False
|
||||
|
||||
|
||||
def reset_body(user: "models.User", link: str, minutes: int) -> str:
|
||||
# No account detail beyond the username, and no customer data — same rule as
|
||||
# the assignment mail. The link is the only sensitive thing in here.
|
||||
who = user.full_name or user.username
|
||||
return (
|
||||
f"Hi {who},\n\n"
|
||||
f"A password reset was requested for your Work Package Suite account "
|
||||
f"({user.username}).\n\n"
|
||||
f"Set a new password:\n{link}\n\n"
|
||||
f"The link expires in {minutes} minutes and can only be used once. "
|
||||
f"If you didn't request this, you can ignore this email — your current "
|
||||
f"password still works.\n"
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/auth/reset-available")
|
||||
def reset_available(db: Session = Depends(get_db)):
|
||||
"""Whether the login page should offer 'Forgot password'. Self-service reset
|
||||
depends entirely on outbound email, so it's off unless email is enabled AND
|
||||
SMTP is configured — otherwise the only route is an admin reset."""
|
||||
s = notify.get_settings(db)
|
||||
return {"enabled": bool(s.get("email_enabled")) and notify.smtp_ready(s)}
|
||||
|
||||
|
||||
@app.post("/api/auth/forgot-password")
|
||||
def forgot_password(body: ForgotPasswordIn, request: Request, db: Session = Depends(get_db)):
|
||||
"""Email a reset link. Always returns the same 200 response whether or not the
|
||||
account exists — this endpoint is unauthenticated, so it must not become a
|
||||
username/email oracle. Failures are recorded in the audit log instead."""
|
||||
s = notify.get_settings(db)
|
||||
if not (s.get("email_enabled") and notify.smtp_ready(s)):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Password reset by email isn't available. Ask an administrator to reset it for you.",
|
||||
)
|
||||
if _reset_throttled(request, body.username):
|
||||
# Same shape as the success response — no oracle, no mail bomb.
|
||||
return {"ok": True, "message": "If that account exists, a reset link is on its way."}
|
||||
user = auth.find_user(db, body.username)
|
||||
if user and user.is_active and user.email:
|
||||
base = (s.get("app_base_url") or "").rstrip("/")
|
||||
token = auth.create_reset_token(user)
|
||||
link = f"{base}/login.html?reset={token}" if base else f"/login.html?reset={token}"
|
||||
sent = notify.send_now(
|
||||
db, user.email,
|
||||
"Work Package Suite — reset your password",
|
||||
reset_body(user, link, auth.RESET_MINUTES),
|
||||
)
|
||||
log_event(db, user.username, "password_reset_requested", "user", user.id,
|
||||
summary=user.username, detail={"emailed": bool(sent)})
|
||||
db.commit()
|
||||
else:
|
||||
# Log the miss for the admin's benefit; the caller can't tell the difference.
|
||||
log_event(db, "(anonymous)", "password_reset_miss", "user", "",
|
||||
summary=(body.username or "")[:200],
|
||||
detail={"reason": "no account, inactive, or no email on file"})
|
||||
db.commit()
|
||||
return {"ok": True, "message": "If that account exists, a reset link is on its way."}
|
||||
|
||||
|
||||
@app.post("/api/auth/reset-password")
|
||||
def reset_password(body: ResetPasswordIn, db: Session = Depends(get_db)):
|
||||
"""Complete a reset using the emailed token. The token carries the user's
|
||||
token_version, and finishing a reset bumps it — so the link is single-use and
|
||||
every existing session for that account is signed out."""
|
||||
claims = auth.decode_reset_token(body.token or "")
|
||||
if not claims:
|
||||
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired. Request a new one.")
|
||||
user = db.get(models.User, claims.get("sub"))
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(status_code=400, detail="This reset link is no longer valid.")
|
||||
if (claims.get("ver", 0) or 0) != (user.token_version or 0):
|
||||
raise HTTPException(status_code=400, detail="This reset link has already been used. Request a new one.")
|
||||
problem = auth.password_problem(body.new_password, user.username, user.email)
|
||||
if problem:
|
||||
raise HTTPException(status_code=400, detail=problem)
|
||||
user.password_hash = auth.hash_password(body.new_password)
|
||||
user.token_version = (user.token_version or 0) + 1 # burns the link + all sessions
|
||||
# A completed reset also clears any login lockout — the person has proven
|
||||
# control of the mailbox, so there's nothing left to throttle.
|
||||
user.failed_attempts = 0
|
||||
user.locked_until = None
|
||||
log_event(db, user.username, "password_reset", "user", user.id, summary=user.username)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/auth/me")
|
||||
def whoami(user: models.User = Depends(auth.get_current_user)):
|
||||
"""Who is logged in. The frontend guard calls this on every page load.
|
||||
@@ -883,22 +959,6 @@ def set_preferences(body: PreferencesIn, user: models.User = Depends(auth.get_cu
|
||||
return {"user": user.to_dict()}
|
||||
|
||||
|
||||
@app.post("/api/auth/password")
|
||||
def change_password(body: PasswordChangeIn, request: Request, response: Response, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
if not auth.verify_password(body.current_password, user.password_hash):
|
||||
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
||||
problem = auth.password_problem(body.new_password, user.username, user.email)
|
||||
if problem:
|
||||
raise HTTPException(status_code=400, detail=problem)
|
||||
user.password_hash = auth.hash_password(body.new_password)
|
||||
user.token_version = (user.token_version or 0) + 1 # invalidate all OTHER existing sessions
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
# Keep this session logged in by re-issuing a cookie carrying the new version.
|
||||
auth.set_session_cookie(response, request, auth.create_token(user))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── User administration ─────────────────────────────────────────────────────────
|
||||
# Two kinds of caller reach these routes: an app admin, who manages every account,
|
||||
# and a Project Super User, who manages the accounts on the projects they administer.
|
||||
@@ -986,9 +1046,6 @@ def user_scope(user: models.User = Depends(auth.get_current_user), db: Session =
|
||||
|
||||
@app.post("/api/auth/users")
|
||||
def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
|
||||
problem = auth.password_problem(body.password, body.username, body.email)
|
||||
if problem:
|
||||
raise HTTPException(status_code=400, detail=problem)
|
||||
allowed = grantable_roles(actor)
|
||||
if body.role not in allowed:
|
||||
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(allowed)}")
|
||||
@@ -1020,7 +1077,6 @@ def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manag
|
||||
username=body.username.strip(),
|
||||
email=body.email.strip(),
|
||||
full_name=body.full_name.strip(),
|
||||
password_hash=auth.hash_password(body.password),
|
||||
role=body.role,
|
||||
project_role=body.project_role.strip()[:120],
|
||||
)
|
||||
@@ -1047,24 +1103,6 @@ def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manag
|
||||
return directory_entry(db, u, actor)
|
||||
|
||||
|
||||
@app.post("/api/auth/users/{user_id}/password")
|
||||
def admin_reset_password(user_id: str, body: AdminPasswordIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
|
||||
u = load_target_user(db, user_id)
|
||||
require_see_user(db, actor, u)
|
||||
require_manage_user(db, actor, u)
|
||||
problem = auth.password_problem(body.new_password, u.username, u.email)
|
||||
if problem:
|
||||
raise HTTPException(status_code=400, detail=problem)
|
||||
u.password_hash = auth.hash_password(body.new_password)
|
||||
u.token_version = (u.token_version or 0) + 1 # revoke the user's existing sessions
|
||||
# An administrative password reset was the one user-account change that left no
|
||||
# trace; it is the most impersonation-adjacent thing on this page, so it logs.
|
||||
log_event(db, actor, "password_reset", "user", u.id, summary=u.username,
|
||||
detail={"by": "administrator"})
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/auth/users/{user_id}/active")
|
||||
def set_user_active(user_id: str, body: ActiveIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
|
||||
u = load_target_user(db, user_id)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Authentication for the Work Package Suite.
|
||||
|
||||
A self-contained username/password login. Passwords are stored only as bcrypt
|
||||
hashes; a successful login issues a signed JWT that rides in an HttpOnly cookie
|
||||
(`wp_session`). Because the token is signed and self-validating, there is no
|
||||
Authentication is an LDAPS simple bind against the domain (D13); this module owns
|
||||
everything *after* that. A successful sign-in issues a signed JWT that rides in an
|
||||
HttpOnly cookie (`wp_session`). Because the token is signed and self-validating, there is no
|
||||
server-side session store — every request is checked by verifying the cookie's
|
||||
signature and expiry (see `auth_gate` and `get_current_user`).
|
||||
|
||||
@@ -12,6 +12,8 @@ Security model:
|
||||
• 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
|
||||
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
|
||||
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.
|
||||
@@ -35,10 +37,11 @@ The user-administration SCOPE of a super user is worked out in server/app.py
|
||||
(`managed_project_ids`, `manage_user_problem`), because it depends on project
|
||||
membership rows — this module only decides which roles carry the power at all.
|
||||
|
||||
Password reset: a short-lived signed token (see `create_reset_token`) is emailed
|
||||
to the account's address. It is single-use by construction — it embeds the user's
|
||||
`token_version`, which is bumped when the password changes, so a used or
|
||||
superseded link stops validating.
|
||||
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 secrets
|
||||
@@ -46,7 +49,6 @@ import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy import select, func
|
||||
@@ -61,8 +63,6 @@ COOKIE_NAME = "wp_session"
|
||||
JWT_ALG = "HS256"
|
||||
# How long a login lasts before the user must sign in again.
|
||||
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12"))
|
||||
# How long an emailed password-reset link stays valid.
|
||||
RESET_MINUTES = int(os.getenv("AUTH_RESET_MINUTES", "60"))
|
||||
|
||||
# ── permissions roles ─────────────────────────────────────────────────────────
|
||||
ROLE_ADMIN = "admin"
|
||||
@@ -121,29 +121,6 @@ def is_project_admin(user: "models.User") -> bool:
|
||||
# same question used to exist here and silently disagreed with the scoped one, which
|
||||
# locked per-project super users out of the routes they were entitled to.
|
||||
|
||||
# Password policy (shared by the API and the CLI).
|
||||
MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12"))
|
||||
_COMMON_PASSWORDS = {
|
||||
"password", "password1", "password123", "passw0rd", "12345678", "123456789",
|
||||
"1234567890", "qwerty123", "letmein123", "changeme", "admin123", "welcome123",
|
||||
"iloveyou1", "abc12345", "qwertyuiop",
|
||||
}
|
||||
|
||||
|
||||
def password_problem(pw: str, username: str = "", email: str = "") -> Optional[str]:
|
||||
"""Return a human-readable reason the password is unacceptable, or None if OK.
|
||||
Shared by the API endpoints and the CLI so the policy is enforced everywhere."""
|
||||
if len(pw) < MIN_PASSWORD_LEN:
|
||||
return f"Password must be at least {MIN_PASSWORD_LEN} characters."
|
||||
low = pw.lower()
|
||||
if username and low == username.strip().lower():
|
||||
return "Password must not be the same as the username."
|
||||
if email and low == email.strip().lower():
|
||||
return "Password must not be the same as the email."
|
||||
if low in _COMMON_PASSWORDS:
|
||||
return "That password is too common — choose something less guessable."
|
||||
return None
|
||||
|
||||
# Paths under /api that do NOT require a session (login itself, health, docs).
|
||||
_EXEMPT_PREFIXES = ("/api/auth/",)
|
||||
_EXEMPT_EXACT = {
|
||||
@@ -184,22 +161,6 @@ def _load_secret() -> str:
|
||||
SECRET_KEY = _load_secret()
|
||||
|
||||
|
||||
# ── password hashing ──────────────────────────────────────────────────────────
|
||||
def hash_password(plain: str) -> str:
|
||||
# bcrypt operates on at most 72 bytes; longer inputs are truncated by the
|
||||
# algorithm. Encode explicitly so non-ASCII passwords hash consistently.
|
||||
return bcrypt.hashpw(plain.encode("utf-8")[:72], bcrypt.gensalt()).decode("ascii")
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
if not hashed:
|
||||
return False
|
||||
try:
|
||||
return bcrypt.checkpw(plain.encode("utf-8")[:72], hashed.encode("ascii"))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
# ── tokens ──────────────────────────────────────────────────────────────────
|
||||
def create_token(user: "models.User") -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -227,33 +188,6 @@ def decode_token(token: str) -> Optional[dict]:
|
||||
return claims
|
||||
|
||||
|
||||
def create_reset_token(user: "models.User") -> str:
|
||||
"""Short-lived, single-use token for an emailed password-reset link.
|
||||
|
||||
Single-use falls out of `ver`: completing a reset bumps the user's
|
||||
token_version, so the link (and any older link) no longer validates."""
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"typ": "pwreset",
|
||||
"sub": user.id,
|
||||
"ver": user.token_version or 0,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(minutes=RESET_MINUTES),
|
||||
}
|
||||
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
|
||||
|
||||
|
||||
def decode_reset_token(token: str) -> Optional[dict]:
|
||||
"""Claims for a valid, unexpired reset token, else None."""
|
||||
try:
|
||||
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
if claims.get("typ") != "pwreset":
|
||||
return None
|
||||
return claims
|
||||
|
||||
|
||||
# ── cookie helpers ────────────────────────────────────────────────────────────
|
||||
def _is_https(request: Request) -> bool:
|
||||
# Behind NGINX, TLS is terminated at the proxy and forwarded as plain HTTP,
|
||||
|
||||
66
server/certs/prime-ca-chain.pem
Normal file
66
server/certs/prime-ca-chain.pem
Normal file
@@ -0,0 +1,66 @@
|
||||
# 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-----
|
||||
462
server/ldap_auth.py
Normal file
462
server/ldap_auth.py
Normal file
@@ -0,0 +1,462 @@
|
||||
"""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"
|
||||
GROUP_CHECK_FAILED = "group_check_failed"
|
||||
|
||||
# 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,
|
||||
GROUP_CHECK_FAILED)
|
||||
|
||||
|
||||
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:
|
||||
"""Is `sam` in `group`, counting nested membership?
|
||||
|
||||
THE RETURN VALUE OF conn.search() IS NOT OPTIONAL READING. The connection is
|
||||
built with raise_exceptions=False, so a search that FAILS returns False and
|
||||
leaves conn.entries empty — which is byte-for-byte indistinguishable from "no
|
||||
match" if you only look at conn.entries. An earlier version of this function did
|
||||
exactly that, and every failure of the extensible-match filter presented to the
|
||||
user as "you are not in the group" while they plainly were.
|
||||
|
||||
Two searches, in order, and the second one exists to catch the first being wrong:
|
||||
|
||||
1. AD's transitive matching rule (LDAP_MATCHING_RULE_IN_CHAIN). This is the
|
||||
correct query — it walks nested groups, which plain memberOf does not.
|
||||
2. If that matches nothing, a plain memberOf equality check for DIRECT
|
||||
membership.
|
||||
|
||||
If (2) matches after (1) did not, the person IS a member and is let in — refusing
|
||||
a real member is the worse error — but it is logged as a WARNING, because it means
|
||||
the transitive rule is returning nothing and NESTED membership is silently not
|
||||
working on this connection. That needs a human; it must not pass unnoticed.
|
||||
"""
|
||||
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)
|
||||
|
||||
esc_sam, esc_dn = escape_filter_chars(sam), escape_filter_chars(dn)
|
||||
|
||||
def _search(filt: str, label: str):
|
||||
ok = conn.search(base_dn(), filt, search_scope=SUBTREE,
|
||||
attributes=["sAMAccountName"], size_limit=1)
|
||||
if not ok:
|
||||
log.error("the %s membership search FAILED (not 'no match') for %r: %s | "
|
||||
"filter=%s", label, sam, conn.result, filt)
|
||||
raise LookupError(GROUP_CHECK_FAILED)
|
||||
return bool(conn.entries)
|
||||
|
||||
if _search(f"(&(sAMAccountName={esc_sam})(memberOf:{NESTED_MEMBER_RULE}:={esc_dn}))",
|
||||
"nested"):
|
||||
return True
|
||||
|
||||
if _search(f"(&(sAMAccountName={esc_sam})(memberOf={esc_dn}))", "direct"):
|
||||
log.warning(
|
||||
"%r IS a direct member of %r, but AD's transitive matching rule "
|
||||
"(%s) returned nothing for them. Allowing the sign-in — refusing a real "
|
||||
"member is worse — but NESTED group membership is not working on this "
|
||||
"connection and needs investigating.", sam, dn, NESTED_MEMBER_RULE)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
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.warning("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.warning("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 as exc:
|
||||
reason = str(exc) or GROUP_NOT_FOUND
|
||||
return LdapResult(False, reason, f"group {group!r}: {reason}")
|
||||
|
||||
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
|
||||
95
server/ldap_fake.py
Normal file
95
server/ldap_fake.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""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,21 +1,42 @@
|
||||
"""Command-line user management for the Work Package Suite.
|
||||
|
||||
Use this to create the FIRST admin account (the /api/auth/users endpoint needs an
|
||||
existing admin, so you have to bootstrap one here), and for occasional account
|
||||
maintenance from a shell on the server.
|
||||
Accounts are not created here any more. D13 provisions them on first successful
|
||||
sign-in, so this tool exists to do the one thing the directory cannot decide:
|
||||
assign the app's PERMISSIONS role. The directory supplies identity; this supplies
|
||||
authorization.
|
||||
|
||||
Run from the PROJECT ROOT (same place you run uvicorn), so the package imports
|
||||
and .env resolve the same way the API does:
|
||||
|
||||
python -m server.manage_users create-admin alice --name "Alice Smith"
|
||||
python -m server.manage_users create bob --role user --name "Bob Jones"
|
||||
python -m server.manage_users list
|
||||
python -m server.manage_users reset-password alice
|
||||
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 enable bob
|
||||
|
||||
If --password is omitted you'll be prompted (input is hidden). Passwords must be
|
||||
at least 8 characters.
|
||||
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 getpass
|
||||
@@ -23,124 +44,194 @@ import sys
|
||||
import uuid
|
||||
|
||||
from .db import SessionLocal, Base, engine
|
||||
from . import models, auth
|
||||
from . import models, auth, ldap_auth
|
||||
|
||||
|
||||
def _gen_id() -> str:
|
||||
return f"user_{uuid.uuid4().hex[:12]}"
|
||||
def _gen_id(prefix: str = "user") -> str:
|
||||
return f"{prefix}_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
def _prompt_password(provided: str | None, username: str = "") -> str:
|
||||
pw = provided
|
||||
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:
|
||||
pw = getpass.getpass("New password: ")
|
||||
confirm = getpass.getpass("Confirm password: ")
|
||||
if pw != confirm:
|
||||
sys.exit("Passwords do not match.")
|
||||
problem = auth.password_problem(pw, username)
|
||||
if problem:
|
||||
sys.exit(problem)
|
||||
return pw
|
||||
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 cmd_create(args, role: str | None = None) -> None:
|
||||
role = role or args.role
|
||||
# 'user' is the pre-roles spelling of 'project_user' and is still accepted so the
|
||||
# documented one-liners keep working; anything else has to be a current role.
|
||||
if role == "user":
|
||||
role = auth.ROLE_PROJECT_USER
|
||||
if role not in auth.ROLES:
|
||||
sys.exit(f"role must be one of {', '.join(auth.ROLES)}")
|
||||
pw = _prompt_password(getattr(args, "password", None), args.username)
|
||||
with SessionLocal() as db:
|
||||
if auth.find_user(db, args.username):
|
||||
sys.exit(f"A user named '{args.username}' already exists.")
|
||||
u = models.User(
|
||||
id=_gen_id(),
|
||||
username=args.username.strip(),
|
||||
full_name=(args.name or "").strip(),
|
||||
email=(args.email or "").strip(),
|
||||
password_hash=auth.hash_password(pw),
|
||||
role=role,
|
||||
)
|
||||
db.add(u)
|
||||
db.commit()
|
||||
print(f"Created {role}: {u.username} (id={u.id})")
|
||||
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 users yet. Create one with: create-admin <username>")
|
||||
print("No accounts yet. They are created on first successful sign-in.")
|
||||
return
|
||||
print(f"{'USERNAME':<24}{'ROLE':<20}{'ACTIVE':<8}{'NAME'}")
|
||||
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}{u.full_name}")
|
||||
f"{('yes' if u.is_active else 'no'):<8}{last:<22}{u.full_name}")
|
||||
|
||||
|
||||
def cmd_reset_password(args) -> None:
|
||||
pw = _prompt_password(getattr(args, "password", None), args.username)
|
||||
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 = auth.find_user(db, args.username)
|
||||
if not u:
|
||||
sys.exit(f"No user named '{args.username}'.")
|
||||
u.password_hash = auth.hash_password(pw)
|
||||
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"Password reset for {u.username}.")
|
||||
print(f"{u.username}: {old} -> {role}")
|
||||
|
||||
|
||||
def cmd_promote(args) -> None:
|
||||
_set_role(args.username, args.role)
|
||||
|
||||
|
||||
def cmd_demote(args) -> None:
|
||||
_set_role(args.username, auth.ROLE_PROJECT_USER)
|
||||
|
||||
|
||||
def _set_active(username: str, active: bool) -> None:
|
||||
operator = authenticate_operator()
|
||||
with SessionLocal() as db:
|
||||
u = auth.find_user(db, username)
|
||||
if not u:
|
||||
sys.exit(f"No user named '{username}'.")
|
||||
u = _load(db, username)
|
||||
if bool(u.is_active) == active:
|
||||
print(f"{u.username} is already {'enabled' if active else 'disabled'}.")
|
||||
return
|
||||
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()
|
||||
print(f"{u.username} is now {'enabled' if active else 'disabled'}.")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# Ensure the users table exists even on a fresh database.
|
||||
# Ensure tables exist on a fresh local database (SQLite dev). Production owns
|
||||
# its schema through alembic.
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
p = argparse.ArgumentParser(prog="manage_users", description="Work Package Suite user management")
|
||||
p = argparse.ArgumentParser(
|
||||
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)
|
||||
|
||||
def add_create(name, help_):
|
||||
sp = sub.add_parser(name, help=help_)
|
||||
sp.add_argument("username")
|
||||
sp.add_argument("--password", help="set non-interactively (otherwise prompted)")
|
||||
sp.add_argument("--name", default="", help="full name")
|
||||
sp.add_argument("--email", default="")
|
||||
return sp
|
||||
sub.add_parser("list", help="list all accounts (no credential needed)")
|
||||
|
||||
add_create("create-admin", "create an admin account")
|
||||
c = add_create("create", "create an account")
|
||||
c.add_argument("--role", choices=list(auth.ROLES) + ["user"], default=auth.ROLE_PROJECT_USER,
|
||||
help="permissions role ('user' is the legacy name for project_user)")
|
||||
pr = sub.add_parser("promote", help="raise an account's permissions role (needs a domain bind)")
|
||||
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)")
|
||||
|
||||
sub.add_parser("list", help="list all accounts")
|
||||
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")
|
||||
|
||||
rp = sub.add_parser("reset-password", help="reset a user's password")
|
||||
rp.add_argument("username")
|
||||
rp.add_argument("--password", help="set non-interactively (otherwise prompted)")
|
||||
|
||||
dp = sub.add_parser("disable", help="disable an account (blocks login)")
|
||||
dp = sub.add_parser("disable", help="disable an account (blocks sign-in)")
|
||||
dp.add_argument("username")
|
||||
ep = sub.add_parser("enable", help="re-enable an account")
|
||||
ep.add_argument("username")
|
||||
|
||||
args = p.parse_args()
|
||||
if args.cmd == "create-admin":
|
||||
cmd_create(args, role="admin")
|
||||
elif args.cmd == "create":
|
||||
cmd_create(args)
|
||||
elif args.cmd == "list":
|
||||
if args.cmd == "list":
|
||||
cmd_list(args)
|
||||
elif args.cmd == "reset-password":
|
||||
cmd_reset_password(args)
|
||||
elif args.cmd == "promote":
|
||||
cmd_promote(args)
|
||||
elif args.cmd == "demote":
|
||||
cmd_demote(args)
|
||||
elif args.cmd == "disable":
|
||||
_set_active(args.username, False)
|
||||
elif args.cmd == "enable":
|
||||
|
||||
@@ -142,8 +142,14 @@ class WorkPackage(Base):
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""A login account. Passwords are never stored in the clear — only a bcrypt
|
||||
hash (see server/auth.py). `username` is what people sign in with.
|
||||
"""A login account. NO PASSWORD IS STORED — D13 moved authentication to an
|
||||
LDAPS bind against the domain (see server/ldap_auth.py), and the
|
||||
`password_hash` column was dropped. `username` is the sAMAccountName people
|
||||
sign in with; an account is created on first successful sign-in if it does not
|
||||
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:
|
||||
• role the PERMISSIONS role — what the account may do in the app.
|
||||
@@ -159,7 +165,6 @@ class User(Base):
|
||||
username: Mapped[str] = mapped_column(String(120), unique=True, index=True)
|
||||
email: Mapped[str] = mapped_column(String(200), default="")
|
||||
full_name: Mapped[str] = mapped_column(String(200), default="")
|
||||
password_hash: Mapped[str] = mapped_column(String(200), default="")
|
||||
role: Mapped[str] = mapped_column(String(20), default="project_user") # permissions role
|
||||
# Job function on the project — free text, offered from a suggested list.
|
||||
project_role: Mapped[str] = mapped_column(String(120), default="")
|
||||
@@ -182,12 +187,14 @@ class User(Base):
|
||||
# Online-guessing throttle (see login()): consecutive failures + a lockout window.
|
||||
failed_attempts: Mapped[int] = mapped_column(Integer, default=0)
|
||||
locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# Bumped to invalidate all existing sessions for this user (e.g. on a password
|
||||
# change). The value is embedded in the JWT and re-checked on every request.
|
||||
# Bumped to invalidate all existing sessions for this user. Password changes no
|
||||
# longer exist (D13), but a role change or a deactivation still has to take
|
||||
# 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)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Public view of a user — NEVER includes the password hash."""
|
||||
"""Public view of a user."""
|
||||
return {
|
||||
"id": self.id, "username": self.username, "email": self.email,
|
||||
"full_name": self.full_name, "role": self.role,
|
||||
|
||||
@@ -49,7 +49,8 @@ DEFAULTS = {
|
||||
|
||||
# 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
|
||||
# defaults + whether self-service password reset can work at all).
|
||||
# defaults). Self-service password reset is gone with D13 — the login page links to
|
||||
# Okta instead, so there is nothing left for the client to feature-detect.
|
||||
PUBLIC_KEYS = ("bim_enabled", "default_locale", "default_timezone")
|
||||
|
||||
|
||||
@@ -83,13 +84,9 @@ def public_settings(db: Session) -> dict:
|
||||
|
||||
|
||||
def app_flags(db: Session) -> dict:
|
||||
"""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."""
|
||||
"""Feature flags for any signed-in user (no secrets, no SMTP detail)."""
|
||||
s = get_settings(db)
|
||||
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
|
||||
return {k: s.get(k) for k in PUBLIC_KEYS}
|
||||
|
||||
|
||||
def smtp_ready(s: dict) -> bool:
|
||||
@@ -124,22 +121,6 @@ def send_email(s: dict, to_addr: str, subject: str, body: str) -> None:
|
||||
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,
|
||||
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 +
|
||||
|
||||
@@ -19,4 +19,8 @@ pydantic==2.13.4
|
||||
python-dotenv==1.2.2
|
||||
bcrypt==5.0.0 # password hashing
|
||||
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)
|
||||
|
||||
@@ -26,6 +26,7 @@ browser found, or the server would not start). 2 is distinct on purpose: "I coul
|
||||
not test this" is not the same answer as "this is broken".
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -86,7 +87,7 @@ def seed(db_path):
|
||||
def mk(username, role):
|
||||
db.add(models.User(id="user_" + username, username=username,
|
||||
email=f"{username}@example.test", full_name=username.title(),
|
||||
password_hash=auth.hash_password(PW), role=role))
|
||||
role=role))
|
||||
|
||||
mk("root", auth.ROLE_ADMIN)
|
||||
mk("sue", auth.ROLE_PROJECT_SUPER) # super user on Job A
|
||||
@@ -150,10 +151,33 @@ def seed(db_path):
|
||||
for u in db.query(models.User).all()}
|
||||
|
||||
|
||||
# D13 / T10.7. The app authenticates by binding to a domain controller, which no
|
||||
# test can reach, and start_server launches it as a SUBPROCESS — so a monkeypatch
|
||||
# here would never reach the code doing the authenticating. server/ldap_fake.py
|
||||
# reads this instead, and refuses to work against a non-SQLite database.
|
||||
#
|
||||
# Most checks never sign in (seed() mints tokens with auth.create_token and sets
|
||||
# the cookie directly), so this matters only where the login FORM is driven —
|
||||
# url_state_check's deep-link-through-login case. It is set for every server here
|
||||
# anyway so that a test which starts signing in later does not fail mysteriously.
|
||||
FAKE_DIRECTORY = json.dumps({
|
||||
u: {"password": PW, "mail": f"{u}@example.test", "full_name": u.title(),
|
||||
"groups": ["WP-Suite-Users"]}
|
||||
for u in ("root", "sue", "pat", "mix", "bob", "sam", "legacy", "new")
|
||||
})
|
||||
|
||||
|
||||
def start_server(port, db_path):
|
||||
env = dict(os.environ)
|
||||
env["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/")
|
||||
env.setdefault("AUTH_SECRET_KEY", "browser-check-secret-not-for-production")
|
||||
env["WP_LDAP_FAKE_DIRECTORY"] = FAKE_DIRECTORY
|
||||
# SET empty, never pop: server/db.py calls load_dotenv() at import and
|
||||
# python-dotenv only skips keys already present in os.environ, so a popped
|
||||
# variable comes back from the developer's .env inside the subprocess. An empty
|
||||
# string is "present" and therefore wins. The fake grants "WP-Suite-Users" to
|
||||
# everyone; a test asserting the group gate belongs in ldap_auth_check.
|
||||
env["LDAP_REQUIRED_GROUP"] = ""
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "server.app:app", "--host", "127.0.0.1",
|
||||
"--port", str(port), "--log-level", "warning"],
|
||||
|
||||
@@ -91,31 +91,6 @@ def main():
|
||||
chk("the console booted with a user table",
|
||||
page.eval("!!document.querySelector('table')"))
|
||||
|
||||
page.eval("void resetPw('user_pat','pat')")
|
||||
time.sleep(0.4)
|
||||
chk("the reset prompt is the kit's modal, open, focused at the input",
|
||||
page.eval("(() => { const o=document.getElementById('wp-dlg-overlay');"
|
||||
" return !!o && o.classList.contains('open')"
|
||||
" && document.activeElement.id==='wp-dlg-input'; })()"))
|
||||
page.eval("document.getElementById('wp-dlg-input').value='short';"
|
||||
"document.getElementById('wp-dlg-ok').click()")
|
||||
chk("a short password is refused AT the input - dialog stays, error says why",
|
||||
page.eval("(() => { const o=document.getElementById('wp-dlg-overlay');"
|
||||
" return o.classList.contains('open')"
|
||||
" && /12 characters/.test(document.getElementById('wp-dlg-err').textContent); })()"))
|
||||
page.eval("document.getElementById('wp-dlg-input').value='CorrectHorseBattery10';"
|
||||
"document.getElementById('wp-dlg-ok').click()")
|
||||
time.sleep(1.2)
|
||||
chk("a good answer closes the dialog and the server accepts it",
|
||||
page.eval("!document.getElementById('wp-dlg-overlay').classList.contains('open')"))
|
||||
chk("...announced through the kit's toast (role=status)",
|
||||
page.eval("(() => { const t=document.getElementById('toast');"
|
||||
" return !!t && t.getAttribute('role')==='status'"
|
||||
" && /Password reset for pat/.test(t.textContent); })()"))
|
||||
st, _ = api(base, "/api/auth/login", "x", "POST",
|
||||
{"username": "pat", "password": "CorrectHorseBattery10"})
|
||||
chk("...and the new password actually works", st == 200, st)
|
||||
|
||||
print("\n3. destroy needs a real yes")
|
||||
page.eval("void deleteUser('user_bob','bob')")
|
||||
time.sleep(0.4)
|
||||
|
||||
@@ -55,8 +55,7 @@ def seed_empty(db_path):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
with SessionLocal() as db:
|
||||
db.add(models.User(id="user_new", username="new", email="new@example.test",
|
||||
full_name="New Starter", password_hash=auth.hash_password(PW),
|
||||
role=auth.ROLE_ADMIN))
|
||||
full_name="New Starter", role=auth.ROLE_ADMIN))
|
||||
db.commit()
|
||||
return {u.username: auth.create_token(u) for u in db.query(models.User).all()}
|
||||
|
||||
|
||||
419
tests/ldap_auth_check.py
Normal file
419
tests/ldap_auth_check.py
Normal file
@@ -0,0 +1,419 @@
|
||||
#!/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"
|
||||
SECRET = "ldap-auth-check-not-for-production"
|
||||
os.environ.setdefault("AUTH_SECRET_KEY", SECRET)
|
||||
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 get(base, path, token):
|
||||
req = urllib.request.Request(base + path, headers={"Cookie": f"wp_session={token}"})
|
||||
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:
|
||||
return e.code, {}
|
||||
|
||||
|
||||
def post_as(base, path, token, payload):
|
||||
req = urllib.request.Request(base + path, method="POST",
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={"Content-Type": "application/json",
|
||||
"Cookie": f"wp_session={token}"})
|
||||
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:
|
||||
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"] = SECRET
|
||||
env["WP_LDAP_FAKE_DIRECTORY"] = json.dumps(fake)
|
||||
# SET it empty, never pop it. server/db.py calls load_dotenv() at import, and
|
||||
# python-dotenv only skips a key that is already present in os.environ — so a
|
||||
# popped variable is helpfully restored from the developer's .env inside the
|
||||
# subprocess, and the test silently runs against the real required group.
|
||||
# An empty string counts as present, so it wins.
|
||||
env["LDAP_REQUIRED_GROUP"] = required_group or ""
|
||||
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("")
|
||||
print("4. local state overrides the directory, and the throttle protects it")
|
||||
db_fd3, db3 = tempfile.mkstemp(suffix=".db"); os.close(db_fd3)
|
||||
import sqlalchemy as _sa0
|
||||
from server.db import Base as _B0
|
||||
from server import models as _m0 # noqa: F401
|
||||
eng0 = _sa0.create_engine("sqlite:///" + db3.replace("\\", "/"))
|
||||
_B0.metadata.create_all(bind=eng0)
|
||||
with eng0.begin() as con:
|
||||
con.execute(_sa0.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','','Root','project_user',0,0,0,'','','',0,'',"
|
||||
"datetime('now'),datetime('now'))")) # is_active = 0
|
||||
eng0.dispose()
|
||||
port3 = free_port()
|
||||
server3 = start(port3, db3, fake, required_group=GROUP)
|
||||
if server3 is None:
|
||||
print("the third test server would not start.")
|
||||
return 2
|
||||
b3 = f"http://127.0.0.1:{port3}"
|
||||
try:
|
||||
st, _ = post(b3, "/api/auth/login", {"username": "root", "password": PW})
|
||||
chk("a disabled local account is refused even though the bind succeeds",
|
||||
st == 403, st)
|
||||
|
||||
# The throttle. AUTH_MAX_ATTEMPTS is 2, and its whole purpose is that failures
|
||||
# are real domain binds counting against the AD lockout policy — so it has to
|
||||
# stop CALLING the directory, not merely refuse. Proving that: burn the budget
|
||||
# with wrong passwords, then present the CORRECT one. A 429 for a credential
|
||||
# that would otherwise succeed is only possible if the throttle runs before the
|
||||
# directory is consulted.
|
||||
codes = [post(b3, "/api/auth/login",
|
||||
{"username": "outsider", "password": "wrong"})[0] for _ in range(3)]
|
||||
chk("the attempt budget is spent and the next try is throttled",
|
||||
codes[-1] == 429, codes)
|
||||
st, _ = post(b3, "/api/auth/login", {"username": "outsider", "password": PW})
|
||||
chk("...and a CORRECT password is still refused while throttled, proving the "
|
||||
"directory is never reached", st == 429, st)
|
||||
chk("...while another account is unaffected (the budget is per-username)",
|
||||
post(b3, "/api/auth/login", {"username": "root", "password": PW})[0] == 403, "")
|
||||
finally:
|
||||
server3.kill()
|
||||
try:
|
||||
server3.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("")
|
||||
print("5. the console's own routes, and what token_version actually does")
|
||||
db_fd4, db4 = tempfile.mkstemp(suffix=".db"); os.close(db_fd4)
|
||||
import sqlalchemy as _sa1
|
||||
from server.db import Base as _B1
|
||||
from server import models as _m1, auth as _auth # noqa: F401
|
||||
url4 = "sqlite:///" + db4.replace("\\", "/")
|
||||
eng1 = _sa1.create_engine(url4)
|
||||
_B1.metadata.create_all(bind=eng1)
|
||||
with eng1.begin() as con:
|
||||
for uid, un, role in [("user_boss", "root", "admin"),
|
||||
("user_pat", "pat", "project_user")]:
|
||||
con.execute(_sa1.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 "
|
||||
f"('{uid}','{un}','','{un}','{role}',1,0,0,'','','',0,'',"
|
||||
"datetime('now'),datetime('now'))"))
|
||||
|
||||
class _U:
|
||||
pass
|
||||
def _tok(uid, un, role):
|
||||
u = _U(); u.id = uid; u.username = un; u.role = role; u.token_version = 0
|
||||
return _auth.create_token(u)
|
||||
boss_tok = _tok("user_boss", "root", "admin")
|
||||
pat_tok = _tok("user_pat", "pat", "project_user")
|
||||
|
||||
port4 = free_port()
|
||||
server4 = start(port4, db4, fake, required_group="")
|
||||
if server4 is None:
|
||||
print("the fourth test server would not start.")
|
||||
return 2
|
||||
b4 = f"http://127.0.0.1:{port4}"
|
||||
try:
|
||||
st, body = get(b4, "/api/auth/users", boss_tok)
|
||||
chk("an admin can list accounts", st == 200, st)
|
||||
|
||||
post(b4, "/api/auth/login", {"username": "outsider", "password": PW})
|
||||
st, body = get(b4, "/api/auth/users", boss_tok)
|
||||
rows = body if isinstance(body, list) else (body.get("users") or body.get("items") or [])
|
||||
names = [u.get("username") for u in rows if isinstance(u, dict)]
|
||||
chk("a just-provisioned account appears in the Admin console list",
|
||||
"outsider" in names, names)
|
||||
|
||||
# Exactly the request users.html sends — D13 criterion 4.
|
||||
st, _ = post_as(b4, "/api/auth/users/user_pat/role", boss_tok, {"role": "admin"})
|
||||
chk("granting admin through the console route succeeds", st == 200, st)
|
||||
chk("...and the role really changed", users_in(db4).get("pat") == "admin", users_in(db4))
|
||||
|
||||
# token_version. NOTHING in app.py bumps it any more: is_active and role are
|
||||
# re-read from the database every request, so both take effect at once without
|
||||
# it. What it still does is invalidate an ALREADY-ISSUED cookie, which is what
|
||||
# manage_users does on disable. Bump it directly and prove the effect.
|
||||
eng2 = _sa1.create_engine(url4)
|
||||
with eng2.begin() as con:
|
||||
con.execute(_sa1.text("update users set token_version = 1 where id='user_pat'"))
|
||||
eng2.dispose()
|
||||
st, _ = get(b4, "/api/auth/me", pat_tok)
|
||||
chk("bumping token_version invalidates a cookie already issued", st == 401, st)
|
||||
st, _ = get(b4, "/api/auth/me", boss_tok)
|
||||
chk("...and leaves every other session alone", st == 200, st)
|
||||
|
||||
st, body = post(b4, "/api/auth/login", {"username": "root", "password": "wrong"})
|
||||
blob = json.dumps(body).lower()
|
||||
chk("no AD sub-code leaks into a response body",
|
||||
not any(c in blob for c in ("52e", "525", "532", "533", "775", "data ")), body)
|
||||
from server import ldap_auth as _la
|
||||
chk("...though _err49 does parse one when AD sends it",
|
||||
"52e" in _la._err49({"message": "80090308: LdapErr: DSID-0C0903A9, comment: "
|
||||
"AcceptSecurityContext error, data 52e, v4563"}))
|
||||
finally:
|
||||
eng1.dispose()
|
||||
server4.kill()
|
||||
try:
|
||||
server4.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)
|
||||
@@ -187,9 +187,13 @@ def main():
|
||||
break
|
||||
time.sleep(0.3)
|
||||
settle(page, 1.2)
|
||||
# NOT `"wp-creation-index.html" in location.href` — that string is in the
|
||||
# ?next= parameter too, so the check passed while still sitting on
|
||||
# login.html with the sign-in rejected. Assert we actually LEFT the
|
||||
# login page (D13/T10.7: it caught nothing when the bind started failing).
|
||||
href = page.eval("location.href")
|
||||
chk("signing in continues to the requested page, not the home page",
|
||||
"wp-creation-index.html" in page.eval("location.href"),
|
||||
page.eval("location.href"))
|
||||
"login.html" not in href and "wp-creation-index.html" in href, href)
|
||||
for _ in range(30):
|
||||
if page.eval("!!window.wpCreatorReady"):
|
||||
break
|
||||
|
||||
Reference in New Issue
Block a user