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 5. The identity controls (sessions, PIN, rate limits, audit, Origin check, derived group map) and the policy controls (server policy on every write, workflow steps as compare-and-set, server-side validation and limits, IT edit reasons, the filed lock, the upload pipeline for drawings and documents) are built and tested. Phase 3 adds the worker controls (read-only VISUAL and M-Files reads, stack guard, advisory locks, redacted errors, audited system writes). No requirement ID closes in phase 3. Phase 4 completes the upload pipeline (a kind per upload with its own types and limit, XLSX and CSV by content, the optional ClamAV scan, the assets volume quota alert) and adds the warehouse rules (the heat gate on release, closed hold and short reasons, receive by custody). Phase 4 closes D5. Phase 5 adds the shop rules (the server as the clock of record, server-assigned mark numbers under the packet row lock, insert-only change-order verifications with a signature, VOID sheets never edited). Every other control names the phase that builds it.
- Part 1: the 20 old layers and where each one lives now.
- Part 2: the new build's controls.
- Part 3: the production requirement IDs and the phase that closes each.
## Part 1: the old layers, moved to the server
The M: drive build had 20 layers of protection, most of them enforced in the browser. Every layer that still applies moves to the server. The rest are dropped or replaced.
| # | Old layer | Now | Phase |
|---|---|---|---|
| 1 | PESCO network and firewall | Kept. The app runs on `docker-1` inside the PESCO network, reached only through Nginx Proxy Manager. It publishes no port. PostgreSQL is on each stack's internal network only. | 0 |
| 2 | Windows folder rights on the M: folder | Dropped. Replaced by database roles: the app role cannot change the schema or update or delete the audit; migrations run as a separate role. Files sit on a volume only the containers see. | 0 |
| 3 | Employee ID + PIN sign-in | Replaced by AD sign-in with the domain password, plus an optional Packet Station PIN checked on the server, for registered shop devices only. | 1 |
| 4 | Department and level screens | A static role-to-screen map in the browser, plus server policy on every request. The browser only decides what to show. | 1, 2 |
| 5 | Per-PC access log | Replaced by the server audit. Each row carries the AD account, session, client IP and user agent. No per-PC browser ids. | 1 |
| 6 | Filing seals and filed-packet lock | HMAC seals computed on the server with a server key. The filed lock is a server policy rule. | 7 |
| 7 | Record integrity check | A worker job (seals, asset hashes, audit hash chain) with an alert. | 9 |
| 8 | Self-hosted libraries | Kept: no CDN. The CSP allows nothing external. | 0 |
| 9 | Output escaping | React escapes output by default. The PDF export escapes on the server. | 1, 7 |
| 11 | PIN sign-off (electronic signature) | Server-verified signature with the PIN or the domain password, stored with who, when, what, factor and a content hash. | 1 |
| 12 | IT edit reasons | Kept. Admin writes need a reason and are marked as IT edits in the packet history and the audit. | 2 |
| 13 | Screen lock | Server-enforced sessions. The browser reports idle and background state; the server records it and refuses an idle or locked session. Unlock is a step-up check on the server. | 1 |
| 14 | Input and upload validation | On the server: field limits on every write, closed reason sets, uploads typed by content per kind (drawing PDF; MTR and document PDF, PNG or JPEG; BOM XLSX or CSV), a size limit per kind (25 MB, BOM 5 MB), sanitised names, an optional ClamAV scan before anything is stored, and a quota alert on the assets volume. Built. | 2, 4 |
| 15 | Second-admin approval | Superseded. Roles come only from AD groups and the group-to-role map is derived, so there is nothing to grant in the app. | 1 |
| 16 | Security alerts and automatic integrity check | Worker jobs and the Notifications tab (email through app-03). | 9 |
| 17 | Controlled-copy stamp on data books | Applied to server-built PDFs and signed with the seal key. | 7 |
| 18 | CSV formula guard | Applied to every server export. | 7 |
| 19 | Update lock and stale-version block | Replaced by a build version endpoint. A screen on an older build shows a banner and reloads. | 0 (endpoint), 10 (banner) |
| 20 | VISUAL helper write guard | Dropped. There is no helper. VisualKit and MFilesKit are read-only by design and connect as read-only accounts. | 3 |
## Part 2: the new build's controls
### Sessions (phase 1)
- 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: the `Origin` header (or the `Referer` origin) must match the request host, and `X-Requested-With: PacketStation` must be present. Otherwise 403.
- 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.
- Optional, per user, set by the user in Account settings after a password sign-in. Setting or changing it needs the domain password in the same request.
- 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.
- Checked before any bind to AD, so the app can never lock a domain account out.
- 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.
- One policy in Python on the server (`server/packetstation/policy/`), pure functions with no I/O. Every write goes: session, policy, transition, validation, version check, packet log row, audit row, NOTIFY, in one transaction with the packet row locked. Nothing in the browser decides.
- Every packet read returns `can: [...]`, `blocked: {...}` and `holder`, so the browser enables controls from the server's answer. The server re-checks on the write.
- A workflow step taken twice is refused with 409 and the current state (compare-and-set on the expected status and department). Child rows carry a row version checked with If-Match. Weld, heat tag, page and change order numbers are assigned by the server under the lock.
- Preconditions (heat map answered, drawing attached, BOM pulled or short, heats recorded on pulled lines, checklist complete, welds recorded, Q.C. items complete, closed in VISUAL before filing) run on the server and are never bypassed, Admin included.
- Admin edits outside custody need an IT reason of at least 8 characters, recorded in the packet log and the audit row (`it_reason`). An Admin cannot write a filed packet except through the reopen action, which needs a reason and a signature and breaks the seal.
- Signatures (PIN or domain password, verified in the same transaction) on submit, send to Q.C., Q.C. accept and reject, route, file, reopen, change order issue, approve, reject and verify, the revision approval and hand drawing uploads.
- Input: every string is cleaned (control and invisible characters removed, NFC, trimmed) and cut to its field limit; heat numbers, stamps, project numbers and lots are checked against the old patterns; JSON bodies over 200 KB are refused.
- Uploads: typed by content (PDF, PNG, JPEG), 25 MB limit read in chunks, sha256, stored by id under the assets volume, served only with a session and with `Content-Disposition` and `X-Content-Type-Options: nosniff`. Phase 4 completes it (see "Uploads and storage").
- The filed lock: a filed packet accepts only the data book date and the Admin reopen. Filing writes an HMAC-SHA256 seal with the server seal key; a reopen keeps the old seal with who broke it and why.
- Every upload names a kind (`purpose`): drawing, mtr, document or bom. No purpose means document. An unknown kind is 422.
- The type is read from the content, never from the file name or the browser's type. A drawing is a PDF. An MTR or a document is a PDF, PNG or JPEG. A BOM is an XLSX or a CSV. A wrong type for the kind is 415 `unsupported_type` with a message that names the allowed types.
- An XLSX must be a zip that lists `[Content_Types].xml` and `xl/workbook.xml`; a plain zip is refused. A CSV has no magic, so it is accepted only as a BOM, and only when its first 64 KB decode as UTF-8 with no NUL and no control character other than tab, CR and LF.
- Limits per kind: 25 MB (`upload_max_bytes`) for drawing, mtr and document, 5 MB (`upload_max_bytes_bom`) for a BOM. The Content-Length is checked first, then the bytes as they are read. Over the limit is 413 `too_large`.
- XLSX and CSV are always served as an attachment, never inline. Every blob keeps `Content-Disposition` and `nosniff`.
- Optional ClamAV (requirement D5). When `CLAMAV_HOST` is set, every upload and every file the worker stores (drawings from M-Files) is streamed to clamd with `zINSTREAM` after it is read in full and before it is deduped or placed. A hit is 422 `infected`, nothing is stored, and an `asset.rejected` audit row keeps the signature, size and type. A scanner that cannot be reached refuses the upload with 503 `scan_unavailable` (fail closed). With `CLAMAV_HOST` empty there is no scan.
- Quota alert. After every stored file and in the housekeeping job, the assets volume usage is checked against `ASSETS_QUOTA_WARN_PCT` (85 by default). Over it, one open `notification_events` row of kind `assets_quota` is written and audited as `storage.quota_warning`. Back under, the row is resolved. A failed check never fails an upload. Admin sees usage, the alert and the scanner on the Storage tab and can acknowledge an alert (`alert.acknowledged`). Phase 9 sends the email.
### Warehouse rules (phase 4)
- Release to the supervisor is refused on the server until every line is pulled or short and every pulled line that issued pieces has a heat (N/A and sub-assembly lines excepted). Heat numbers are checked against the old pattern.
- Hold reasons and short reasons are closed sets on the server.
- Short receipts go to the warehouse in any status but filed, or to the holder of the packet through the BOM edit rule. Nobody else.
- An MTR on a line must be a PDF, PNG or JPEG asset.
- The server is the clock of record. The station clock is kept in `packet_stages` with the database time. Only the server writes the clock columns. No request field sets the elapsed time.
- Weld and heat tag numbers (`W<n>`, `H<n>`) are assigned by the server from the packet counters, under the packet row lock. Two people marking at once never get the same number.
- ASSIGN checks on the server that the welder belongs to the department and that a limited worker's station is one of theirs.
- A change-order verification needs a signature and is insert-only. A second one for the same department is refused. It cannot be withdrawn.
- VOID sheets are never edited. A voided page is kept, and new marks or moves on it are refused (422 `void_page`).
- A borrowed heat map is read-only. Nothing on the sibling packet can be written through the borrowing packet.
- Presence over SSE carries display names, never other personal data. Each entry is the user id, display name, screen and packet id.
- The worker publishes no port and joins no proxy network. It reaches out to VISUAL and M-Files only; nothing reaches in. Admin and Clerical reach it only through rows in PostgreSQL (`job_requests`, the `feed` settings row) and NOTIFY.
- It reads VISUAL through VisualKit as `visualkit_ro` and M-Files through MFilesKit as `mfileskitRO`. Both libraries are read-only in their own code. Nothing in Packet Station writes to either.
- Stack guard at start and again before every feed run: the test stack refuses a production VISUAL target, production refuses an implicit one, and production refuses a fixture source.
- One run per job at a time, by a PostgreSQL advisory lock per job name. Feed writes run in one transaction with a lock timeout and skip rows a person has locked.
- Job errors are redacted (connection strings, `password=`, `Authorization:` and the VISUAL and M-Files passwords themselves) and cut to 20 entries of 500 characters before they are stored or shown.
- Every feed write goes through the server policy as the system principal, which can take only the `vis_*` actions. Each one writes a packet log row and an audit row with role `system`. Each run writes a `feed.run` audit row with its counts, and each drawing fetch a `drawing.fetched` row with the sha256. Feed settings and resource map changes are audited with before and after.
- Downloaded drawings go through the same checks as uploads: PDF magic, size limit, streamed sha256, stored by id.
- 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.
- Phase 4 audit actions: `asset.rejected` (an upload the virus scan refused, with `reason`, `signature`, `size` and `mime`), `storage.quota_warning` (the assets volume went over the warning threshold, with the usage) and `alert.acknowledged` (an Admin acknowledged an open alert). Every warehouse write is a `packet.<action>` row with `detail.item`, the warehouse stage before and after, and `detail.qty` on short writes.
- 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.
| D5 | Closed, phase 4 (optional ClamAV: a `clamav` compose profile; when `CLAMAV_HOST` is set every upload and every stored drawing is scanned before it is kept, a hit is refused and audited, and an unreachable scanner refuses uploads; off when the host is not set) |
| O1, O2 | Closed, phase 9 (pg_dump timer plus PBS; one rehearsed restore); quarterly restore IT's |
| O3, O4 | Closed, phase 10 |
| O5 | Superseded (no migration; M: retired read-only) |
The brief's wording for the identity IDs, which the table above follows:
- **I4 and I7** (PIN lockout, PIN signatures verified on the server) are closed by this build.
- **I8** is closed for the app's part. The kiosk device lock policy in I8 is IT's, via Intune or GPO.
- **I6** (joiner, mover, leaver) is largely carried by the AD groups plus the nightly reconcile. The quarterly access review is still IT's.
- **I3** is closed for "PIN checked on the server" and open for "Intune-managed, device certificate". Registered devices use a device cookie for now.
- **I1** is partly closed by AD sign-in. There is no SSO yet.
- **I5** is superseded. No in-app admin grant exists, so second-admin approval has nothing to approve. Separate IT admin accounts with MFA remain IT's.
Out of scope for this build, with hooks left: writing to VISUAL or M-Files (never), Entra ID sign-in and MFA (I1, I2), envelope encryption with Key Vault (D1, D2), true `.xlsx` exports, scheduled report emails.