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:
@@ -65,6 +65,8 @@ Key settings:
|
||||
- `LDAP_URL=ldaps://DC-02.PESCO.COM:636 ldaps://PDC-1.PESCO.COM:636`, `LDAP_BIND_DN=CN=svc-packetstation,OU=System_Accounts,OU=Admin,DC=PESCO,DC=com`, `LDAP_GROUP_BASE_DN=OU=PacketStation,OU=Groups,OU=PESCO_FARMINGTON,DC=PESCO,DC=com`, `LDAP_PASSWORD_FILE`, `LDAP_CA_BUNDLE`.
|
||||
- `SMTP_HOST=app-03.pesco.com`, `SMTP_PORT=25`, `SMTP_STARTTLS=true`, `[email protected]`, `SMTP_CA_BUNDLE`.
|
||||
- `APP_ALIAS` per stack.
|
||||
- Identity timing and limits, all optional with these defaults: `SESSION_IDLE_MINUTES=15`, `SESSION_LOCK_MINUTES=3`, `SESSION_HIDDEN_LOCK_SECONDS=60`, `SESSION_PASSWORD_HOURS=12`, `SESSION_PIN_HOURS=10`, `ROLES_REFRESH_MINUTES=15`, `SIGNIN_ACCOUNT_LIMIT=5`, `SIGNIN_ACCOUNT_WINDOW_MINUTES=5`, `SIGNIN_ACCOUNT_COOLDOWN_MINUTES=10`, `SIGNIN_IP_LIMIT=30`, `SIGNIN_IP_WINDOW_MINUTES=5`, `PIN_MAX_FAILURES=5`, `STEPUP_MAX_FAILURES=3`, `DEVICE_COOKIE_DAYS=365`, `PIN_MIN_DIGITS=6`, `PIN_MAX_DIGITS=12`.
|
||||
- The session cookie is marked `Secure` when the request arrived over https (as reported by NPM through `X-Forwarded-Proto`). Behind the proxy that is always the case.
|
||||
|
||||
## Deploy command
|
||||
|
||||
@@ -148,6 +150,14 @@ The image built on docker-1 from this tree (python:3.12-slim-bookworm, ODBC Driv
|
||||
|
||||
Inside containers the short name `VSQL\DEV` does not resolve. The env files use `vsql.pesco.com\DEV`.
|
||||
|
||||
## CI stack and the Samba domain controller (phase 1)
|
||||
|
||||
`deploy/compose.ci.yaml` adds a throwaway Samba Active Directory domain controller (`samba-dc`, realm `PSTEST.LOCAL`, host `dc1.pstest.local` on the internal `backend` network) built from `deploy/samba/`. On first start it provisions the domain, generates a CA and a server certificate, seeds the OU tree, the service account, the groups (49 of the 50, plus one extra, to test the report) and 17 test users with random passwords, and exports `ca.pem` and `users.json` to the `ci-dc-export` volume. The app, migrate and worker mount that volume at `/etc/ssl/pstest` and `deploy/ci/ci.env` points `LDAP_CA_BUNDLE` at it, so CI exercises the real LDAPS path. The `playwright` service (profile `e2e`) drives the app at `http://ps-app:8000` with the exported users. [deploy/samba/README.md](../deploy/samba/README.md) has the details and the commands.
|
||||
|
||||
The DC runs with `cap_add: [SYS_ADMIN]`, which Samba needs to set the sysvol ACLs. The app carries the alias `ps-app` for Playwright because `app` is a top-level domain on Chromium's HSTS preload list, so `http://app:8000` is forced to TLS and fails. Running the server test suite against the stack's own PostgreSQL is not safe: the `pg_settings` fixture resets the role passwords (see deploy/samba/README.md).
|
||||
|
||||
The real PESCO domain controllers are never used by CI. The test and production stacks do not run the DC.
|
||||
|
||||
## Backup (planned, phase 9)
|
||||
|
||||
`pg_dump` of both stacks from a systemd timer on the host at 02:00 America/Denver, before the 03:00 PBS run. Seven dumps kept in `/var/backups/packetstation/<stack>/` with last-success and last-failure markers. The worker's backup-check job alerts if the newest dump is missing or older than 26 hours. Scripts and units go in `scripts/`.
|
||||
|
||||
+1
-1
@@ -232,7 +232,7 @@ Smaller questions from the design, with the default the build takes unless told
|
||||
12. Partspecs, title or folded-operation target changes on an in-shop packet: default no change order, as the old build.
|
||||
13. Work-order-gone confirmation after 2 consecutive runs and the 10% suspect-batch guard: defaults as stated.
|
||||
14. Pause scope: default pauses the feed, the drawing job and auto-release together.
|
||||
15. Gitea runner: whether the Samba DC container can run there (overlayfs xattrs, possibly cap SYS_ADMIN). Checked in phase 1; if not, CI uses a runner with a Docker socket on docker-1.
|
||||
15. Gitea runner: whether the Samba DC container can run there (overlayfs xattrs, possibly cap SYS_ADMIN). Checked in phase 1; if not, CI uses a runner with a Docker socket on docker-1. Phase 1 finding (2026-10-16): the container needs `cap_add: [SYS_ADMIN]` (Samba 4.17 sets sysvol NT ACLs through `security.*` xattrs) and runs that way on docker-1; the Gitea runner itself is still to be tried.
|
||||
16. NPM: confirm `Block common exploits` does not interfere with uploads; checked when the test proxy host is made in phase 0.
|
||||
17. SET_DATABOOK_AT is writable by anyone in the old build, even when filed: default restrict to Clerical and Q.C.
|
||||
18. CO verifications cannot be withdrawn; ITP line signatures can be undone by the same inspector or an Admin before the block is signed.
|
||||
|
||||
+19
-5
@@ -1,6 +1,6 @@
|
||||
# Packet Station schema
|
||||
|
||||
Status: planned, phases 1 to 3. This is a stub. The full DDL lands here as the migrations are written. See [PLAN.md](PLAN.md), "Schema".
|
||||
Status: phase 1 built (identity and organisation seed). Phases 2 and 3 add the rest. The DDL is in `server/packetstation/migrations/`; this file explains it. See [PLAN.md](PLAN.md), "Schema".
|
||||
|
||||
## Conventions
|
||||
|
||||
@@ -10,13 +10,27 @@ Identity bigints, except users, keyed on the AD objectGUID. Postgres enums for w
|
||||
|
||||
A superuser for bootstrap, the migrate role `ps_migrate` (owns the schema, runs migrations) and the app role `ps_app`. The plan's deployment section also names a worker role `ps_worker`. Phase 0 creates only the first three; whether the worker gets its own role is still to settle.
|
||||
|
||||
## Identity (phase 1)
|
||||
## Identity (phase 1, migration 0002)
|
||||
|
||||
users, user_roles, sessions, devices, user_pins, signin_attempts, signatures.
|
||||
Enums: `role_kind` (admin, clerical, clerical_supervisor, warehouse, warehouse_supervisor, qc, qc_supervisor, pm, shop_worker, shop_lead, shop_supervisor), `session_kind` (password, pin), `sig_factor` (password, pin), `dept_kind` (support, shop).
|
||||
|
||||
## Organisation (phase 2)
|
||||
- `users`: `id uuid` is the AD `objectGUID`, stable across renames. Caches `sam_account` (unique, case-insensitive), `upn`, `display_name`, `emp_no` (text; indexed but not unique, because a duplicate must be storable and flagged), `email`, `welder_stamp`, `active`, `directory_flags` jsonb (disabled, missing or duplicate employee ID), `ad_synced_at`, `last_signin_at`. A user whose AD account is disabled or gone keeps the row, marked inactive.
|
||||
- `user_roles`: (`user_id`, `role`, `dept_code`) rebuilt at every sign-in, at the 15-minute role refresh and by the AD refresh. Office roles carry their department code, admin carries null, shop roles carry the two-digit code. Only the highest shop level per department is stored.
|
||||
- `sessions`: `token_hash` (HMAC-SHA256 of the cookie token with the session key), `kind`, `device_id` for PIN sessions, `expires_at` (absolute), `last_seen_at` (idle), `locked_since`, `revoked_at` and `revoked_reason`, `ip`, `user_agent`, `roles_snapshot` jsonb (`{"roles": [...], "groups": [...]}`), `roles_checked_at`, `stepup_failures`, `hidden_since` (the last hidden-tab report, cleared by the next request).
|
||||
- `devices`: registered shop devices. `name`, `token_hash` of the device cookie, `registered_by`, `last_seen_at`, `revoked_at`, `revoked_by`.
|
||||
- `user_pins`: one row per user with a PIN. Argon2id `hash`, `failed_count` (across sign-in, unlock and signature), `disabled_at`, `set_at`. Never readable by Admin in clear; Admin may delete the row.
|
||||
- `signin_attempts`: append-only. `account` (`lower(login)`, `emp:<emp_no>` or `user:<uuid>`), `ip`, `kind` (password, pin, unlock, signature, pin_set), `ok`, `reason`, `user_id`, `session_id`. Both rate limits count the failed rows here.
|
||||
- `signatures`: append-only. Who (`user_id`, `account` and `display_name` snapshots), the `session_id` and `session_kind`, the `factor`, `action`, `meaning`, `packet_id`, `subject_type`, `subject_id` and a `content_hash` (sha256 of the canonical JSON of what was signed). Later phases reference it from every sign-off row.
|
||||
|
||||
departments (code and slug immutable), stations, user_station_limits.
|
||||
Append-only tables use the `ps_append_only` trigger from 0001 and revoke UPDATE, DELETE and TRUNCATE from `ps_app`.
|
||||
|
||||
## Organisation (phase 1 seed, phase 2 completes)
|
||||
|
||||
- `departments`: `code` (text PK, `CL`, `WH`, `QC`, `PM`, `01` to `14`), `slug` (the AD group short name, unique), `name`, `kind`, `sort`, `active`, `supervisor_group` (false only for PM). A trigger refuses changes to `code` and `slug`. Seeded with the 18 departments from the brief and the old `depts.js`.
|
||||
- `stations`: (`dept_code`, `name`, `sort`, `active`), seeded with the old station lists (Stall 1 to 6, Station 1 to 4, Hydro Bay 1 and 2, Blast Booth, Paint Booth 1 and 2).
|
||||
- `user_station_limits`: phase 2.
|
||||
|
||||
The 50 AD group names are derived from `departments` plus `PS-Admins`: `PS-<slug>` and `PS-<slug>-Supervisor` for support departments, `PS-<code>-<slug>-<Worker|Lead|Supervisor>` for shop departments. `packetstation ad-groups --list` prints them.
|
||||
|
||||
## Projects and units (phase 2)
|
||||
|
||||
|
||||
+16
-3
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user