Phase 1: identity, sessions, audit, frontend shell

AD sign-in over LDAPS (search with the service account, bind as the user,
nested groups through LDAP_MATCHING_RULE_IN_CHAIN, disabled accounts refused),
roles as the union of PS-* groups mapped by a table derived from the seeded
departments. Server-side sessions with idle, lock and absolute timeouts, Origin
and X-Requested-With check, step-up unlock. Optional Argon2id PIN set with the
domain password; PIN sign-in by employee ID on registered devices only; five
wrong attempts disable the PIN, three wrong at unlock or signature end the
session. Rate limits per account and per address before any bind. Devices,
signatures table and sign helper, audit rows for every identity event, SSE over
LISTEN/NOTIFY with presence. Account settings and Admin APIs and screens
(AD view, devices, PIN clearing, group map with missing-group flags, refresh
from AD now). CLI ad-groups --list and ad-refresh. Migration 0002.

Frontend: real sign-in (password tab, PIN keypad on registered devices), lock
screen, role switcher, Account settings, Admin shell, screen placeholders.

CI: Samba AD DC container (PSTEST.LOCAL) with a generated CA, 49 of the 50
groups plus one extra, 17 test users; wired into the CI compose overlay and
the Gitea workflow; Playwright sign-in scenario.

Verified on docker-1: 171 server tests green inside the CI stack against
PostgreSQL and the Samba DC, 48 frontend tests, 8 Playwright scenarios against
the live stack (password and PIN sign-in, lock and unlock, Admin group map).
Findings recorded in the docs: the DC needs CAP_SYS_ADMIN, a hidden tab locks
only after a minute as the old build did,
and the app uses the alias ps-app for Playwright because "app" is on
Chromium's HSTS preload list.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-16 07:40:29 -06:00
co-authored by Claude Fable 5.1
parent 0b663bb2b2
commit 97b0973130
95 changed files with 9609 additions and 183 deletions
+16 -3
View File
@@ -2,7 +2,7 @@
This document is rewritten for the server build. The old browser build's security document is in the old repo (`C:\Users\spencerm\Coding\PacketStation\docs\SECURITY.md`). The design comes from [BRIEF.md](BRIEF.md) and [PLAN.md](PLAN.md).
Status: phase 0. Most controls below are designed, not built. Each one names the phase that builds it.
Status: phase 1. The identity controls (sessions, PIN, rate limits, audit, Origin check, derived group map) are built and tested against a Samba domain controller in CI. Every other control names the phase that builds it.
Contents:
@@ -44,9 +44,12 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro
- HttpOnly, SameSite=Strict session cookie. The session row on the server decides.
- Password sessions: idle sign-out after 15 minutes, absolute 12 hours. PIN shop sessions: absolute 10 hours, idle 15 minutes. All are settings.
- Idle and background lock are reported by the browser and enforced by the server. Unlock is a step-up check with the PIN or the domain password.
- Origin check on every state-changing request.
- Origin check on every state-changing request: the `Origin` header (or the `Referer` origin) must match the request host, and `X-Requested-With: PacketStation` must be present. Otherwise 403.
- Admin is a password session only. A PIN session cannot open Admin or change Account settings.
- Roles are resolved from AD at sign-in, cached on the session, and re-resolved every 15 minutes and nightly. A disabled AD account is always refused.
- Roles are resolved from AD at sign-in (nested groups through `LDAP_MATCHING_RULE_IN_CHAIN`), cached on the session, and re-resolved every 15 minutes on the session and by the AD refresh. A disabled AD account is always refused. A session whose user has lost every role is ended at the next refresh.
- The session cookie token is stored only as an HMAC with the session key. The AD password is used for the bind and discarded.
- The group-to-role map is derived from the department table and the `PS-` naming convention at startup. No screen edits it, so nobody can promote a group or themselves inside the app.
- The browser reports hidden tabs and idle time; the server records `locked_since` and refuses a locked session with 423 until a step-up unlock. The server also locks a session itself when no request arrived for the lock interval (3 minutes, as the old build's screen lock), and when a tab stayed hidden for over a minute (the old build's background lock). A reload or an in-app navigation never locks.
### PIN (phase 1)
@@ -54,6 +57,8 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro
- 6 to 12 digits, hashed with Argon2id on the server. Never visible to Admin. Admin can clear a PIN (audited), not set one.
- PIN sign-in uses the employee ID and works only on registered devices. Devices are registered by an Admin or a shop department Supervisor in a password session.
- Five wrong PIN attempts in a row, across sign-in, unlock and signature, disable the PIN. Three wrong attempts at unlock or signature end the session. A guessed PIN never touches AD.
- A PIN sign-in still checks AD with the service account: a disabled account, or one removed from every group, is refused.
- The device cookie is a random token stored as an HMAC; revoking a device ends every PIN session on it.
### Rate limits (phase 1)
@@ -61,6 +66,8 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro
- Defaults: 5 failed password sign-ins per account per 5 minutes, then a 10-minute cooldown. 30 failed attempts per client address per 5 minutes. Successful sign-ins never count.
- The domain's own lockout (20 attempts in 5 minutes) stays the last line.
- The client address comes from `X-Forwarded-For` only when the request comes from `npm_app_1` itself, re-resolved on change. Other containers on `npm_default` are not trusted.
- A refused attempt answers 429 with `Retry-After`, is recorded as `rate_limited` and never counts toward the limits itself.
- Failed sign-ins answer one message for an unknown account, a wrong password and a disabled account, so accounts cannot be enumerated. The audit and `signin_attempts` keep the real reason.
### Policy (phase 2)
@@ -74,6 +81,12 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro
- Append-only `audit_events`, with a hash chain (`prev_hash`, `row_hash`).
- The app role may insert and select, never update or delete. A trigger also refuses updates and deletes. Migrations run as a separate role.
- `packetstation doctor` checks that an audit UPDATE fails.
- Every identity event is a row: sign-in, failed sign-in with a reason, sign-out, lock, unlock, session ended, PIN set, disabled or cleared, device registered or revoked, signature, AD refresh. Each carries the user, session, roles, client address and a small detail object, never a secret.
- `signin_attempts` and `signatures` are append-only in the same way.
### CI identity fixtures (phase 1)
- CI runs a throwaway Samba Active Directory domain controller (`PSTEST.LOCAL`) built from `deploy/samba/`, with a generated CA so the LDAPS path is real. It is seeded with the 50 groups (one missing and one extra on purpose) and test users for every role, including nested, disabled, multi-role, missing and duplicate employee ID cases. Passwords are random per run and exported to a volume the tests read. PESCO's AD is never used by CI.
### Seals (phase 7)