T10.8 D13/D14 - documentation matches what the code now does

DEPLOY-login-portal.md was the most wrong and is rewritten. It described taking
a username/password portal live - bcrypt, and a first admin created with
`create-admin --password`. Every command in it now fails. It keeps its filename
and carries a note saying what it replaced, because an admin holding the old
copy needs to know why the steps stopped working rather than concluding the
deploy is broken. New content leads with the warning that there is no
break-glass, and puts verification BEFORE announcing the deploy - the log line,
the certificate check that binds nothing, then a real sign-in.

DEPLOYMENT.md: AUTH_RESET_* replaced with the LDAP variables; the users table
row no longer claims a password_hash column; "Self-service password reset"
replaced by a section saying there isn't one and pointing at Okta. New "Domain
authentication" section covering the three things that are not obvious - why
prime.local and never a DC or an IP, why the CA bundle is not a certificate
issued to this app (with the thumbprints and a Get-ChildItem line to rebuild
it), and why the outbound network stopped being optional - plus the lockout
arithmetic written out so the next person to raise AUTH_MAX_ATTEMPTS sees the
constraint rather than a magic 2.

server/README.md: endpoint table drops /api/auth/password and gains the role
route; the login-portal section becomes domain authentication; create-admin
becomes the two-step bootstrap (sign in, then promote).

CLAUDE.md: a new "authentication rules" section beside the token rule, for the
same reason that one exists - four things that look like tidying-up if you do
not know why. The empty-password guard that must run before bind(), CERT_REQUIRED
with an explicit CA file, AUTH_MAX_ATTEMPTS being arithmetic rather than taste,
and connecting to the domain name rather than a DC. Plus: no break-glass, and
roles are local - never read a role from AD.

Closed three done-when boxes that were open rather than ticked:

  T10.8  all of them
  T10.9  promote/demote verified against a real bind (Aug 24), not a stub
  T10.3  the Postgres round trip, on postgres:16-alpine

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-24 08:26:31 -05:00
parent 8d49fb9248
commit dc0cee240e
5 changed files with 315 additions and 111 deletions

View File

@@ -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`.