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:
124
DEPLOYMENT.md
124
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_*`
|
||||
@@ -257,7 +264,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) |
|
||||
@@ -350,27 +357,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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user