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:
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user