Compare commits

..
4 Commits
Author SHA1 Message Date
spencerm 288804d5f5 Merge pull request 'Phase 9: Admin completion, reconcile, notifications, integrity, backups' (#49) from phase-9 into main
ci / frontend (push) Successful in 50s
ci / server (push) Successful in 11m18s
ci / image (push) Successful in 51s
ci / e2e (push) Successful in 4m13s
Reviewed-on: #49
2026-10-27 20:54:46 +00:00
spencermandClaude Opus 5.5 8528e38448 ci: re-run after two e2e jobs collided on the CI stack name
ci / frontend (pull_request) Successful in 51s
ci / server (pull_request) Successful in 11m12s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 4m7s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 14:18:52 -06:00
spencermandClaude Opus 5.5 7a43236630 deps: pypdf 6.21.1 to 6.21.2
ci / frontend (pull_request) Successful in 52s
ci / server (pull_request) Successful in 11m16s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Failing after 34s
The CI dependency scan flags GHSA-6c7f-9265-g8fw, GHSA-jr96-gvgh-j3r4
and GHSA-gmh7-3g9r-mjh5 against 6.21.1, all fixed in 6.21.2.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 14:05:18 -06:00
spencermandClaude Opus 5.5 a82e056b6a Phase 9: Admin completion, reconcile, notifications, integrity, backups
ci / frontend (pull_request) Successful in 1m11s
ci / server (pull_request) Successful in 12m2s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 6m1s
Admin: Departments (name and stations; add shows the three groups IT must
create), Profiles (welder stamp, station limits, active) with a CSV import
that previews and applies only when clean, Checklists (weld and plain),
Security (session, lockout and rate-limit settings with bounds, alerts for
the last 24 hours, the integrity result), Access log with filters and a
guarded CSV, Who is in, Stamp check, Notifications, and the employeeID
flags. Every Admin change is audited.

Worker: ad-reconcile (nightly and on "refresh from AD now"), session-refresh
(15 minutes), integrity (seals, asset hashes, missing and orphan files, the
audit chain), backup-check, notify-send and notify-digest. Notifications
route per event type to AD groups, users, mailboxes or the department
Supervisor group, immediately or in a daily digest, over STARTTLS verified
against the PESCO CA, with dry run on the test stack.

Backups: scripts/backup/ps-backup.sh and ps-restore.sh with a systemd timer
at 02:00 America/Denver. Restore rehearsed on the test stack 2026-10-21.
CI: pip-audit and npm audit. pypdf 6.21.1 and certifi 2026.10.19.

Migration 0010. Closes the app part of I6, M3, O1, O2 and S3.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 18:27:09 -06:00
116 changed files with 15125 additions and 297 deletions
+16 -1
View File
@@ -68,6 +68,18 @@ jobs:
pip install -r "$RUNNER_TEMP/dev-requirements.txt"
pip check
# Requirement S3. Audits every pin in server/requirements.txt (transitive included) against
# the PyPI advisory database. visualkit and mfileskit are not on PyPI: they come from the Gitea
# index (requirements-pesco.txt) and are not audited here. pip-audit has no severity filter, so
# any known vulnerability fails the job, which covers high and critical. pip-audit runs from its
# own venv so its dependencies never touch the pinned set above.
- name: Dependency scan (pip-audit)
run: |
set -euo pipefail
python -m venv "$RUNNER_TEMP/audit"
"$RUNNER_TEMP/audit/bin/pip" install --quiet pip-audit==2.9.0
"$RUNNER_TEMP/audit/bin/pip-audit" -r server/requirements.txt --no-deps --disable-pip --progress-spinner off
- name: Lint (ruff)
run: |
ruff check .
@@ -95,6 +107,9 @@ jobs:
- name: Install
run: npm ci --no-audit --no-fund
# Requirement S3. Fails on a high or critical advisory in any dependency, dev tools included.
- name: Dependency scan (npm audit)
run: npm audit --audit-level=high
- name: Lint
run: npm run lint
- name: Types (tsc)
@@ -159,7 +174,7 @@ jobs:
- name: Generate feed fixtures
run: |
set -euo pipefail
python -m pip install pypdf==6.21.0
python -m pip install pypdf==6.21.2
python -m tests.fixtures.generator --out tests/fixtures/generated
# The Playwright container writes state; the worker reads as uid 10001.
chmod -R a+rwX tests/fixtures/generated
+26
View File
@@ -19,12 +19,38 @@ All notable changes to this project are recorded here. The format follows [Keep
### Changed
- Security fixes in the pinned dependencies, found by the new CI scan: `pypdf` 6.21.0 to 6.21.2 (GHSA-5hx5-vpf9-5wcv, a crafted PDF could reach the image parsers; 6.21.2 for GHSA-6c7f-9265-g8fw, GHSA-jr96-gvgh-j3r4 and GHSA-gmh7-3g9r-mjh5) and `certifi` 2026.7.22 to 2026.10.19 (PYSEC-2026-4320, a distrusted root removed). The image must be rebuilt for both to reach the stacks.
- `POST /api/admin/ad/refresh` no longer runs the AD refresh in the request. It asks the worker for an `ad-reconcile` run and answers 202 `{requested, pending, job}`; `GET /api/admin/ad/reconcile` gives the latest report.
- The storage quota alert is now mailed through the Notifications rules (to PS-Admins by default), with a "Cleared" message when usage drops back.
- The session, lock and sign-in limit settings can be overridden on the Admin Security tab; the environment values are the defaults.
- `deploy/env/prod.env.example` sets `BACKUP_DIR=/var/backups/packetstation/prod`, as the brief and the docs say.
- Gitea issue 44: the packet tabs put Materials before Heat map.
- Gitea issue 45: Admin and Clerical sessions wait 60 idle minutes before the screen lock and before signing out (`SESSION_DESK_MINUTES`, default 60; it never shortens `SESSION_LOCK_MINUTES` or `SESSION_IDLE_MINUTES`). Everyone else keeps 3 and 15. `GET /api/auth/me` answers the session's `lock_minutes` and the browser's idle lock follows it. The hidden-tab lock after a minute is unchanged.
- VISUAL is the source of truth for the drawing rev. When M-Files' Effective rev differs from the rev VISUAL calls but M-Files still holds that rev as Retired (or Effective), the drawing job attaches it to the draft with no flag and no Clerical approval, and the packet page says the drawing is retired in M-Files. Any other M-Files status, or no document at that rev, still flags `rev_mismatch`; Packet Station only ever attaches Effective or Retired drawings. This replaces the "Use rev V as VISUAL calls it" choice from issue 41. Needs MFilesKit 0.1.3 (`DrawingLookup.revision`) and migration 0009.
### Added
- Phase 9: Admin completion, AD reconcile, integrity, notifications, backups. The Admin page gains eight tabs: Departments, Profiles, Checklists, Security, Access log, Who is in, Stamp check and Notifications. The tab list wraps into rows on a narrow screen.
- Departments tab: every department with its AD groups (and whether each is in the OU) and its stations. Edit a department's name, turn it on or off, rename, reorder, add or hide stations. Stations are never deleted, since station limits and packets point at them. The code and the short name cannot change. "New department" adds a shop department with its stations and shows the three AD groups IT must create (`PS-<code>-<short name>-Worker`, `-Lead`, `-Supervisor`) before it is saved. Only shop departments can be added; the support departments are fixed.
- Profiles tab: a person's welder stamp (1 to 12 letters, numbers or dashes, unique), whether their profile is active, and their station limits per department. An inactive profile drops out of the welder picker and the worker filter; it changes nothing the person may do. A CSV import by employee ID (columns `employee_id`, `welder_stamp`, `active`, `stations`; an empty cell leaves a value, `-` clears it; the `stations` cell is the person's whole list) shows a preview with every change and every error, and applies only when the preview has no errors, in one go. The Directory flags panel lists active users with no employee ID and employee IDs shared by more than one account.
- Checklists tab: edit the weld and plain station checklists (group, key, text, order; at most 40 items; the placeholders `{drawing}`, `{rev}` and `{wps}`). A saved key cannot be renamed. A removed item drops out of new checklists only; open stages keep their answers. "Use the default list" restores the old build's list.
- Security tab: the session, lock and sign-in limit settings with their bounds (the per-account failure limit stays at 15 or under, below AD's 20), applied without a restart. Security alerts for the last 24, 72 or 168 hours: account cooldowns, repeated wrong PINs, wrong PINs or passwords at unlock or signature, denied actions, sign-in bursts, Admin changes, IT edits and integrity findings, with a filter per kind. The last integrity run and its findings.
- Access log tab: every sign-in attempt and every sign-in, session, PIN, device and signature audit row for everyone, filtered by time, person or account, kind, result and address (or address range), 200 rows at a time, and downloaded as CSV (at most 50,000 rows, with the formula guard, logged in the print log and the audit).
- Who is in tab: the live sessions, with the kind, device, address, when seen, locked or active, and the screen and packet the person has open. Refreshes every 30 seconds.
- Stamp check tab: paste a stamp line from a printed or exported copy, or just its 16-character code, or upload the PDF, and the server says whether it is genuine, does not verify, or was never printed, with who printed what and when.
- Notifications tab: one rule per event type (13 of them), each on or off, sent at once or in a daily digest at a set time, to AD groups (found with a directory search), single users, mailboxes, or the Supervisor group of the event's department. People can be left out of a rule, except on the three security event types. A test send goes to the rule's recipients or to one address, even on the test stack. The sent log lists every message with its recipients and the result. Every rule starts on, sent at once, to PS-Admins.
- Account settings: a Notifications section that shows each event that reaches the person and why (a group, the Supervisor group of a department, a mailbox, by name), with an opt-out per event in a password session. Security events cannot be turned off.
- Email through app-03 with STARTTLS and the PESCO CA, never in plain text. Subjects start `[Packet Station]` on production and `[Packet Station TEST]` elsewhere. Recipients are in the envelope only, so nobody sees the list. The test stack records messages without sending them (`NOTIFY_DRY_RUN`), except a test send. Standing problems (feed failed or degraded, drawing lookups failing, change order waiting, group missing, backup stale, integrity failure, storage quota) send once when they start and a "Cleared" message when they end.
- Events raised: account cooldowns, a PIN disabled, a session ended after wrong PINs, sign-in bursts (one address over its limit, or 20 failed sign-ins across accounts in 5 minutes), integrity failures, failed and degraded feed runs, drawing lookups failing for more than 24 hours, change orders waiting for review more than 3 days, Q.C. and ITP rejects (to the department), the AD reconcile report, groups missing from the OU, a stale backup, a new version deployed and the storage quota (Gitea issue 20).
- The AD reconcile every night at 01:30, and on "Refresh from AD now", which now asks the worker for a run and shows "Refresh requested" and the latest report: groups missing from or extra in the OU, users seen, added, updated and deactivated, sessions ended, and employee ID problems. A user disabled or gone in AD is marked inactive and signed out; a user left with no group is signed out. The reconcile refuses to change anything when no mapped group is in the OU, or when every group is empty while active users exist. A run that fails or refuses sends one "AD reconcile did not run" notice until a run succeeds.
- The role refresh every 15 minutes (a Security setting): every signed-in person's groups and account state are read again from AD, their roles and sessions updated, and anyone disabled, gone or left with no role signed out. AD down changes nothing.
- The integrity job every night at 04:00: every seal of a filed packet checked with the seal key, asset files rehashed (uploads of the last 7 days every night, all of them on Sundays), asset rows with no file, files with no asset row (reported, never deleted) and the audit hash chain. Findings are kept and raise an alert; a clean run clears it.
- Nightly database backups on docker-1: `scripts/backup/ps-backup.sh` with a systemd timer at 02:00 Denver time, an hour before the Proxmox backup. Each stack's database is dumped with `pg_dump -Fc` plus the roles without passwords, checked with `pg_restore --list`, 7 kept, with `last-success` and `last-failure` markers. The worker's backup-check job at 07:15 raises an alert when the newest dump is missing, empty, older than 26 hours or followed by a failure. `scripts/backup/ps-restore.sh` restores a dump into a fresh database, copies assets back without overwriting, checks every asset hash and prints row counts. The restore was rehearsed on the test stack on 2026-10-21: every table's row count and all 189 asset hashes matched.
- Denied actions are audited (`policy.denied`, method, path and code only), as are account cooldowns (`auth.lockout`) and sign-in bursts (`auth.signin_burst`). Every Admin change is audited with before and after.
- CI dependency scanning: `pip-audit` over `server/requirements.txt` fails on any known vulnerability; `npm audit --audit-level=high` fails on a high or critical one.
- Migration 0010: notification rules, recipients, exclusions, outbox and the sent log, the event columns for delivery, `integrity_findings`, `users.profile_active`, `assets.hash_checked_at` and `hash_ok`, `departments.created_at` and `created_by`.
- Tests: every new Admin endpoint as every role (Admin password session 200, Admin PIN session 403, every other role 403, anonymous 401), departments, profiles and the CSV import, checklists, security settings and their effect on the limits, alerts, the access log and its CSV, presence and the stamp check; event raising, recovery, routing, the SMTP path against a local relay with STARTTLS, digests and directory failures; the reconcile, the role refresh, the integrity job (a tampered seal, a changed, missing and orphan file, an edited audit row) and the backup check; the backup script with a fake `docker`; frontend tests for every new tab and the Account section; Playwright scenarios for Departments, Notifications, Security, the access log, the stamp check and Who is in.
- docs/SCHEMA.md, POLICY.md, SECURITY.md (I6 app part, M3, O1, O2 and S3 closed), DEPLOY.md and FEED.md updated; PLAN.md open questions 43 to 50.
- Phase 8: Project Management, the find bar, presence and motion. The Project Management screen (`/pm`, view only) with the tabs Overview (the headline numbers, a plain-words summary, the pipeline by step with the slowest step in pink, the units late or at risk), Projects & units (every project with its units, due date, days left, packets by step, % done and health: On track, At risk, Late, Complete, No packets yet; a project is as healthy as its worst unit; tap a project for its units and a unit for its jobs), Bottlenecks & aging (time in the current step, every open packet longest waiting first with over 3 days flagged, average time per step for completed moves), Hours vs target (station timer hours and VISUAL ticket hours side by side against the work-order target by department, worker and project, plus every packet over target), Quality & issues (Q.C. rejects, open shorts, open change orders, warehouse holds, sent back) and Find jobs. The period picker (30 days, 90 days, 12 months, all) sets the window for history figures. Every table has Excel (CSV); "Excel (CSV) — all" saves every report; "PDF report" builds the PESCO production status report (cover with the headline numbers, summary and pipeline, projects, flow, labor, issues) on the server with the controlled-copy stamp. Every export is logged as "report exported" in the audit and the print log.
- The find bar on every home screen: search over project number or name, lot, operation, drawing and rev, WPS, customer, PO, old project numbers, work orders, heat numbers, part IDs, welder name or stamp, change order and ECN numbers, with several words narrowing and Esc clearing; the project then unit picker; Filters with Department, Status (including At a station and Everything not filed), Station, Worker and, for Clerical and Project Management, the flag kind; the quick flags Change order open, Shorts open, Project overdue, Due within 7 days and Mine (what that screen is responsible for; hidden on the station and Project Management screens); Board or List with the list sortable by last updated, project / lot, due date, status or department. Filters and the view are saved per browser (`ps.flt`); the search text is not. Tapping a number tile shows only those jobs; tapping again clears. The numbers follow the project pick but not the other filters.
+13 -7
View File
@@ -6,9 +6,9 @@ This repo is the rebuild of Packet Station as a server application: Docker Compo
## Status
**Rebuild in progress. Phase 8 done: the Project Management screen with its reports and exports, the find bar on every home screen with server-side filters and the list view, presence in packets, the motion switch, and the data book checked page by page against the old build.**
**Rebuild in progress. Phase 9 done: the Admin page completed (departments, profiles with the CSV import, checklists, security settings and alerts, the access log, who is in, the stamp check), the nightly AD reconcile and the 15-minute role refresh, the integrity job, notifications by email with rules per event, nightly database backups with a rehearsed restore, and dependency scanning in CI.**
People can sign in, set up projects and units, make hand drafts, attach a drawing, and send a packet to the warehouse, a department or Q.C. The worker reads released work orders from VISUAL on a schedule, makes and updates drafts, opens change orders on packets already in the shop, looks drawings up in M-Files and attaches them at the rev VISUAL calls. Clerical sees it all on the From VISUAL panel and in the Flagged and Changes to approve columns; Admin sets the feed up on the Feed tab. Warehouse works its task board (waiting, being pulled, on hold, pulled, released), pulls parts line by line with heat numbers, shorts, receipts and MTRs, and releases to the supervisor once every line is pulled or short and every pulled line has a heat. A supervisor assigns the packet to a station and a welder, the welder starts the clock, marks welds on the drawing, places the heat table, finishes the checklist and submits with a PIN signature, and the supervisor sends it to Q.C. or routes it to another department with the marks carried. Department supervisors verify change orders with a signature. Q.C. inspects the packet on its own tab (the check items, a visual result per weld, NDE, hardness, notes), accepts it to the data book, to Shipping or on to another department with a PIN signature, or rejects it back to the supervisor with a reason or an NCR number. Q.C. keeps project folders of documents per project and unit (ITP, WPS / PQR, procedures, certificates) where a new revision supersedes the old one and nothing is deleted, adds reports to a packet for the data book and takes them out with a reason, and works each unit's digital ITP from the Q.C. template through line initials, N/A, rejects with NCR numbers, section block signatures and a sign-off. Open hold and witness points show as a warning on the unit's packets. Clerical confirms the job closed in VISUAL (the feed's closed state and any open work orders shown beside it) and files the packet with a signature. Filing seals the packet with an HMAC and builds the manufacturing data book on the server, stored on the seal; filing past open work orders needs a second confirmation. IT can reopen a filed packet with a reason and a signature, which keeps the broken seal and its data book. Anyone who can open a packet can view or download the data book, the job traveler with the work order QR codes, the ITP PDF and the traceability CSV, and print a stamped copy of the traveler, the drawings with the heat or weld map, the drawings without markup, the VOID sheets, the data book or any document. Every copy carries a stamp signed with the seal key, and every print and export is logged. The ITP sign-off files the signed ITP PDF. Every packet write goes through the server policy. The old build on `M:\Production\Packet Station` stays the live system until go-live. The new system starts empty; nothing is migrated from M:.
People can sign in, set up projects and units, make hand drafts, attach a drawing, and send a packet to the warehouse, a department or Q.C. The worker reads released work orders from VISUAL on a schedule, makes and updates drafts, opens change orders on packets already in the shop, looks drawings up in M-Files and attaches them at the rev VISUAL calls. Clerical sees it all on the From VISUAL panel and in the Flagged and Changes to approve columns; Admin sets the feed up on the Feed tab. Warehouse works its task board (waiting, being pulled, on hold, pulled, released), pulls parts line by line with heat numbers, shorts, receipts and MTRs, and releases to the supervisor once every line is pulled or short and every pulled line has a heat. A supervisor assigns the packet to a station and a welder, the welder starts the clock, marks welds on the drawing, places the heat table, finishes the checklist and submits with a PIN signature, and the supervisor sends it to Q.C. or routes it to another department with the marks carried. Department supervisors verify change orders with a signature. Q.C. inspects the packet on its own tab (the check items, a visual result per weld, NDE, hardness, notes), accepts it to the data book, to Shipping or on to another department with a PIN signature, or rejects it back to the supervisor with a reason or an NCR number. Q.C. keeps project folders of documents per project and unit (ITP, WPS / PQR, procedures, certificates) where a new revision supersedes the old one and nothing is deleted, adds reports to a packet for the data book and takes them out with a reason, and works each unit's digital ITP from the Q.C. template through line initials, N/A, rejects with NCR numbers, section block signatures and a sign-off. Open hold and witness points show as a warning on the unit's packets. Clerical confirms the job closed in VISUAL (the feed's closed state and any open work orders shown beside it) and files the packet with a signature. Filing seals the packet with an HMAC and builds the manufacturing data book on the server, stored on the seal; filing past open work orders needs a second confirmation. IT can reopen a filed packet with a reason and a signature, which keeps the broken seal and its data book. Anyone who can open a packet can view or download the data book, the job traveler with the work order QR codes, the ITP PDF and the traceability CSV, and print a stamped copy of the traveler, the drawings with the heat or weld map, the drawings without markup, the VOID sheets, the data book or any document. Every copy carries a stamp signed with the seal key, and every print and export is logged. The ITP sign-off files the signed ITP PDF. An Admin manages departments and stations, welder stamps and station limits (one at a time or from a CSV), the checklists, the session and lockout settings, and who gets which email. The worker reconciles users with AD every night, re-reads each signed-in person's groups every 15 minutes, checks seals, files and the audit chain every night, and checks that the nightly backup ran. Problems and events reach people by email through app-03. Every packet write goes through the server policy. The old build on `M:\Production\Packet Station` stays the live system until go-live. The new system starts empty; nothing is migrated from M:.
| Area | Exists yet? |
|---|---|
@@ -22,7 +22,7 @@ People can sign in, set up projects and units, make hand drafts, attach a drawin
| Q.C. screen, inspection, Q.C. folders, packet documents, digital ITP, hold-point warnings | Yes (phase 6, verified in the CI stack on docker-1) |
| Close and file, HMAC seal with the data book, reopen, data book, traveler and ITP PDFs, traceability CSV, prints and the print log | Yes (phase 7) |
| Project Management screen (six tabs, periods, health, CSV and PDF reports), the find bar completed (search, filters, quick flags, Mine, Board and List, saved per browser), presence, motion, iPad layout | Yes (phase 8, verified in the CI stack on docker-1) |
| Admin completion, notifications, backups | No (phase 9) |
| Admin completion (departments, profiles, checklists, Security, access log, who is in, stamp check, Notifications), AD reconcile and role refresh, integrity job, email notifications, backups and restore, CI dependency scan | Yes (phase 9) |
| Regression bench and go-live | No (phase 10) |
## Phases
@@ -82,7 +82,7 @@ docker compose -p packetstation-test --env-file ~/packetstation/test/stack.env \
Phase 2 adds `/api/projects`, `/api/units`, `/api/packets` (header, transitions, work orders, pages, documents, BOM, shorts, welds, heat tags, heat table, stage, checklist, clock, warehouse stage, Q.C., change orders, log, seal), `/api/assets` and `/_blob/{id}`. Every packet read carries `can`, `blocked` and `holder` from the policy. See [docs/POLICY.md](docs/POLICY.md) and [docs/SCHEMA.md](docs/SCHEMA.md).
The `packetstation` command has `doctor`, `migrate`, `app`, `worker`, `job run <name>` (one job in the foreground), `fixtures use N` (switch the fixture state, never on prod), `ad-groups --list` (the 50 AD group names the app expects) and `ad-refresh` (create or refresh the users from the groups' members).
The `packetstation` command has `doctor`, `migrate`, `app`, `worker`, `job run <name>` (one job in the foreground), `fixtures use N` (switch the fixture state, never on prod), `ad-groups --list` (the 50 AD group names the app expects) and `ad-refresh` (the AD reconcile once, in the foreground, with the same refusal guard as the nightly job).
Phase 3 adds the worker (`packetstation worker`: APScheduler, the `visual-feed`, `drawing-lookup` and `housekeeping` jobs, settings re-read on every fire, run now through `job_requests`), the VISUAL and drawing cache tables (migration 0004), `/api/feed/*`, `/api/jobs/*`, `/api/admin/feed/*`, rev approval and hand uploads under `/api/packets/{id}`, and the synthetic fixture generator (`python -m tests.fixtures.generator --out DIR`) that CI feeds the worker with. See [docs/FEED.md](docs/FEED.md).
@@ -92,6 +92,10 @@ Phase 7 adds the close and file panel, `confirm_open_wos` on the file transition
Phase 8 adds the Project Management screen (`/pm`) and its API under `/api/pm` (`overview`, `projects` with `expand=`, `aging`, `hours`, `quality`, `find`, `export.csv?table=<key>|jobs|all`, `report.pdf`), read by any signed-in role and writing nothing; every export is logged as `report exported` in the audit and the print log. The find bar params (`q`, `status`, `dept`, `station_id`, `welder_id`, `flags`, `flag`, `sort`, `mine_screen`, `mine_station_id`, `facets`) are read by `GET /api/packets` and by the shop, stall, warehouse and Q.C. boards, so the server applies every filter; the KPI numbers follow the project pick only. Presence over `/api/events` now carries the role and drops locked sessions (session events `locked` and `unlocked`). No new table or migration. See [docs/POLICY.md](docs/POLICY.md) "Phase 8: Project Management, find bar, presence".
Phase 9 adds, under `/api/admin`: `departments` (list, `PATCH /departments/{code}`, `POST /departments`), `PATCH /users/{id}/profile`, `POST /users/import` (CSV preview and apply), `checklists`, `security/settings`, `security/alerts`, `security/integrity`, `access-log` and `access-log.csv`, `presence`, `stamp-verify` and `stamp-verify/pdf`, `directory-flags`, `ad/reconcile`, and `notifications` (rules, group search, test send, sent log). `POST /api/admin/ad/refresh` now asks the worker for an `ad-reconcile` run and answers 202. Any signed-in person reads `GET /api/account/notifications`, and opts out of an event type with `PUT /api/account/notifications/{event_type}` (password session; security events cannot be turned off). The Admin page gains the tabs Departments, Profiles, Checklists, Security, Access log, Who is in, Stamp check and Notifications, and Account settings gains a Notifications section. The worker gains six jobs: `ad-reconcile` (01:30), `session-refresh` (every 15 minutes), `integrity` (04:00), `backup-check` (07:15), `notify-send` (every minute) and `notify-digest` (every 5 minutes). Migration 0010 adds the notification tables, the integrity findings and the profile switch. See [docs/POLICY.md](docs/POLICY.md) "Phase 9: Admin, notifications, reconcile" and [docs/FEED.md](docs/FEED.md) "Phase 9 jobs".
Backups run on the host, outside the app: `scripts/backup/ps-backup.sh` (nightly `pg_dump` of each stack from a systemd timer at 02:00 Denver time, 7 kept, with markers the worker checks) and `scripts/backup/ps-restore.sh` (restore into a fresh database, copy assets back, check hashes, compare row counts). [scripts/backup/README.md](scripts/backup/README.md) and [docs/DEPLOY.md](docs/DEPLOY.md) "Backup" have the install steps and the restore rehearsal of 2026-10-21.
CI stack with the Samba domain controller and the Playwright scenario, on a Docker host:
```bash
@@ -101,6 +105,8 @@ docker compose -p packetstation-ci --env-file deploy/ci/ci.env -f deploy/compose
docker compose -p packetstation-ci --env-file deploy/ci/ci.env -f deploy/compose.yaml -f deploy/compose.ci.yaml down -v
```
CI scans the dependencies on every run: `pip-audit` over `server/requirements.txt` fails on any known vulnerability, and `npm audit --audit-level=high` fails on a high or critical one.
Server tests that need PostgreSQL run when `TEST_PG_SUPERUSER_URL` is set; the ones that need the domain controller run when the `TEST_LDAP_*` variables and `TEST_DC_USERS_FILE` are set. See [deploy/samba/README.md](deploy/samba/README.md).
## Docs
@@ -108,10 +114,10 @@ Server tests that need PostgreSQL run when `TEST_PG_SUPERUSER_URL` is set; the o
- [docs/BRIEF.md](docs/BRIEF.md): the brief this rebuild follows.
- [docs/PLAN.md](docs/PLAN.md): the approved phased plan and open questions.
- [docs/SECURITY.md](docs/SECURITY.md): old layers and where they live now, the new controls, the requirement ID table.
- [docs/DEPLOY.md](docs/DEPLOY.md): host, stacks, secrets, NPM proxy host, doctor, backups.
- [docs/FEED.md](docs/FEED.md): the worker, the VISUAL feed, the packet rules, the fingerprint, the drawing decision table, the flags and the feed settings.
- [docs/DEPLOY.md](docs/DEPLOY.md): host, stacks, secrets, NPM proxy host, doctor, notifications, backups and the restore rehearsal.
- [docs/FEED.md](docs/FEED.md): the worker and its jobs, the VISUAL feed, the packet rules, the fingerprint, the drawing decision table, the flags and the feed settings.
- [docs/POLICY.md](docs/POLICY.md): who may do what, the preconditions, the order of checks, concurrency, and the tightenings against the old build.
- [docs/SCHEMA.md](docs/SCHEMA.md): the database schema (phases 1 to 7).
- [docs/SCHEMA.md](docs/SCHEMA.md): the database schema (migrations 0001 to 0010).
- [docs/GO-LIVE.md](docs/GO-LIVE.md): go-live procedure (phase 10).
- [docs/PAPER-FALLBACK.md](docs/PAPER-FALLBACK.md): what the shop does when the server is down (phase 10).
- [CHANGELOG.md](CHANGELOG.md): what changed.
+1 -1
View File
@@ -19,7 +19,7 @@ SECRETS_DIR=/etc/packetstation/prod/secrets
# Host PEM file with the PESCO CA chain. Mounted at /etc/ssl/pesco/ca.pem.
CA_BUNDLE_FILE=/etc/packetstation/ca/pesco-ca.pem
# Host backup folder, mounted read-only in the worker. Compose sets BACKUP_DIR=/backups inside.
BACKUP_DIR=/var/backups/packetstation/packetstation
BACKUP_DIR=/var/backups/packetstation/prod
# ---- Stack and site ----
# prod, test, ci or dev. Guards against crossing stacks.
+162 -10
View File
@@ -32,7 +32,7 @@ Both stacks run from the same image as separate compose projects.
- `postgres`: PostgreSQL 16, named volume, `backend` network only. Its init creates the database roles from secrets.
- `migrate`: one-shot, runs `packetstation migrate` as the migrate role.
- `app`: `packetstation app` (uvicorn). `backend` network plus egress plus `npm_default` through the proxy overlay. Assets volume, CA bundle bind, secrets, healthcheck.
- `worker`: `packetstation worker`. Same image. Runs the VISUAL feed, the drawing lookup and housekeeping (see [FEED.md](FEED.md)). `backend` network plus egress (VISUAL, M-Files), no `npm_default`, no published port. Assets volume (fetched drawings land there), CA bundle bind, backup folder read-only (used from phase 9). Its healthcheck reads the heartbeat file.
- `worker`: `packetstation worker`. Same image. Runs the VISUAL feed, the drawing lookup, housekeeping and the phase 9 jobs: the AD reconcile, the role refresh, the integrity check, the backup check and the notifications (see [FEED.md](FEED.md)). `backend` network plus egress (VISUAL, M-Files), no `npm_default`, no published port. Assets volume (fetched drawings land there), CA bundle bind, backup folder read-only at `/backups` (the backup-check job reads it). Its healthcheck reads the heartbeat file.
- `clamav` (optional, profile `clamav`, phase 4): `clamav/clamav:1.4`, pinned by tag (with a comment in the compose file), not by digest. clamd on port 3310 for the app and the worker, reached by the network alias `packetstation-clamav` (as `postgres` has its own alias). `backend` network plus egress (freshclam downloads the signatures), no `npm_default`, no published port. Volume `clamav-db` at `/var/lib/clamav`, `mem_limit: 2g`, healthcheck `clamdcheck.sh`. Started only with `--profile clamav`. See "Virus scan (ClamAV)".
- All services restart unless stopped, log to stdout (json-file), and have healthchecks.
@@ -66,9 +66,10 @@ Key settings:
- `VISUAL_SERVER`, `VISUAL_DATABASE`, `VISUAL_USER=visualkit_ro`, `VISUAL_PASSWORD_FILE`.
- `MFILES_URL=https://mfiles.pescoinc.biz/REST`, `MFILES_VAULT_GUID={BB69270D-023E-4EA8-B899-AD20B1349702}`, `MFILES_USERNAME=mfileskitRO`, `MFILES_CA_BUNDLE`.
- `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`.
- `SMTP_HOST=app-03.pesco.com`, `SMTP_PORT=25`, `SMTP_STARTTLS=true`, `[email protected]`, `SMTP_CA_BUNDLE`, `NOTIFY_DRY_RUN` (`true` on the test stack, `false` on production). `STACK` (`test` or `prod`) picks the subject prefix. See "Notifications".
- `BACKUP_DIR`: the stack's backup folder on the host, mounted read-only in the worker. See "Backup".
- `APP_ALIAS` per stack.
- Identity timing and limits, all optional with these defaults: `SESSION_IDLE_MINUTES=15`, `SESSION_LOCK_MINUTES=3`, `SESSION_DESK_MINUTES=60` (Admin and Clerical wait at least this long for both), `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`.
- Identity timing and limits, all optional with these defaults (an Admin can override most of them within bounds on the Security tab; see [SECURITY.md](SECURITY.md), "Security settings"): `SESSION_IDLE_MINUTES=15`, `SESSION_LOCK_MINUTES=3`, `SESSION_DESK_MINUTES=60` (Admin and Clerical wait at least this long for both), `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`.
- Worker sources, all optional: `FEED_SOURCE` (`visualkit`, the default, or `fixture`), `DRAWING_SOURCE` (`mfiles`, the default, or `fixture`) and `FIXTURE_DIR` (default `/fixtures`). A fixture source reads files instead of VISUAL or M-Files (see [FEED.md](FEED.md), "Fixture sources"). `STACK=prod` refuses a fixture source at start. The test and production stacks leave all three unset. Only the CI env file sets them.
- Uploads and storage, all optional: `UPLOAD_MAX_BYTES` (default 25 MB, drawings, MTRs and documents), `UPLOAD_MAX_BYTES_BOM` (default 5 MB, hand BOMs), `ASSETS_QUOTA_WARN_PCT` (default 85, 1 to 99) and `ASSETS_QUOTA_BYTES` (default 0, which means the size of the assets volume; any other value is a byte budget for the stored files). See "Storage quota".
- Virus scan, all optional: `CLAMAV_HOST` (empty by default, which means no scan; `packetstation-clamav` for the profile service), `CLAMAV_PORT` (default 3310) and `CLAMAV_TIMEOUT_SECONDS` (default 30). The env examples carry `#CLAMAV_HOST=packetstation-clamav` commented out. See "Virus scan (ClamAV)".
@@ -119,7 +120,7 @@ clamd holds the signatures in memory, about 1.2 GB with the full set and more wh
Every stored file and the housekeeping job check the assets volume. With `ASSETS_QUOTA_BYTES=0` (the default) the check reads the volume's own used and total bytes; with a byte budget it adds up the stored files' sizes against it. At `ASSETS_QUOTA_WARN_PCT` percent (85 by default) the app opens one `notification_events` alert of kind `assets_quota` and audits `storage.quota_warning`. When usage drops back under, the alert is resolved. A failed check is logged and never fails an upload.
The Admin page, Storage tab, shows the usage bar (used of total, percent, the threshold), the open quota alert with an Acknowledge button, the ClamAV line ("Not configured", or the host with reachable or not reachable) and every open alert. Phase 9 sends the alert by email to PS-Admins (decided 2026-10-18, Gitea issue 20, which also kept the 85 percent threshold). Until then, check the Storage tab.
The Admin page, Storage tab, shows the usage bar (used of total, percent, the threshold), the open quota alert with an Acknowledge button, the ClamAV line ("Not configured", or the host with reachable or not reachable) and every open alert. The alert is mailed through the `assets_quota` notification rule, to PS-Admins by default (decided 2026-10-18, Gitea issue 20, which also kept the 85 percent threshold), with a "Cleared" message when usage drops back.
## Python packages and the Gitea index
@@ -253,7 +254,7 @@ It checks:
- `visualkit doctor` and `mfileskit doctor`. This is the first real Linux test of both libraries.
- Database connection, roles, migrations at head, and an audit UPDATE that must fail.
- LISTEN/NOTIFY round trip.
- Assets volume and free space. Backup folder (planned, phase 9).
- Assets volume and free space. The backup folder is readable (SKIP when it is not mounted).
- LDAP TLS bind on both controllers, reading `memberOf`, `employeeID` and `userAccountControl` as the service account, the 50-group count and one nested-group query.
- SMTP STARTTLS with certificate verification.
- ClamAV (phase 4): clamd answers PING at `CLAMAV_HOST`. SKIP when no host is set.
@@ -284,7 +285,7 @@ The real PESCO domain controllers are never used by CI. The test and production
CI never reads VISUAL or M-Files. `deploy/ci/ci.env` sets `FEED_SOURCE=fixture`, `DRAWING_SOURCE=fixture` and `FIXTURE_DIR=/fixtures`. The image does not contain `tests/`, so the e2e job generates the fixtures on the runner before the stack starts:
```bash
python -m pip install pypdf==6.21.0
python -m pip install pypdf==6.21.2
python -m tests.fixtures.generator --out tests/fixtures/generated
```
@@ -310,10 +311,161 @@ Two runner settings were needed and are in deploy/runner: the actions cache serv
port 8088 (job containers cannot reach the runner's own network, and every cache call timed out), and the
job workspace on a host directory allowed in `valid_volumes` (so the stack's bind mounts resolve).
## Backup (planned, phase 9)
## Image rebuild for 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/`.
Phase 9 bumps two pins for security fixes found by the CI dependency scan: `pypdf` 6.21.0 to 6.21.2 and `certifi` 2026.7.22 to 2026.10.19 (`server/requirements.txt` and `pyproject.toml`). They reach a stack only with a new image. Build and tag the phase 9 commit as usual (`:sha-<sha>` from CI), set it in the stack's env file, and `up -d`. Migration 0010 runs on the way up. Then run the doctor.
## Restore (planned, phase 9)
## Notifications (phase 9)
Restore the dump into a fresh database and copy the assets back, from the live folder or a file-level PBS restore. Rehearsed once on the test stack and written up here. A whole-VM restore from PBS is the disaster path.
The app sends test messages and the worker sends everything else, through PESCO's relay.
| Setting | Test stack | Production | CI |
|---|---|---|---|
| `SMTP_HOST` | `app-03.pesco.com` | `app-03.pesco.com` | empty (mail off) |
| `SMTP_PORT` | 25 | 25 | 25 |
| `SMTP_STARTTLS` | `true` | `true` | `true` |
| `SMTP_FROM` | `noreply@pescoinc.biz` | `noreply@pescoinc.biz` | `noreply@pescoinc.biz` |
| `SMTP_CA_BUNDLE` | `/etc/ssl/pesco/ca.pem` | `/etc/ssl/pesco/ca.pem` | `/etc/ssl/pesco/ca.pem` |
| `NOTIFY_DRY_RUN` | `true` | `false` | `true` |
| `STACK` | `test` | `prod` | `ci` |
- STARTTLS always, verified against the PESCO CA, with the host name so the certificate name matches. No login. A relay that does not offer STARTTLS, or a certificate that does not verify, fails the message. There is no plain-text fallback.
- Subjects start `[Packet Station]` when `STACK=prod`, otherwise `[Packet Station TEST]`.
- Each message is addressed To `SMTP_FROM`. The recipients are in the envelope only.
- `NOTIFY_DRY_RUN=true` records every message as `dry_run` in the sent log and sends nothing. The test stack keeps it on, so the test stack never mails people about test data. Production sets it `false`.
- With `SMTP_HOST` empty, every message is recorded `failed` with "Mail is off: SMTP_HOST is empty."
- The test send. On the Admin page, Notifications tab, open a rule, put an address in "Send the test to" and press "Send a test". It sends at once from the app, even in dry run, and shows the result. The sent log keeps the row (marked Test). A test with no address goes to the rule's recipients. On a new stack, send one test to your own address before relying on the rules.
- Every rule starts on, sent at once, to the AD group PS-Admins. Change the recipients on the Notifications tab. Each person sees on Account settings what reaches them and why.
- The worker needs the LDAP bind to resolve group recipients. While AD is down, an immediate message waits and is tried twice more, then recorded `failed`.
The doctor's SMTP line checks STARTTLS with certificate verification, without sending.
## Backup (phase 9)
Host scripts in `scripts/backup/`, run by root from a systemd timer. They run outside the app, so a broken app still backs up. [scripts/backup/README.md](../scripts/backup/README.md) has the same steps with more detail.
| File | Installed as |
|---|---|
| `ps-backup.sh` | `/usr/local/sbin/ps-backup.sh` (root, 0755) |
| `ps-restore.sh` | `/usr/local/sbin/ps-restore.sh` (root, 0755) |
| `backup.conf.example` | `/etc/packetstation/backup.conf` (root, 0644) |
| `packetstation-backup.service` | `/etc/systemd/system/` (root, 0644) |
| `packetstation-backup.timer` | `/etc/systemd/system/` (root, 0644) |
`backup.conf` lists the stacks, one per line: name, container (or `project:<compose project>`), database, folder.
```
test project:packetstation-test packetstation /home/spencerm/packetstation/test/backups
#prod project:packetstation packetstation /var/backups/packetstation/prod
```
The production line stays commented out until the production stack runs (phase 10). Until then it would fail every night.
Where the dumps go:
- Test stack: `/home/spencerm/packetstation/test/backups`, owned by spencerm (the script gives new files the folder's owner and group). The test `stack.env` sets `BACKUP_DIR` to this path.
- Production: `/var/backups/packetstation/prod`, `root:10001` 0750. The production `stack.env` sets `BACKUP_DIR=/var/backups/packetstation/prod`.
The worker mounts the stack's folder read-only at `/backups`. Its `backup-check` job (07:15) only lists the folder, reads the dumps' sizes and times, and reads the markers.
What a run does, per stack:
1. Finds the stack's running postgres container (by compose labels for `project:`). None or two is a failure.
2. `docker exec <pg> pg_dump -U postgres -d packetstation -Fc` over the local socket, which the postgres image trusts. No password is read or passed. Written under a temporary name, `.ps-<stamp>.partial`.
3. `pg_dumpall --globals-only --no-role-passwords`: the roles without their password hashes. The role passwords are in the stack secrets.
4. `pg_restore --list` with the container's own pg_restore must list at least one entry. Then the files are renamed to `ps-<UTC yyyymmddThhmmssZ>.dump` and `.globals.sql`, mode 0600.
5. Keeps the newest 7 (`KEEP=7` in the unit, `--keep N`), with their globals files.
6. Writes `last-success` or `last-failure` (`<ISO UTC time> <dump name>`, plus the reason on failure), mode 0644. A failed run leaves no partial file.
Each stack runs on its own, so one failing does not stop the next. Exit 1 if any stack failed, 2 for a usage or config error. `flock` keeps two runs from overlapping.
The unit is `Type=oneshot` as root, `Nice=10`, `IOSchedulingClass=idle`, `TimeoutStartSec=1h`, with `NoNewPrivileges`, `PrivateTmp` and `ProtectSystem=full` (no `ProtectHome`, so it can write the test folder under `/home`). The timer is `OnCalendar=*-*-* 02:00:00 America/Denver` with `Persistent=true`: 02:00 Denver time all year, an hour before the 03:00 PBS run, so PBS captures the fresh dumps. A missed run runs at the next boot.
### Install (Spencer, with sudo, on docker-1)
After the phase 9 tree is synced to `~/packetstation/repo`:
```bash
cd ~/packetstation/repo
sudo install -o root -g root -m 0755 scripts/backup/ps-backup.sh /usr/local/sbin/ps-backup.sh
sudo install -o root -g root -m 0755 scripts/backup/ps-restore.sh /usr/local/sbin/ps-restore.sh
sudo install -d -o root -g root -m 0755 /etc/packetstation
sudo install -o root -g root -m 0644 scripts/backup/backup.conf.example /etc/packetstation/backup.conf
sudo install -o root -g root -m 0644 scripts/backup/packetstation-backup.service /etc/systemd/system/
sudo install -o root -g root -m 0644 scripts/backup/packetstation-backup.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemd-analyze verify /etc/systemd/system/packetstation-backup.service
sudo systemctl enable --now packetstation-backup.timer
systemctl list-timers packetstation-backup.timer
sudo systemctl start packetstation-backup.service
journalctl -u packetstation-backup.service -n 20 --no-pager
ls -la ~/packetstation/test/backups
```
`list-timers` shows the next run at 08:00 or 09:00 UTC (02:00 in Denver). The one run by hand should leave a new `ps-*.dump` and a fresh `last-success`. After changing a script in the repo, run its `install` line again: the installed copies are root's, so a sync of the checkout does not change what root runs.
When the production stack exists (phase 10):
```bash
sudo install -d -o root -g root -m 0755 /var/backups/packetstation
sudo install -d -o root -g 10001 -m 0750 /var/backups/packetstation/prod
sudoedit /etc/packetstation/backup.conf # uncomment the prod line
sudo systemctl start packetstation-backup.service
```
A run by hand needs no sudo for the test stack (spencerm is in the docker group):
```bash
scripts/backup/ps-backup.sh --stack test project:packetstation-test packetstation /home/spencerm/packetstation/test/backups
```
The dumps hold the database only. The assets volume (the files) is not in them. It is covered by PBS, since the volume is part of the VM.
## Restore (phase 9)
`ps-restore.sh` never stops, restarts or reconfigures a stack. It restores into a new database next to the live one, and a person decides how to switch. A whole-VM restore from PBS stays the disaster path.
```bash
B=/home/spencerm/packetstation/test/backups
D=$(ls $B/ps-*.dump | tail -1)
# 1. Into a fresh database. Refuses an existing one; --replace drops it first (DROP refuses while anyone is connected).
ps-restore.sh --project packetstation-test --dump "$D" --globals "${D%.dump}.globals.sql" --db ps_restored
# 2. Assets, from a folder or a tar (for example a PBS file-level restore of the assets volume folder).
# Existing files are never overwritten. Files end up owned by 10001:10001.
ps-restore.sh --project packetstation-test --assets /path/to/assets.tar --assets-volume packetstation-test_assets
# 3. Every asset row's sha256 against the files in the volume.
ps-restore.sh --project packetstation-test --verify-assets --db ps_restored --assets-volume packetstation-test_assets
# Row counts of any database, to compare with the live one.
ps-restore.sh --project packetstation-test --counts-only --db packetstation
```
- The database is created as `01-roles.sh` creates it (owner `ps_migrate`, UTF8, `template0`, CONNECT and TEMPORARY for `ps_app`), then `pg_restore --exit-on-error --single-transaction` as postgres. A failed restore leaves an empty database, never a half one.
- The globals file is applied without stopping on "already exists". Any other error fails. The script refuses a globals file that holds a password hash. On a new cluster the roles come back without passwords: set each from the stack's secret file with `\password` in psql, or start the stack on an empty volume so initdb creates them.
- The assets copy runs in a throwaway container from the image the postgres container already runs, with `--network none`, so nothing is downloaded.
- To put a restored database into service: stop that stack's app and worker, rename as postgres (`ALTER DATABASE packetstation RENAME TO packetstation_old; ALTER DATABASE ps_restored RENAME TO packetstation;`), start the stack, check the app, then drop the old database. This step is deliberate and manual.
### Restore rehearsal 2026-10-21
Done on docker-1 against the test stack (`packetstation-test`, image `sha-ce28d2f`, container `packetstation-test-postgres-1`, postgres:16-bookworm by digest), as spencerm without sudo, from a scratch copy of the scripts. The stack was not stopped, restarted or changed. Times UTC.
| Step | Result |
|---|---|
| Backup by hand into `/home/spencerm/packetstation/test/backups` | 21:24:05 to 21:24:06, 0.76 s. `ps-20261021T212405Z.dump`, 2,234,906 bytes, 743 `pg_restore --list` entries. Globals 774 bytes with no password line (roles postgres, ps_app, ps_migrate, attributes only). `last-success` written. Dump and globals 0600, marker 0644, all spencerm. Exit 0. This dump stays in the folder. |
| Failure path (a database that does not exist, into a scratch folder) | Exit 1, pg_dump's error on stderr, `last-failure` = `2026-10-21T21:24:53Z ps-20261021T212453Z.dump pg_dump failed`, no partial file. |
| Live row counts | `--counts-only --db packetstation`: 64 tables. |
| Restore into `ps_restore_rehearsal` with the globals | Globals: 3 statements skipped (the roles exist), no other error, no password touched. Created and restored in 1.65 s. Every one of the 64 tables has the same row count as the live database, checked just before and just after (for example assets 189, audit_events 2137, bom_lines 5742). |
| Assets out of the live volume | Tarred through a throwaway container (`--network none`, read-only mount): 129,546,240 bytes, 189 files, 3.8 s. sha256 of every file against the 189 `assets` rows of the restored database: 189 match, 0 differ, 0 missing, 0 files without a row. |
| Asset restore path | `--assets <the tar> --assets-volume ps_restore_rehearsal_assets` into a throwaway volume, 0.47 s. `--verify-assets`: 189 rows, 189 match, 0 differ, 0 missing, 0 orphans. A second copy changed nothing (no overwrite). Files owned 10001:10001. |
| Guard | Restoring again into `ps_restore_rehearsal` without `--replace`: refused, exit 1. |
| Cleanup | `DROP DATABASE ps_restore_rehearsal`, the throwaway volume and the scratch folder removed. The cluster lists only packetstation, postgres, template0 and template1. App, worker and postgres stayed healthy throughout. |
| Timer | `systemd-analyze calendar "*-*-* 02:00:00 America/Denver"`: next run 2026-10-22 08:00 UTC. `systemd-analyze verify` on both units found only the script not installed yet. systemd 255. |
Nothing went wrong. Notes from it:
- The dump is small (2.2 MB). The assets (125 MB) are the bulk and are not in the nightly dump. PBS covers them; the restore path is a PBS file-level restore of the volume folder, or a tar, through `--assets`.
- The globals restore re-applies role attributes. On the same cluster that changes nothing. On a new cluster it creates the roles without passwords: set them from the secrets.
- The next rehearsal is IT's, quarterly (requirement O2).
+90 -9
View File
@@ -1,6 +1,6 @@
# Packet Station feed and drawings
Status: phase 3. This file says how the worker reads VISUAL and M-Files and what it does with what it reads. The design is in [PLAN.md](PLAN.md), "Worker, feed and drawings". When this file and the code disagree, the code is the rule and this file is out of date.
Status: phase 9. This file says how the worker reads VISUAL and M-Files, what it does with what it reads, and the other jobs it runs (phase 9: the AD reconcile, the role refresh, the integrity check, the backup check and the notifications). The design is in [PLAN.md](PLAN.md), "Worker, feed and drawings". When this file and the code disagree, the code is the rule and this file is out of date.
## Purpose
@@ -36,7 +36,7 @@ The listening connection reconnects with backoff when it drops.
### Pause
When `paused` is on, scheduled fires of the pausable jobs (`visual-feed`, `drawing-lookup`) are skipped. Each skip is recorded as a `job_runs` row with status `skipped` and counts `{"reason": "paused"}`. Auto-release does not run while paused. "Run now" still works while paused. Housekeeping is not paused.
When `paused` is on, scheduled fires of the pausable jobs (`visual-feed`, `drawing-lookup`) are skipped. Each skip is recorded as a `job_runs` row with status `skipped` and counts `{"reason": "paused"}`. Auto-release does not run while paused. "Run now" still works while paused. Housekeeping and the phase 9 jobs are not paused.
### Run now and job_requests
@@ -82,16 +82,97 @@ At start the worker runs the same stack guard as the app: the test stack refuses
| `visual-feed` | `interval_shop_minutes` inside shop hours (default 5), `interval_off_minutes` outside (default 30) | yes | The five reads, the cache, the packet rules, auto-release. Chains `drawing-lookup`. |
| `drawing-lookup` | After every feed run, plus every `drawing_interval_minutes` (default 30) | yes | The drawing queue (see "Drawings"). |
| `housekeeping` | Daily at 03:30 site time | no | Prunes old rows. The plan keeps the caches 90 days and `job_runs` 180 days. |
| `ad-reconcile` | Daily at 01:30 site time, plus "Refresh from AD now" | no | Users and roles from the AD groups, inactive users signed out, employee ID flags (phase 9). |
| `session-refresh` | Every `roles_refresh_minutes` (default 15) | no | Every live session's groups and account state read again (phase 9). |
| `integrity` | Daily at 04:00 site time | no | Seals, asset hashes, missing and orphan files, the audit hash chain (phase 9). |
| `backup-check` | Daily at 07:15 site time | no | The newest dump is there, not empty, under 26 hours old, and not followed by a failure (phase 9). |
| `notify-send` | Every 60 seconds | no | Rule seed, the version check, the watch checks, immediate delivery and the digest queue (phase 9). |
| `notify-digest` | Every 5 minutes | no | Sends each digest that is due, one message per address (phase 9). |
Planned, not built in phase 3:
`job_schedule` holds each job's next run, interval and pause state for the Admin tab. Migration 0004 seeds rows for `visual-feed`, `drawing-lookup` and `housekeeping`; 0010 adds the six phase 9 jobs.
- `session-refresh` (every 15 minutes): re-resolve groups and the account state for every live session.
- `ad-reconcile` (nightly 01:30 site time, plus "refresh from AD now"), phase 9.
- `integrity` (04:00): seals, asset hashes, missing and orphan files, the audit hash chain. Phase 9.
- `backup-check` (07:15): the newest dump is under 26 hours old. Phase 9.
- `notify-send` (every 60 seconds) and `notify-digest` (daily at the digest time). Phase 9.
## Phase 9 jobs
`job_schedule` holds each job's next run, interval and pause state for the Admin tab. Migration 0004 seeds rows for `visual-feed`, `drawing-lookup` and `housekeeping`.
None of the six phase 9 jobs is pausable. Each is a module in `worker/jobs/` with its own `SPEC`. `load_jobs()` imports them lazily, so a module that fails to import is logged and left out, and the rest run. They use the same run now, advisory lock and `job_runs` rules as the feed. `packetstation job run <name>` runs any of them once.
### ad-reconcile (01:30 site time, plus "Refresh from AD now")
1. Reloads the departments (and with them the group map).
2. Reads AD, outside any transaction: every group in `LDAP_GROUP_BASE_DN` (name and DN), the members of every mapped group through `LDAP_MATCHING_RULE_IN_CHAIN` in pages of 500 (disabled accounts included, so they can be marked), and, by objectGUID in batches of 50, every active user who was not in any group.
3. **Refusal guard.** When no mapped group is in the OU (usually a wrong `LDAP_GROUP_BASE_DN`), or when every mapped group is empty while active users exist, the run raises `ReconcileRefused`, fails with that reason in its errors, and changes nothing (PLAN.md open question 44).
4. One transaction: users upserted on objectGUID and their roles rebuilt; a disabled user marked inactive with their sessions ended `ad_disabled`; a user AD no longer has marked inactive with `ad_gone`; a user in AD with no mapped group left without roles and their sessions ended `roles_lost`. `directory_flags` gets `emp_missing`, `emp_duplicate`, `gone` and `disabled`. Every ended session writes `auth.session_ended` with `by: ad-reconcile`, and the run writes an `ad.refresh` audit row with its counts.
5. Events, in their own transaction: `group_missing` (a condition per missing group, subject the group name) raised, and resolved for every mapped group present; `ad_reconcile_report` (one-shot, info) when anyone was added or deactivated, an extra group is in the OU, or an employee ID is missing or shared. A notify error is recorded on the run and does not fail it.
AD unreachable fails the run and changes nothing. A failed or refused run raises the `ad_reconcile_report` condition "AD reconcile did not run" (subject `ad-reconcile:failed`), so the rule's recipients get one mail until a run succeeds and clears it. The counts are the report the Admin page shows: `groups_checked`, `groups_missing` and `groups_extra` (names), `users_seen`, `created`, `updated`, `deactivated`, `sessions_ended`, `emp_missing` (accounts) and `emp_duplicate` (employee IDs), lists capped at 200.
"Refresh from AD now" inserts a `job_requests` row for `ad-reconcile` and answers 202 (open question 48). `packetstation ad-refresh` runs the same reconcile in the foreground.
In CI the Samba DC has an extra group and a duplicate employee ID on purpose, so the report is raised every night there.
### session-refresh (every `roles_refresh_minutes`, default 15)
For every user with a live session (not revoked, not expired): AD first, by objectGUID in batches, then each enabled user's groups. Then one transaction: a disabled or gone user is marked inactive and their sessions ended (`ad_disabled`, `ad_gone`); a user left with no role has their roles cleared and sessions ended (`roles_lost`); everyone else gets their user row, `user_roles`, each live session's `roles_snapshot` and `roles_checked_at` updated. Any AD failure makes the run `degraded` with `counts.ad = "unavailable"`, and nothing is written or ended. The interval comes from the Security tab setting, read at each run, so a changed value applies after the next run. Counts: `users`, `refreshed`, `roles_changed`, `deactivated`, `roles_lost`, `sessions_ended`.
### integrity (04:00 site time)
Each step in its own transaction:
- Seals: every unbroken seal of a filed packet is rebuilt and compared with the seal key. A seal made under another `key_id` is a finding. No seal key makes the run degraded and the seals unchecked.
- Assets: every asset row checked for its file (`asset_missing`). The sha256 is recomputed for assets uploaded in the last 7 days every night, and for every asset on Sundays (site time); `hash_checked_at` and `hash_ok` are set (`asset_hash`).
- Orphans: files under `ASSETS_DIR` that no asset names, older than one hour (uploads in flight are skipped), `lost+found` skipped (`blob_orphan`). Reported, never deleted.
- Audit: `audit.verify_chain` over the hash chain (`audit_chain`).
One `integrity_findings` row per finding, at most 500 per kind (the count in the run's counts). Any finding raises `integrity_failure` (condition, critical, subject `integrity`). A clean run that was not degraded resolves it. Counts: `full`, `seals`, `assets`, `hashed`, `files`, `audit_rows`, `findings` per kind, `total`.
### backup-check (07:15 site time)
Reads the stack's backup folder, mounted read-only at `/backups` (`BACKUP_DIR` on the host). Problems: the folder is not there, no `*.dump` at the top of it, the newest dump is empty or older than 26 hours (by modification time), or `last-failure` is newer than `last-success` (or there is a failure marker and no success marker). Any problem raises `backup_stale` (condition, subject the stack name); none resolves it. The run itself stays `ok`; the alert carries the news. Counts: `folder`, `dumps`, `newest`, `newest_at`, `age_hours`, `size`, `last_success`, `last_failure`, `problems`. The CI stack's backup folder is empty, so CI raises `backup_stale` in dry run.
### notify-send (every 60 seconds)
1. Seeds any missing rule (on, at once, digest 07:00, to PS-Admins). Never changes a rule that exists.
2. On its first run in each worker process: when `PS_BUILD` differs from the settings row `notify.last_build`, updates the row and raises `version_deployed` (subject `build:<build>`). Not at worker start, since the scheduler may start before PostgreSQL answers (open question 47). A restart on the same build raises nothing.
3. At most every 15 minutes per process, the watch checks: `drawing_failing` when drawing lookups have been failing for more than the rule's hours (default 24; subject `drawings`, the list of up to 50 in the body), resolved when none is; `co_waiting` per change order pending review more than the rule's days (default 3) on a live packet (subject `co:<id>`, the packet's department), resolved when it is no longer waiting.
4. Delivery: up to 200 undispatched events, oldest first, each in its own transaction with `FOR UPDATE SKIP LOCKED`. No rule: `no_rule`. Rule off: `rule_off`. Digest rule: an outbox row, `queued`. Immediate: recipients resolved now, one message, one `notifications_sent` row, `sent`, `dry_run`, `failed` or `no_recipients`. A failed SMTP send is recorded and not retried. AD unavailable while resolving a group: a `failed` row "directory unavailable", the event stays pending (`directory_retry:N`), and the third failure dispatches it `failed`.
A failed or retried send makes the run degraded.
### notify-digest (every 5 minutes)
For each rule whose digest time (site time) has passed today and that has outbox rows queued before it: recipients resolved per event, then grouped per address across every rule. One message per address lists every due event (`Daily digest: N notifications`), with one `notifications_sent` row per message (event type `digest`). An outbox row is marked sent only when every address got it. A failed address leaves the rows for the next run, which skips addresses that already got an event, and the job backs off 30 minutes (open question 43). Nothing queued: the run ends without touching AD.
### Events from the feed and the drawing job
After each `visual-feed` and `drawing-lookup` run, `worker/runs.py` raises or resolves the feed conditions:
| Run status | `feed_failed` (subject the job name, critical, the first ten errors in the body) | `feed_degraded` (visual-feed only) |
|---|---|---|
| failed | raised | unchanged |
| degraded | resolved | raised |
| ok | resolved | resolved |
| skipped | unchanged | unchanged |
`drawing_failing` comes from the notify-send watch check above, not from the run. Each event goes to its rule's recipients, PS-Admins by default. A broken run connection falls back to a fresh one and never fails the run.
### What each event means
| Event type | Raised by | Kind | Cleared by |
|---|---|---|---|
| `security_alert` | Account cooldown, PIN disabled, session ended after wrong PINs | one-shot, 15 min per subject | none |
| `signin_burst` | An address over its limit, or the burst threshold across accounts | one-shot, 30 min per subject | none |
| `integrity_failure` | integrity | condition | a clean integrity run |
| `feed_failed` | visual-feed or drawing-lookup failed | condition | an ok or degraded run |
| `feed_degraded` | visual-feed degraded | condition | an ok run |
| `drawing_failing` | notify-send watch | condition | no lookup failing that long |
| `ad_reconcile_report` | ad-reconcile | one-shot | none |
| `group_missing` | ad-reconcile, per group | condition | the group found |
| `backup_stale` | backup-check | condition | a good backup |
| `co_waiting` | notify-send watch, per change order | condition, department | decided or no longer waiting |
| `qc_reject` | A Q.C. reject on a packet, an ITP line reject | one-shot, department | none |
| `version_deployed` | notify-send, first run on a new build | one-shot | none |
| `assets_quota` | The storage check after every upload and in housekeeping | condition | usage back under the threshold |
A condition sends once when it opens and a "Cleared: <title>" message to the same rule when it closes. Raised again while open, it only counts (`raised_count`). The department of an ITP reject is the one department among the unit's live packets that works to the line's page; none or several gives none.
## Feed reads
+18 -4
View File
@@ -42,7 +42,7 @@ frontend/ (Vite + React + TS; src/theme, src/screens/{clerical,warehouse,supervi
tests/server/ (pytest: policy matrix, merge rules, packet rules, feed fixtures, auth)
tests/e2e/ (Playwright scenarios, synthetic fixtures) tests/fixtures/generator.py
deploy/ compose.prod.yml compose.test.yml compose.ci.yml npm/ (proxy host notes) samba-ad/ (CI DC seed)
scripts/ backup.sh restore.sh packetstation-backup.service/.timer
scripts/backup/ ps-backup.sh ps-restore.sh packetstation-backup.service packetstation-backup.timer backup.conf.example
.gitea/workflows/ci.yml
```
@@ -115,6 +115,7 @@ Each phase ends with passing tests, docs updated (README, CHANGELOG, SECURITY.md
- Notifications tab (rules per event type, recipients: group, user, mailbox, dynamic department Supervisor group; immediate or digest; exclusions; test send; sent log; dry run on test), SMTP via app-03 with STARTTLS and CA verify, subject prefixes, seed to PS-Admins.
- scripts/backup.sh + systemd timer (America/Denver 02:00), 7 dumps, marker; worker backup-check job; restore script; restore rehearsed on the test stack and written up.
- Closes: I6 (app part), M3, O1, O2 (first rehearsal), S3 (pip-audit and npm audit in CI).
- Done 2026-10-21. The eight Admin tabs, the Account notifications section, the six worker jobs, the notify package with SMTP over STARTTLS, migration 0010, the backup and restore scripts with the systemd units, and the CI dependency scan are built and tested. The backup and the restore were rehearsed by hand on the test stack on docker-1 (DEPLOY.md, "Restore rehearsal 2026-10-21"). The first scan found two pins to bump (pypdf 6.21.1, certifi 2026.10.19). The separate `notification_state` table was not needed: the open `notification_events` row is the state. The timer is installed by Spencer with sudo (DEPLOY.md, "Backup"). Decisions taken are open questions 43 to 50.
### Phase 10: Regression bench, hardening, go-live (medium)
- Rewrite the 38 scenarios against the new UI with synthetic fixtures; run in Gitea Actions against compose.ci.yml. Policy matrix, merge rules and packet-rule tests already run from phase 2 and 3.
@@ -169,7 +170,7 @@ Conventions: identity bigints except users (uuid = AD objectGUID); Postgres enum
- **Change orders:** change_orders (co_no, origin visual|drawing|manual, review pending|approved|rejected, minor, hold, held_by, issued_*, approved_*, rejected_*, vis jsonb, waiting jsonb, staged jsonb, target_fp; one pending visual CO per packet), co_items, co_required_depts, co_verifications (insert-only, signature required). View v_open_cos ports openCOs.
- **Documents:** assets (uuid, sha256, size, mime, original_name, storage_key, source, source_ref jsonb, uploaded_by, packet_id); packet_docs; qc_docs (project, unit, type, rev, auto, superseded_by); itps (unit unique, template jsonb frozen, status, signoff_*, reopen_*), itp_lines, itp_rejects (append-only, ncr), itp_blocks.
- **Audit, settings, jobs:** audit_events (append-only, prev_hash and row_hash chain, user, session, role_snapshot, action, subject, packet_id, detail jsonb, it_reason, ip); settings (key, value jsonb, version); job_requests, job_runs (trigger, requested_by, status ok|degraded|failed|skipped|abandoned, counts jsonb, errors jsonb, build_version, visual_target), job_schedule, worker_heartbeat.
- **Notifications (per the brief):** notification_rules (event_type, enabled, delivery immediate|digest, digest_time); notification_recipients (rule_id, kind group|user|mailbox|dept_supervisor, group_dn, user_id, address); notification_exclusions (rule_id, user_id); notification_outbox; notifications_sent (event, recipients as resolved, subject, result); notification_state (dedupe and recovery).
- **Notifications (per the brief):** notification_rules (event_type, enabled, delivery immediate|digest, digest_time); notification_recipients (rule_id, kind group|user|mailbox|dept_supervisor, group_dn, user_id, address); notification_exclusions (rule_id, user_id); notification_outbox; notifications_sent (event, recipients as resolved, subject, result). The planned notification_state (dedupe and recovery) was not needed: the open notification_events row under its unique index is the dedupe, and a recovery row points back at it (phase 9).
- **VISUAL and drawing caches:** visual_batches; visual_rows (typed key wo, lot, split, sub, seq int; all contract fields; row_hash; first, last and gone batch); visual_materials (plus piece int, mat_hash); visual_orders and visual_order_links (base or project, rank, lines, top_jobs, order_date); visual_labor; visual_row_changes and visual_material_changes (per batch, added|changed|gone); visual_ignored_wos (go-live list with pulled_in_*); visual_resource_map; drawing_lookups (dwg PK, checked_at, ok, rev, name, object and file ids and versions, base, draft_rev, draft_where, in_drafting, note, failing_since, asset_id, visual_rev_seen).
- **JSONB only here:** foreign-shaped feed payloads (source, visual, partspecs, vis, waiting, staged, raw), template-driven answers (checklist, checks, itp template, settings), and frozen snapshots (roles_snapshot, qc_history, audit detail, job counts). Everything the policy or the boards filter on is a real column.
- **Versioning:** packets.version goes up in every transaction that touches the packet or a child; it is the ETag and the NOTIFY payload. Child rows carry row_version. Every write takes `SELECT ... FOR UPDATE` on the packet first, which replaces the old 8-second lease.
@@ -204,7 +205,7 @@ Conventions: identity bigints except users (uuid = AD objectGUID); Postgres enum
## Deployment and CI (phase 0, 9, 10; docs/DEPLOY.md)
- **Dockerfile:** frontend stage on node:22-bookworm-slim; builder on python:3.12-slim-bookworm with a venv, `pip install --require-hashes --index-url <gitea>/simple/ -r server/requirements.lock` then the package; runtime with the Microsoft Debian 12 repo (msodbcsql18, unixodbc, tini, tzdata), non-root uid 10001, `PS_BUILD` label. entrypoint exports MFILES_PASSWORD from the secret file and dispatches app (uvicorn with no proxy headers; the app's TrustedProxyMiddleware re-resolves npm_app_1 every 60 s), worker, migrate, doctor, job run, fixtures.
- **Compose:** base file with postgres (16-bookworm by digest, `backend` internal network only, initdb creates ps_migrate, ps_app and ps_worker from secrets), one-shot migrate, app and worker (backend plus egress, assets volume, CA bundle bind, secrets, healthchecks, restart unless-stopped, json-file logs, 1 GB limits; worker also mounts the backup folder read-only). Proxy overlay joins app to npm_default with `APP_ALIAS`. CI overlay adds the Samba DC, fixture sources and dry-run mail. Env files per stack in /etc/packetstation; secrets root:10001 0440. Deploy: `docker compose -p packetstation --env-file ... -f compose.yaml -f compose.proxy.yaml up -d`.
- **Compose:** base file with postgres (16-bookworm by digest, `backend` internal network only, initdb creates ps_migrate and ps_app from secrets; the app and the worker both log in as ps_app, the worker with application name `ps-worker`), one-shot migrate, app and worker (backend plus egress, assets volume, CA bundle bind, secrets, healthchecks, restart unless-stopped, json-file logs, 1 GB limits; worker also mounts the backup folder read-only). Proxy overlay joins app to npm_default with `APP_ALIAS`. CI overlay adds the Samba DC, fixture sources and dry-run mail. Env files per stack in /etc/packetstation; secrets root:10001 0440. Deploy: `docker compose -p packetstation --env-file ... -f compose.yaml -f compose.proxy.yaml up -d`.
- **NPM proxy host** (recorded in deploy/npm-proxy-host.md): http to the alias on 8000, Force SSL, HTTP/2, NPM HSTS off (the app sends it), `client_max_body_size 30m`, a `location /api/events` block with buffering off and 3600 s timeouts using the `$server` variables so the alias is re-resolved.
- **Doctor:** config and stack guard, `visualkit doctor`, `mfileskit doctor`, DB connect and migrations at head and an audit UPDATE that must fail, LISTEN/NOTIFY round trip, assets volume and free space, backup folder, LDAP TLS bind reading memberOf, employeeID and userAccountControl with the 50-group count and one IN_CHAIN query on both controllers, SMTP STARTTLS verify, tzdata.
- **Backups:** scripts/backup/ps-backup.sh per stack via `docker exec pg_dump -Fc` plus globals, verified with `pg_restore --list`, 7 kept, last-success and last-failure markers in /var/backups/packetstation/<stack>/; systemd timer `OnCalendar=*-*-* 02:00:00 America/Denver`; restore script and rehearsal recorded in DEPLOY.md.
@@ -276,13 +277,26 @@ From phase 8 (2026-10-18), for Seth to veto. The phase 8 design numbered these 3
41. The find bar filters are applied by the server on every board and list, so a board never fetches more than it shows. Default: the KPI numbers follow the project pick only, not the other filters. Phase 8 (2026-10-18) applied the default.
42. Mine is decided from the person's AD roles and the screen they are on. Default: the stall's Mine needs the picked station and shows nothing without one. Phase 8 (2026-10-18) applied the default.
From phase 9 (2026-10-21), for Seth or Spencer to veto:
43. The daily digest: default one message per address per digest time, listing every due event of every digest rule that reaches that address, not one message per rule. An address that already got an event is not sent it again after a partial failure. Phase 9 (2026-10-21) applied the default.
44. The AD reconcile guard: default the reconcile refuses and changes nothing when no mapped group is in the OU (a wrong `LDAP_GROUP_BASE_DN`) or when every mapped group is empty while active users exist. The run is recorded as failed with the reason in its errors, and an `ad_reconcile_report` condition "AD reconcile did not run" goes to the rule once, cleared by the next good run. A wholesale sign-out needs a person to look first. Phase 9 (2026-10-21) applied the default.
45. `profile_active`: default an inactive profile only hides the person from the welder picker and the worker filter. It does not change their roles or rights, and an assign made through the API still checks only the role. AD decides who may do what. Phase 9 (2026-10-21) applied the default.
46. Account opt-out: default any person may opt out of an event type on Account settings, in a password session, except the security event types (security alert, sign-in burst, integrity failure). An Admin cannot leave anyone out of those either. Phase 9 (2026-10-21) applied the default.
47. Version deployed: default the event is raised by the first `notify-send` run in each worker process when `PS_BUILD` differs from the build last announced (settings row `notify.last_build`), not at worker start, since the scheduler may start before PostgreSQL answers. A restart on the same build sends nothing. Phase 9 (2026-10-21) applied the default.
48. "Refresh from AD now": default the button asks the worker for an `ad-reconcile` run and answers 202 at once, instead of running the reconcile inside the web request. The Admin sees "Refresh requested" and the report when the run finishes. Phase 9 (2026-10-21) applied the default.
49. New departments: default only shop departments can be added in the app (422 `support_fixed` for a support department). The support roles are fixed in the `role_kind` enum and the group map, so a new support department would map to no role. Phase 9 (2026-10-21) applied the default.
50. The profile CSV `stations` cell: default the cell is the person's whole list of station limits across departments and replaces every limit they had; `-` clears them all; an empty cell leaves them. Phase 9 (2026-10-21) applied the default.
51. A direct welder assign: default it does not check `profile_active`, so an Admin's inactive switch hides a person from the picker but a typed assign still works. Phase 9 (2026-10-21) applied the default; say if an inactive profile should be refused there too.
52. A checklist item added while a stage is open: default it applies to that open stage too, because completeness reads the live template, so the item must be ticked before submit. Phase 9 (2026-10-21) applied the default.
## Verification
- Every phase: `pytest` server suite, `npm test` and `npm run build` for the frontend, `docker compose -f deploy/compose.ci.yml up` with the Samba DC and the Playwright scenarios for that phase, `packetstation doctor` inside the image.
- Phase 3: a recorded DEV feed run compared with the packets the old build made for the same window (counts of drafts, units, projects; same keys), run from the developer machine against VISUAL DEV only.
- Phase 7: server tests for close and file against the policy oracle, the open work order confirm, the seal with its data book, reopen and refile, a failed data book build, the data book sections and index, the traveler, the ITP PDF, every print kind with its stamp and `print_log` row, the CSV formula guard and the stamp signature; frontend tests for the close and file panel, the print menu, the prints list and the ITP buttons; the Playwright close scenario (close, PDFs and CSV, prints, file with the seal, reopen and refile, the signed ITP PDF) in the CI stack on docker-1.
- Phase 8: the data book of a synthetic filed packet compared page by page with a data book exported from the old build on the same fixture (moved from phase 7). Done in phase 8 (2026-10-18); the page table, the fixes and what is left are in the phase 8 comparison note.
- Phase 9: restore rehearsal on the test stack from a pg_dump and an assets copy.
- Phase 9: restore rehearsal on the test stack from a pg_dump and an assets copy. Done 2026-10-21: every table's row count and all 189 asset hashes matched (DEPLOY.md, "Restore rehearsal 2026-10-21").
- Phase 10: the full 38-scenario bench green in Gitea Actions.
## Host layout on docker-1
+111 -1
View File
@@ -823,7 +823,7 @@ The packet page shows who else has the packet open: `Also in this packet now: <N
- A locked session drops out at once. Lock and unlock publish a `session` event (`locked`, `unlocked`) from `POST /api/auth/lock`, `POST /api/auth/unlock` and the server's idle lock. Unlock brings the person back.
- A revoked session drops out at once, and its stream ends.
- Closing the tab or losing the network drops the person out when the stream ends, within the stream's ping cycle (25 seconds). No other idle rule is needed.
- The server also keeps who is on each screen for the Admin view of phase 9. No endpoint reads it yet.
- The server also keeps who is on each screen. Phase 9's Admin "Who is in" tab reads it (`GET /api/admin/presence`).
### Tightenings against the old build
@@ -836,6 +836,116 @@ The packet page shows who else has the packet open: `Also in this packet now: <N
| Each browser published its own presence, with the name, employee ID, role and stall, refreshed every 20 seconds. | The server builds presence from the session: display name and role, no employee ID. A locked or revoked session drops out at once. | Presence shows only what it needs, and a locked screen is not someone in the packet. | Yes |
| Over target was worked out in the browser from the hours it had. | Over target is timer hours over the work-order target, on the server. Ticket hours are shown beside it and never decide it. | The station timer is the clock of record (open question 1). | Yes |
## Phase 9: Admin, notifications, reconcile
Nothing in the packet rule table changes in phase 9. The new endpoints are Admin endpoints and one Account endpoint. None of them writes a packet.
### Who may use the Admin endpoints
Every endpoint under `/api/admin` needs an Admin (PS-Admins) in a password session. An Admin in a PIN session is 403 `password_session_required`. Every other role is 403. An unsigned request is 401. Every write also passes the Origin check and the body limit. Tests try every endpoint as every role.
| Endpoint | What | Audit |
|---|---|---|
| `GET /departments` | Every department, its AD groups (and whether each is in the OU; unknown when AD is down) and its stations | none |
| `PATCH /departments/{code}` | Name, active, stations (rename, reorder, add, hide) | `admin.department_updated` |
| `POST /departments` | A new shop department with its stations; answers the three group names IT must create | `admin.department_added` |
| `GET /users`, `GET /users/{id}` | Gain the welder stamp, `profile_active`, station limits and the directory flags | none |
| `PATCH /users/{id}/profile` | Welder stamp, `profile_active`, station limits per department | `admin.profile_updated` |
| `POST /users/import` | The profile CSV, as a preview (`apply: false`) or applied | `admin.profile_updated` per changed person, `admin.profile_import` |
| `GET /checklists`, `PUT /checklists` | The weld and plain station checklists | `admin.checklists_updated` |
| `GET /security/settings`, `PUT /security/settings` | The session, lock and sign-in limit settings | `admin.security_settings_updated` |
| `GET /security/alerts?hours=` | Security alerts for 1 to 168 hours | none |
| `GET /security/integrity` | The newest finished integrity run and its findings | none |
| `GET /access-log` | Sign-in attempts and identity audit rows for everyone | none |
| `GET /access-log.csv` | The same, as CSV, at most 50,000 rows | `admin.access_log_exported`, a `print_log` row of kind `access-log-csv` |
| `GET /presence` | Live sessions, locked or not, with the screen and packet | none |
| `POST /stamp-verify`, `POST /stamp-verify/pdf` | Checks a stamp line, a 16-character code or a PDF (25 MB) against the print log | `admin.stamp_verified` |
| `GET /directory-flags` | Active users with no employee ID, and employee IDs on more than one account | none |
| `POST /ad/refresh` | Asks the worker for an `ad-reconcile` run; 202 (open question 48) | `ad.refresh_requested` |
| `GET /ad/reconcile` | The newest finished reconcile run and its report | none |
| `GET /notifications` | Mail settings, the subject prefix, every rule with its recipients, exclusions and last message | none |
| `PUT /notifications/{event_type}` | One rule: on or off, delivery, digest time, its hours or days, recipients, exclusions | `admin.notification_rule_updated` |
| `GET /notifications/groups?q=` | AD group search (2 to 100 characters; 503 when AD is down) | none |
| `POST /notifications/{event_type}/test` | A test message, to the rule's recipients or to one address | `admin.notification_test` |
| `GET /notifications/sent` | The sent log | none |
Every Admin change is audited, with the state before and after where there is one.
### Compare and set
The checklists, the security settings and each notification rule carry a version. A save sends the version it read. When someone else saved first the answer is 409 `version_conflict` with the current value, and nothing is written. The screen reloads and says so.
### Departments
- The code and the short name (the slug) never change. They are in the AD group names. A trigger refuses a change, and a PATCH body that names `code` or `slug` is 422.
- Stations are never deleted. Station limits, packets and history point at them. A station is hidden by setting it inactive. A station listed without an id is added. A station missing from the list is left as it is. Two stations of one department with the same name are refused (422 `duplicate_station`).
- A new department needs a code of 2 to 4 capital letters or digits and a short name of 2 to 31 letters or digits starting with a letter, both unused (409 `code_taken`, `slug_taken`). Only shop departments can be added; a support department is 422 `support_fixed`, because the support roles are fixed (open question 49). The answer names the three groups IT must create. Until they exist the department has no people.
- After a change the app rebuilds its group map from the departments table. The worker reloads it at the start of every reconcile and role refresh run.
### Profiles
- The welder stamp is 1 to 12 letters, digits or dashes, unique among users ignoring case (409 `stamp_taken`). Empty clears it. The packet's own welder stamp field still allows 10; the profile stamp is copied onto the packet as it is.
- `profile_active` off hides the person from the welder picker and the worker filter. It does not change their roles or what they may do, and an assign sent to the API still checks only the role (open question 45). AD decides who may do what.
- Station limits are set per department. A station must belong to that department. An empty list clears the department's limits.
- The CSV import matches employee IDs as text among active users. `0042` is not `42`. Columns: `employee_id` (required), `welder_stamp`, `active`, `stations` (`<dept code>:<station name>` joined by `;`, the person's whole list; open question 50). An empty cell leaves the value. `-` clears it. Up to 2,000 rows and 1 MB. Each row lists its changes and its errors: unknown employee ID, an ID on more than one user, an ID twice in the file, a bad or taken stamp, a bad active value, an unknown department or station. Apply with any error is 422 with the same rows and changes nothing. Otherwise every row is applied in one transaction.
### Checklists
Both lists need at least one item and at most 40. A key is lower case letters, digits and underscores, starting with a letter, at most 30 characters, unique in its list. Group and text are not empty, at most 200 characters. The only placeholders are `{drawing}`, `{rev}` and `{wps}`. Answers on open stages are kept by key. A removed item drops out of new checklists only. An item added while a stage is open must also be ticked on that stage before submit, since the completeness check reads the live list.
### Security settings
Stored as the `security` settings row over the environment defaults. A value out of its bounds is refused (422 `bad_settings`), and a stored value out of bounds is ignored. The keys and bounds are in [SECURITY.md](SECURITY.md), "Security settings (phase 9)". A save applies in the app within 30 seconds. The PIN and step-up failure limits (5 and 3) stay environment settings.
### Access log, presence and the stamp check
- The access log shows every person's sign-in attempts (successful and failed) and their `auth.*`, `pin.*`, `device.*` and `sig.*` audit rows, as the Account access log shows a person their own. The address filter takes an address or a range. Request bodies and secrets are never in it.
- Who is in shows live sessions (not revoked, not expired) seen within the idle minutes that apply to each. The screen and packet come from the event stream, matched by person.
- The stamp check never trusts the text. It finds the 16-character signature, looks it up in `print_log`, recomputes it with the seal key from the logged row and compares the text with the logged stamp line. It answers genuine, does not verify (the signature or the text differs from the log), or not found.
### Notifications
- An Admin edits every rule. Recipients are any AD group under the base DN, a user, a mailbox, or (for the department events, `co_waiting` and `qc_reject`) the Supervisor group of the event's department. At most 50 recipients and 200 exclusions per rule.
- An Admin may leave people out of a rule (an exclusion), except on the security event types: `security_alert`, `signin_burst` and `integrity_failure`. An exclusion on those is 422. Nobody can be left out of them (open question 46).
- A test send goes at once, from the app, even on a stack in dry run. With an address it goes only there.
- Recipients are resolved when a message goes, not when the rule is saved: group members (nested, enabled accounts with mail), a user's address while the user is active, a mailbox as typed. An exclusion drops the person by user and by their address.
### Account notifications
| Endpoint | Who | What |
|---|---|---|
| `GET /api/account/notifications` | Any signed-in, unlocked session, PIN included | Each enabled event type: whether it reaches the person and why (`user`, `mailbox`, `group <name>`, `Supervisor group of <code>`, `left out by an Admin`, `directory unavailable`), and whether they opted out or may |
| `PUT /api/account/notifications/{event_type}` | The person, in a password session | `{opt_out}`. Writes or removes their own exclusion. 403 for a security event type. Audit `account.notification_opt_out` when something changed |
A person's own opt-out is kept when an Admin saves the rule. An Admin's exclusion is not removed by the person.
### Security events
The three security event types (`security_alert`, `signin_burst`, `integrity_failure`) have no opt-out and no exclusion. An Admin can still turn the rule off or change its recipients, on the record.
- `security_alert`: an account cooldown started, a PIN disabled after five wrong attempts, or a session ended after three wrong PINs or passwords at unlock or signature. One per account or person per 15 minutes.
- `signin_burst`: one address over its failure limit, or failed sign-ins across accounts in 5 minutes at or over `signin_burst_threshold` (default 20). One per address, or one for the burst, per 30 minutes.
- `integrity_failure`: the integrity job found a problem. Cleared by a clean run.
### Audit rows added in phase 9
- `policy.denied`: a 403 after a person was known. Method, path and code only, never a body or query string. One per person, method, path and code per minute.
- `auth.lockout`: an account went into its cooldown. `auth.signin_burst`: an address over its limit, or a burst across accounts.
- `auth.session_ended` with `{reason, by}` for every session the reconcile or the role refresh ends (`ad_disabled`, `ad_gone`, `roles_lost`).
- The `admin.*` rows in the table above, `ad.refresh_requested` and `account.notification_opt_out`.
### Tightenings against the old build
| What the old build allowed | What the rebuild does | Why | Can be vetoed |
|---|---|---|---|
| The Admin page added a department with any typed code, shop or support, and changed a department's stations as a comma list, so a station could disappear. | Code and short name are checked and never change. Only shop departments are added. Stations are never deleted, only hidden. The three AD group names are shown before the save. | The code and short name are in the AD group names, and limits and packets point at stations. | Yes |
| A welder stamp was set in the Admin user sheet, checked in the browser, 1 to 10 characters, not unique. | The stamp is checked on the server, 1 to 12 characters, unique among users. | Two welders with one stamp break the weld record. | Yes |
| Users, levels and departments were edited in the Admin page. | Roles come only from AD groups. The Admin edits only the profile (stamp, profile active, station limits). Turning a profile off hides the person from pickers and changes no right. | AD is the one place access is granted. | No |
| The security alerts panel and the integrity check ran in IT's browser when Admin was opened. | Both run on the server: alerts from the audit and the sign-in attempts, the integrity check every night in the worker, with an email alert. | A check that runs only when someone looks finds problems late. | No |
| Nothing was emailed. | Events go by email through app-03 to rules an Admin sets. Security events cannot be turned off by the person or excluded by an Admin. | A security alert that can be muted is not one. | Yes |
| The access log export ran in the browser. | The CSV is built on the server with the formula guard and logged in the print log and the audit. | The browser alone could skip the log. | No |
| A stamped copy could be checked only by eye. | The stamp check recomputes the signature with the seal key and compares the text with the print log. | A copy is genuine only if the server says so. | No |
## Worker writes (phase 3)
The worker has no session. It writes through the same policy as a person, as the system principal `Principal(user_id=None, system="visual-feed")`. Each write locks the packet row (`FOR UPDATE SKIP LOCKED`; a row locked by someone else is left for the next run), builds the packet state, runs `policy.check()`, makes its change, bumps `version`, and writes a `packet_log` row (by nobody, by name "VISUAL feed", role `system`), an audit row (`packet.<action>`, role `system`) and a change event. See [FEED.md](FEED.md) for when each write happens.
+19 -4
View File
@@ -1,6 +1,6 @@
# Packet Station schema
Status: phases 1 to 4 built (identity, organisation, projects, packets, change orders, documents, jobs, VISUAL and drawing caches, the warehouse columns in use, the `notification_events` placeholder). Phase 5 adds the shop columns, the checklist templates and the clock rules (migration 0006). Phase 6 adds the Q.C. columns, the Q.C. check items, the Q.C. folder metadata, the ITP header, line and block details and the ITP log (migration 0007). Phase 7 adds the print log and the data book on the seal (migration 0008). Phase 8 adds no table and no migration: the PM report exports use new `print_log` kinds and presence uses two new session events. Phase 9 adds the notification rules and delivery. The DDL is in `server/packetstation/migrations/`; this file explains it. See [PLAN.md](PLAN.md), "Schema".
Status: phases 1 to 4 built (identity, organisation, projects, packets, change orders, documents, jobs, VISUAL and drawing caches, the warehouse columns in use, the `notification_events` placeholder). Phase 5 adds the shop columns, the checklist templates and the clock rules (migration 0006). Phase 6 adds the Q.C. columns, the Q.C. check items, the Q.C. folder metadata, the ITP header, line and block details and the ITP log (migration 0007). Phase 7 adds the print log and the data book on the seal (migration 0008). Phase 8 adds no table and no migration: the PM report exports use new `print_log` kinds and presence uses two new session events. Phase 9 adds the notification rules and delivery, the integrity findings and the Admin profile switch (migration 0010). The DDL is in `server/packetstation/migrations/`; this file explains it. See [PLAN.md](PLAN.md), "Schema".
## Conventions
@@ -339,7 +339,7 @@ A unit folder shows its own current rows and inherits the project's current rows
These tables have the `ps_append_only` row and TRUNCATE triggers, and `ps_app` holds only SELECT and INSERT on them:
`audit_events`, `signin_attempts`, `signatures`, `project_renumbers`, `short_receipts`, `packet_transitions`, `qc_history`, `packet_log`, `itp_rejects`, `itp_log` (0007), `print_log` (0008), `co_verifications`.
`audit_events`, `signin_attempts`, `signatures`, `project_renumbers`, `short_receipts`, `packet_transitions`, `qc_history`, `packet_log`, `itp_rejects`, `itp_log` (0007), `print_log` (0008), `notifications_sent` and `integrity_findings` (0010), `co_verifications`.
The app role is refused by privilege first. The migrate role owns the tables, so the trigger refuses it. The views `v_units` and `v_open_cos` are SELECT only for `ps_app`.
@@ -352,7 +352,7 @@ Phase 3, migration 0004:
- `job_requests`: one row per "Run now". `job`, `requested_at`, `requested_by`, `note`, `claimed_run_id` and `claimed_at` (set by the run that took it). Index on `job` where `claimed_at` is null. The app inserts the row and NOTIFYs `ps_job_request` in the same transaction.
- `job_runs`: one row per run. `job`, `trigger` (schedule, request, chain, cli), `requested_by` uuid[] (the requests claimed), `started_at`, `finished_at`, `status` (running, ok, degraded, failed, skipped, abandoned), `counts` jsonb, `errors` jsonb (a list of redacted strings), `build_version`, `visual_target` (`"<server> / <database>"`), `host`. Index on (`job`, `id DESC`).
- `job_schedule`: one row per job (`job` PK). `next_run_at`, `interval_seconds`, `paused`, `updated_at`. Written by the worker after each run for the Admin tab. Seeded for `visual-feed`, `drawing-lookup` and `housekeeping`.
- `job_schedule`: one row per job (`job` PK). `next_run_at`, `interval_seconds`, `paused`, `updated_at`. Written by the worker after each run for the Admin tab. Seeded for `visual-feed`, `drawing-lookup` and `housekeeping`; 0010 adds `ad-reconcile`, `session-refresh`, `integrity`, `backup-check`, `notify-send` and `notify-digest`.
- `worker_heartbeat`: a single row (`id` = 1 by CHECK). `at`, `build`, `host`, `pid`, `running` text[] (the jobs running now). Upserted every 30 seconds.
## Notifications (phases 4 and 9)
@@ -361,7 +361,22 @@ Phase 4, migration 0005:
- `notification_events`: one row per alert. `id` bigserial, `kind` (text, for example `assets_quota`), `severity` (`info`, `warning` or `critical` by CHECK, default `warning`), `subject` (what the alert is about, for example `assets`), `detail` jsonb (for the quota alert, the volume usage: `total`, `used`, `free`, `pct`, `warn`), `created_at`, `resolved_at` (set when the condition clears), `acknowledged_at` and `acknowledged_by` (an Admin saw it). The unique index `notification_events_open` on (`kind`, `coalesce(subject, '')`) where `resolved_at` is null keeps one open alert per kind and subject, so a check that runs again does not stack rows. The table is not append-only: the app sets `resolved_at` and the acknowledgement. 0005 also grants `ps_app` USAGE and SELECT on every sequence in the schema.
Phase 9 builds the delivery on this table: rules, recipients, exclusions, outbox, sent log, state.
Phase 9, migration 0010, builds the delivery on this table:
- `notification_events` gains `event_type` (the catalog key; `kind` holds the same text), `title` (the mail subject after the prefix), `body`, `dept_code` (for the department events, so the department's Supervisor group can be the recipient), `raised_count` and `last_raised_at` (a condition raised again while open only counts), `recovery_of` (the row a "Cleared" notice follows), `dispatched_at` and `dispatch_result` (set by `notify-send`: sent, dry_run, failed, no_recipients, queued for the digest, rule_off, or no_rule; `directory_retry:N` marks an event left pending while AD was down, and the third failure dispatches it failed). The phase 4 quota rows were marked dispatched with `before_phase_9`, so they are never mailed late. A standing condition (feed failed, backup stale, quota, integrity failure, group missing, drawing failing, change order waiting) stays open until it clears, and the open index is the dedupe. A one-shot event (security alert, sign-in burst, reject, reconcile report, version deployed) is inserted already resolved. The plan's separate `notification_state` table is not needed: the open row is the state.
- `notification_rules`: one row per event type (`event_type` PK). `enabled`, `delivery` (enum `notify_delivery`: immediate or digest), `digest_time` (site time, default 07:00), `param` (N hours for drawing_failing, N days for co_waiting), `version` (compare and set for the Admin tab), `updated_at`, `updated_by`. The code seeds a missing rule enabled, immediate, with PS-Admins as its recipient, and never changes a rule that exists.
- `notification_recipients`: `event_type`, `kind` (enum `notify_recipient_kind`: group, user, mailbox, dept_supervisor), `group_dn` and `group_name` (any AD group under LDAP_BASE_DN), `user_id`, `address`. CHECKs make each kind carry its own column. Groups are resolved to their members' mail when a message goes, not stored.
- `notification_exclusions`: (`event_type`, `user_id`) PK, `self` (set by the person on Account settings; never allowed on the security event types), `created_at`, `created_by`.
- `notification_outbox`: events waiting for their rule's digest. `event_id`, `event_type`, `queued_at`, `sent_at`, `sent_id` (the `notifications_sent` row).
- `notifications_sent`: append-only. One row per message: `at`, `event_type` (`digest` for a digest), `event_ids`, `digest`, `test`, `subject`, `recipients` jsonb (`[{address, user_id, why}]`, as resolved), `result` (sent, dry_run, failed, no_recipients), `error`, `requested_by` (for a test send).
## Admin and integrity (phase 9, migration 0010)
- `users.profile_active`: the Admin's switch on a person's profile, default true. `users.active` stays the reconcile's (disabled or gone in AD). An inactive profile drops out of the welder picker and the worker filter. It does not change what the person may do.
- `departments.created_at` and `created_by`: set when an Admin adds a department in the app. Seeded departments have neither. `code` and `slug` stay immutable by trigger.
- `assets.hash_checked_at` and `hash_ok`: when the integrity job last rehashed the file and whether it matched `sha256`.
- `integrity_findings`: append-only. One row per thing the integrity job found wrong: `run_id` (no foreign key, since housekeeping prunes `job_runs`), `at`, `kind` (seal, asset_hash, asset_missing, blob_orphan, audit_chain), `subject` (the packet, asset, file path or audit id), `detail` jsonb.
- Settings rows used by phase 9: `security` (the Admin's overrides of the session, lockout and rate-limit settings; missing keys fall back to the environment), `checklist_templates` (now edited on the Admin Checklists tab), `notify.last_build` (the build the worker last announced).
## VISUAL and drawing caches (phase 3)
+102 -15
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 8. 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). Phase 6 adds the Q.C. rules (server gates on accept, block sign and sign-off, NCR numbers checked by pattern, Q.C. documents never deleted, the append-only ITP log, server-verified signatures on every Q.C. and ITP sign-off). No requirement ID closes in phase 6. Phase 7 adds the close and file controls (the HMAC seal with the data book filed on it, the controlled-copy and print stamps signed with the seal key, `print_log` and audit rows for every print and export, the CSV formula guard, PDF metadata, escaping in the PDFs). Phase 7 closes M2, S6, S7 and S8. Phase 8 adds the Project Management reads and exports, the server-side find bar filters and the presence rules (display name and role only, locked and revoked sessions dropped). No requirement ID closes in phase 8. Every other control names the phase that builds it.
Status: phase 9. 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). Phase 6 adds the Q.C. rules (server gates on accept, block sign and sign-off, NCR numbers checked by pattern, Q.C. documents never deleted, the append-only ITP log, server-verified signatures on every Q.C. and ITP sign-off). No requirement ID closes in phase 6. Phase 7 adds the close and file controls (the HMAC seal with the data book filed on it, the controlled-copy and print stamps signed with the seal key, `print_log` and audit rows for every print and export, the CSV formula guard, PDF metadata, escaping in the PDFs). Phase 7 closes M2, S6, S7 and S8. Phase 8 adds the Project Management reads and exports, the server-side find bar filters and the presence rules (display name and role only, locked and revoked sessions dropped). No requirement ID closes in phase 8. Phase 9 adds the nightly AD reconcile and the 15-minute role refresh, the integrity job with its alert, email notifications over verified STARTTLS with security events nobody can mute, the security alerts and the Security tab settings, the Admin tabs (every change audited), nightly backups with a rehearsed restore, and dependency scanning in CI. Phase 9 closes the app part of I6, M3, O1, O2 and S3. Every other control names the phase that builds it.
Contents:
@@ -22,7 +22,7 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro
| 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. Built. | 2, 7 |
| 7 | Record integrity check | A worker job (seals, asset hashes, audit hash chain) with an alert. | 9 |
| 7 | Record integrity check | A worker job every night (seals, asset hashes, missing and orphan files, the audit hash chain) with an email alert. Built. | 9 |
| 8 | Self-hosted libraries | Kept: no CDN. The CSP allows nothing external. | 0 |
| 9 | Output escaping | React escapes output by default. The PDF builders clean and escape every text on the server. Built. | 1, 7 |
| 10 | Central permission policy | Server policy, checked on every write, including writes that do not come from the screens. Built. | 2 |
@@ -31,7 +31,7 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro
| 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 |
| 16 | Security alerts and automatic integrity check | The Admin Security tab (alerts from the audit and the sign-in attempts, the last integrity run), the worker jobs and the Notifications tab (email through app-03). Built. | 9 |
| 17 | Controlled-copy stamp on data books | Applied to server-built PDFs and signed with the seal key. Built, phase 7. | 7 |
| 18 | CSV formula guard | Applied to every server export. Built, phase 7. | 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) |
@@ -42,11 +42,11 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro
### 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. Admin and Clerical sessions wait 60 idle minutes (issue 45). All are settings.
- Password sessions: idle sign-out after 15 minutes, absolute 12 hours. PIN shop sessions: absolute 10 hours, idle 15 minutes. Admin and Clerical sessions wait 60 idle minutes (issue 45). All are settings, and from phase 9 an Admin can change them within bounds on the Security tab (see "Security 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.
- Admin is a password session only. A PIN session cannot open Admin or change Account settings.
- 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.
- 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, by the `session-refresh` job and by the nightly AD reconcile (see "AD reconcile and role 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; 60 for Admin and Clerical, issue 45), 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.
@@ -64,7 +64,7 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro
- 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 domain's own lockout (20 attempts in 5 minutes) stays the last line. From phase 9 the limits can be changed on the Security tab, and the per-account limit is capped at 15, always under AD's 20.
- 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.
@@ -90,7 +90,7 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro
- 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. IT decided on 2026-10-18 (Gitea issue 19) to leave the scan off in both stacks: every workstation that uploads runs Microsoft Defender. Drawings the worker fetches from M-Files are not scanned on the way in. They come from PESCO's own vault.
- 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 to PS-Admins (Gitea issue 20).
- 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`). From phase 9 the alert and its "Cleared" notice are mailed through the `assets_quota` rule, to PS-Admins by default (Gitea issue 20).
### Warehouse rules (phase 4)
@@ -136,7 +136,7 @@ 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.
- 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. Phase 9 adds `auth.lockout`, `auth.signin_burst`, `policy.denied`, `auth.session_ended` with `by` for sessions the reconcile and role refresh end, and the `admin.*` rows for every Admin change. 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.
@@ -150,14 +150,14 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro
- The data book is built on the server inside the filing transaction and stored as an asset on the seal. A data book that cannot be built fails the filing; nothing is filed half way.
- Filing while the feed shows a work order open in VISUAL needs a second confirmation. The work orders are kept on the seal and in the packet log.
- A reopen by Admin needs a reason and a signature. It marks the seal broken with who, when and why, and keeps the row and its data book. Filing again writes a new seal beside it.
- The integrity job checks seals and asset hashes (phase 9).
- The integrity job re-verifies every seal of a filed packet every night (see "Integrity job").
### PDFs, prints and exports (phase 7)
- Every PDF and the CSV are built on the server. The browser only opens the URL. Reads need a signed-in session; every role that can open the packet may read them, PM included.
- Controlled-copy stamp (S7): every exported page carries `Controlled copy - exported by Name (emp) <UTC time>`, the seal's first 12 hex or `not yet filed`, and ` - stamp <sig>`. The filed data book is the stored copy, served as it was built.
- Printed-copy stamp (S8): every printed page carries `PRINTED COPY - <what> - printed by Name (emp) <UTC time> - <project> Lot <unit> dwg <drawing> R<rev> - verify revision before use` and the stamp signature, in a white box at the bottom of the page.
- Stamp signature: 16 hex of HMAC-SHA256 with the seal key over the packet, the kind (a printed copy signs `print:<kind>`, so a print and an export of the same thing in the same minute never share a signature), the seal hash, the user and the time. A copy can be checked against the server later with `verify_stamp`; the Admin screen for it comes in phase 9.
- Stamp signature: 16 hex of HMAC-SHA256 with the seal key over the packet, the kind (a printed copy signs `print:<kind>`, so a print and an export of the same thing in the same minute never share a signature), the seal hash, the user and the time. A copy can be checked against the server with `verify_stamp`; from phase 9 the Admin Stamp check tab does it from a pasted line or a PDF.
- Logging (S7, S8): every print and export writes one `print_log` row (who, when, session, packet, unit or ITP, kind, what, pages, the stamp line, the stamp signature, the client address) and one audit row (`print` or `export`). `print_log` is append-only: the app role has SELECT and INSERT only, and a trigger refuses updates, deletes and truncate.
- CSV formula guard (S6): a cell that starts with `=`, `+`, `-`, `@`, a tab or a CR gets a leading `'`. A cell with a quote, a comma or a line break is quoted with the quotes doubled.
- PDF metadata: title, subject and keywords name the packet and the stamp signature; Author is PESCO and Producer is Packet Station.
@@ -177,6 +177,93 @@ The M: drive build had 20 layers of protection, most of them enforced in the bro
- A locked session drops out of presence at once, and comes back on unlock. A revoked session drops out at once and its stream ends. A closed tab drops out when the stream ends, within the 25-second ping cycle.
- Saved filters live in the browser's localStorage (`ps.flt`, `ps.proj`, `ps.pmtab`, `ps.pmper`, `ps.motion`). They hold filter choices only. The search text is never saved. Nothing in them is trusted by the server.
### AD reconcile and role refresh (phase 9)
- `ad-reconcile` runs every night at 01:30 site time, and when an Admin presses "Refresh from AD now" (a job request; the button answers 202 at once). It lists the PacketStation OU, reports mapped groups missing from it and extra groups in it, reads every mapped group's members through `LDAP_MATCHING_RULE_IN_CHAIN` (nested) in pages of 500, and upserts users on their objectGUID. AD is read first, then everything is written in one transaction.
- A user disabled in AD, or gone from AD, is marked inactive and every session they have is ended (`ad_disabled`, `ad_gone`). A user still in AD but in no mapped group loses their roles and their sessions (`roles_lost`). Each ended session writes an `auth.session_ended` audit row with the reason and `by: ad-reconcile`.
- Missing and duplicate employee IDs are flagged on the user (`directory_flags`) and listed on the Admin Profiles tab. A PIN sign-in by a duplicate employee ID is refused, as in phase 1.
- Safety guard: the reconcile refuses and changes nothing when no mapped group is found in the OU (a wrong `LDAP_GROUP_BASE_DN`) or when every mapped group is empty while active users exist (PLAN.md open question 44). AD unreachable fails the run and changes nothing.
- `session-refresh` runs every 15 minutes (the `roles_refresh_minutes` setting, 5 to 60). For every user with a live session it reads the account by objectGUID and its groups again, rebuilds the roles and each session's role snapshot, and ends the sessions of anyone disabled, gone or left with no role. AD unreachable makes the run degraded and ends nothing.
- A request still re-reads the roles when its session's snapshot is older than the refresh interval, as in phase 1. Between the two, a person removed from a group loses the role within 15 minutes, and a person disabled in AD is signed out within 15 minutes.
- Together these close the app's part of I6 (joiner, mover, leaver). The quarterly access review stays IT's.
### Integrity job (phase 9)
- `integrity` runs every night at 04:00 site time. It re-verifies every unbroken seal of a filed packet with the seal key (a seal under another key id is a finding), rehashes asset files (uploads of the last 7 days every night, every asset on Sundays) and records `hash_checked_at` and `hash_ok`, finds asset rows with no file and files under the assets volume with no asset row (older than an hour; reported, never deleted), and walks the audit hash chain.
- Each finding is a row in the append-only `integrity_findings` (at most 500 per kind per run, the full count in the run). Any finding raises the `integrity_failure` alert, a security event that nobody can opt out of. A clean run clears it with a "Cleared" message. The Admin Security tab shows the last run and its findings.
- A missing seal key makes the run degraded and leaves the seals unchecked, never "clean".
- This closes M3 (tamper detection on records, files and the audit).
### Notifications (phase 9)
- Mail goes through `SMTP_HOST` (app-03.pesco.com, port 25) with STARTTLS, verified against `SMTP_CA_BUNDLE` (the PESCO CA). There is no plain-text fallback: a relay without STARTTLS, a TLS error or a wrong certificate fails the message, and the failure is recorded. There is no SMTP login.
- Each message is addressed To `SMTP_FROM` (`[email protected]`). The recipients are in the envelope only, so nobody sees the list.
- Recipients are resolved when the message goes. Group members must be enabled accounts with a mail address. An inactive user gets nothing. Every message writes one append-only `notifications_sent` row with each address, the user and why it was chosen.
- Bodies are plain text from the server. No link carries a token or a session. Raising an event never fails the action that raised it.
- The test stack runs with `NOTIFY_DRY_RUN=true`: messages are recorded as `dry_run` and not sent. A test send from the Admin tab always sends. Subjects start `[Packet Station]` on production and `[Packet Station TEST]` on any other stack.
- The security event types (`security_alert`, `signin_burst`, `integrity_failure`) cannot be opted out of on Account settings, and an Admin cannot exclude anyone from them.
### Security alerts (phase 9)
The Admin Security tab lists, for the last 24 hours by default (up to 168):
| Kind | From |
|---|---|
| Account cooldowns (`lockout`) | `auth.lockout` audit rows, written when an account goes over its failure limit |
| Wrong PINs (`pin_failures`) | Three or more wrong PINs for one person in the window, at any step |
| Wrong unlock or signature (`stepup_wrong_pin`) | A wrong PIN or password at unlock or at a signature |
| Denied actions (`denied`) | `policy.denied` audit rows: any 403 after the person was known (method, path and code; never a body) |
| Sign-in bursts (`signin_burst`) | `auth.signin_burst` audit rows: an address over its limit, or failed sign-ins across accounts in 5 minutes at or over the burst threshold |
| Admin changes (`admin_change`) | `admin.*` audit rows |
| IT edits (`it_edit`) | Audit rows with an IT reason |
| Integrity (`integrity`) | `integrity_findings` rows |
Account cooldowns, a PIN disabled, a session ended after wrong PINs and sign-in bursts are also mailed (`security_alert`, `signin_burst`).
### Security settings (phase 9)
The Admin Security tab overrides these environment defaults, stored in the `security` settings row with compare and set, audited with before and after. A value out of bounds is refused; a stored value out of bounds is ignored and the default applies. The app reads them per request, cached for at most 30 seconds.
| Setting | Default | Bounds |
|---|---|---|
| `session_lock_minutes` (lock after idle) | 3 | 1 to 30 |
| `session_idle_minutes` (sign out after idle) | 15 | 1 to 60 |
| `session_desk_minutes` (Admin and Clerical idle) | 60 | 5 to 240 |
| `session_password_hours` | 12 | 1 to 24 |
| `session_pin_hours` | 10 | 1 to 24 |
| `roles_refresh_minutes` | 15 | 5 to 60 |
| `signin_account_limit` (failed sign-ins per account before a cooldown) | 5 | 1 to 15 |
| `signin_account_window_minutes` | 5 | 1 to 60 |
| `signin_account_cooldown_minutes` | 10 | 0 to 120 |
| `signin_ip_limit` (failures per address) | 30 | 5 to 200 |
| `signin_ip_window_minutes` | 5 | 1 to 60 |
| `signin_burst_threshold` (failures across accounts in 5 minutes that raise an alert) | 20 | 5 to 200 |
The account limit is capped at 15 so the app always stops before AD's own lockout at 20 attempts in 5 minutes. The PIN limit (5) and the step-up limit (3) stay environment settings.
### Admin (phase 9)
- Every Admin endpoint needs PS-Admins in a password session. Every Admin change is audited with before and after. Tests try every endpoint as every role.
- Roles still come only from AD. The Admin edits departments (name, stations), profiles (stamp, profile active, station limits), checklists, the security settings and the notification rules. None of these grants a role. Turning a profile off hides the person from pickers and changes no right.
- Department codes and short names never change (a trigger, and 422 on the API). Stations are never deleted.
- The access log CSV has the formula guard, at most 50,000 rows, and is logged in `print_log` and the audit.
- The stamp check recomputes the stamp signature with the seal key and compares the text with the print log (S7, S8). It reads a pasted line or a PDF of up to 25 MB.
### Backups (phase 9)
- `scripts/backup/ps-backup.sh` runs as root from a systemd timer at 02:00 Denver time, an hour before the Proxmox Backup Server run of the whole VM. It dumps each stack with `pg_dump -Fc` inside the postgres container over the local socket (no password read or passed), checks each dump with `pg_restore --list`, keeps 7, and writes `last-success` or `last-failure`.
- The roles are dumped with `--no-role-passwords`. A password hash in a file is an offline cracking target, and the file lands in PBS. The restore script refuses a globals file that holds a password hash. The role passwords stay in the stack secrets.
- Dumps and globals are mode 0600. The production folder is `root:10001` 0750, so only root and the worker (read-only mount) see it.
- The worker's `backup-check` job raises `backup_stale` when the newest dump is missing, empty, older than 26 hours, or followed by a failure.
- The restore was rehearsed on the test stack on 2026-10-21 into a separate database: every table's row count matched and all 189 asset files matched their recorded sha256 ([DEPLOY.md](DEPLOY.md), "Restore rehearsal 2026-10-21"). This closes O1 and O2 for the first rehearsal. The quarterly restore test is IT's.
### Dependency scanning (phase 9)
- Every CI run scans the dependencies. `pip-audit` (pinned, in its own virtual environment) checks every pin in `server/requirements.txt` without resolving and fails on any known vulnerability, which is stricter than high or critical. `npm audit --audit-level=high` fails on a high or critical finding in the frontend, dev dependencies included.
- `visualkit` and `mfileskit` come from PESCO's own index and are not audited. Their dependencies that we install are pinned in `requirements.txt` and are.
- No finding is ignored. If one ever has no fix, the way out is `--ignore-vuln <ID>` with a comment and a date.
- The first scan (2026-10-21) found `pypdf` 6.21.0 (GHSA-5hx5-vpf9-5wcv) and `certifi` 2026.7.22 (PYSEC-2026-4320). Both were bumped. This closes S3.
### Headers (phase 0)
- The app sets HSTS, CSP and the other security headers itself. NPM's own HSTS is off.
@@ -203,7 +290,7 @@ The IDs come from Part 3 of the old SECURITY.md. The status follows the approved
| I3 | Closed for "PIN on the server", phase 1; device certificate deferred |
| I4 | Closed, phase 1 |
| I5 | Superseded (no in-app admin grant); separate IT admin accounts remain IT's |
| I6 | Largely closed, phase 9 (groups plus reconcile); quarterly review IT's |
| I6 | App part closed, phase 9 (roles only from AD groups; the nightly `ad-reconcile` marks disabled and gone users inactive, ends their sessions and flags employee ID problems; `session-refresh` re-reads every live session's groups and account state every 15 minutes and ends the sessions of anyone disabled, gone or left with no role); quarterly access review IT's |
| I7 | Closed, phase 1 |
| I8 | App part closed, phase 1; kiosk lock policy IT's (Intune or GPO) |
| A1, A2, A3 | Closed, phase 2 (server policy on every call, workflow steps as compare-and-set, validation and limits on the server) |
@@ -214,17 +301,17 @@ The IDs come from Part 3 of the old SECURITY.md. The status follows the approved
| D6, D7 | Deferred (retention period and customer copy need a decision from Seth and Q.C.) |
| M1 | Closed, phase 2 (append-only audit and packet log written only by the server; every packet write carries both) |
| M2 | Closed, phase 7 (filing seal is HMAC-SHA256 with the server seal key from a Docker secret; a reopen keeps the broken seal and its data book) |
| M3 | Closed, phase 9 |
| M3 | Closed, phase 9 (the nightly `integrity` job re-verifies seals, rehashes asset files, finds missing and orphan files and walks the audit hash chain; findings are kept in the append-only `integrity_findings` and raise `integrity_failure`, which nobody can opt out of) |
| M4, M5 | Deferred (logs to stdout now; SIEM shipping is IT's) |
| S1 | Closed, phase 0, tuned phase 10 |
| S2 | Closed, phase 1 |
| S3 | Closed, phase 9 (CI scanning) |
| S3 | Closed, phase 9 (CI scanning on every run: `pip-audit` over every pin in `server/requirements.txt` fails on any known vulnerability; `npm audit --audit-level=high` fails on a high or critical one; the first scan bumped pypdf and certifi) |
| S4 | Deferred to IT (pentest before go-live) |
| S5 | Closed, phase 2 (React escapes output; server messages are plain text); phase 7 adds escaping in the PDF builders |
| S6 | Closed, phase 7 (the CSV formula guard on every export) |
| S7 | Closed, phase 7 (the controlled-copy stamp on every exported PDF, signed with the seal key; PDF metadata; every export logged in `print_log` and the audit) |
| S8 | Closed, phase 7 (the PRINTED COPY stamp with "verify revision before use" on every printed page, signed with the seal key; every print logged in `print_log` and the audit). PLAN.md lists S8 under phase 5, but the print stamps come with the PDFs in phase 7, so S8 closes there. |
| O1, O2 | Closed, phase 9 (pg_dump timer plus PBS; one rehearsed restore); quarterly restore IT's |
| O1, O2 | Closed, phase 9 (nightly `pg_dump` of each stack at 02:00 Denver time from a systemd timer, checked with `pg_restore --list`, 7 kept, before the 03:00 PBS run of the VM; the `backup-check` job alerts on a missing, empty, stale or failed backup; restore rehearsed on the test stack on 2026-10-21 with every row count and asset hash matching); quarterly restore test IT's |
| O3, O4 | Closed, phase 10 |
| O5 | Superseded (no migration; M: retired read-only) |
@@ -232,7 +319,7 @@ 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.
- **I6** (joiner, mover, leaver) is closed for the app's part by the AD groups, the nightly reconcile and the 15-minute role refresh. 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.
+409
View File
@@ -0,0 +1,409 @@
// Phase 9 Admin: departments, profiles, checklists, security, access log, presence, stamp check, directory flags,
// the AD reconcile, and notifications. Field names follow the phase 9 design contract exactly.
import type { AdminUser } from "../auth/types";
import { api } from "./client";
import { qs } from "./query";
const enc = encodeURIComponent;
/* ---------- Departments ---------- */
export interface DeptGroup {
name: string;
role: string;
/** null when AD could not be read. */
in_ou: boolean | null;
}
export interface DeptStation {
id: number;
name: string;
sort: number;
active: boolean;
}
export interface AdminDepartment {
code: string;
slug: string;
name: string;
kind: "shop" | "support";
sort: number;
active: boolean;
weld: boolean;
supervisor_group: boolean;
created_at: string | null;
groups: DeptGroup[];
stations: DeptStation[];
}
export interface DeptPatch {
name?: string;
active?: boolean;
stations?: Array<{ id?: number; name: string; sort: number; active: boolean }>;
}
export interface DeptNew {
code: string;
slug: string;
name: string;
kind: "shop" | "support";
weld: boolean;
supervisor_group: boolean;
stations: string[];
}
export interface DeptAdded {
department: AdminDepartment;
groups_to_create: string[];
}
/* ---------- Profiles ---------- */
export interface DirectoryFlags {
emp_missing?: boolean;
emp_duplicate?: boolean;
gone?: boolean;
disabled?: boolean;
}
/** A row of GET /api/admin/users with the phase 9 profile fields. */
export interface ProfileUser extends AdminUser {
welder_stamp?: string | null;
profile_active?: boolean;
station_limits?: Record<string, number[]>;
directory_flags?: DirectoryFlags | null;
}
export interface ProfilePatch {
welder_stamp?: string | null;
profile_active?: boolean;
station_limits?: Record<string, number[]>;
}
export interface ImportRow {
line: number;
employee_id: string;
user_id: string | null;
display_name: string | null;
changes: Record<string, { from: unknown; to: unknown }>;
errors: string[];
}
export interface ImportAnswer {
rows: ImportRow[];
ok: number;
errors: number;
applied: boolean;
}
/* ---------- Checklists ---------- */
export interface ChecklistItem {
group: string;
key: string;
text: string;
}
export interface ChecklistLists {
weld: ChecklistItem[];
plain: ChecklistItem[];
}
export interface ChecklistsAnswer {
value: ChecklistLists;
defaults: ChecklistLists;
version: number;
}
/* ---------- Security ---------- */
export interface SecuritySettingsAnswer {
values: Record<string, number>;
defaults: Record<string, number>;
bounds: Record<string, [number, number]>;
version: number;
}
export type AlertKind =
| "lockout"
| "pin_failures"
| "denied"
| "stepup_wrong_pin"
| "admin_change"
| "it_edit"
| "integrity"
| "signin_burst";
export interface SecurityAlert {
at: string;
kind: AlertKind | string;
user_id: string | null;
display_name: string | null;
account: string | null;
ip: string | null;
detail: unknown;
}
export interface IntegrityFinding {
kind: string;
subject: string;
detail: unknown;
at: string;
}
export interface IntegrityAnswer {
run: { id: number; started_at: string | null; finished_at: string | null; status: string; counts: Record<string, unknown> | null } | null;
findings: IntegrityFinding[];
}
/* ---------- Access log ---------- */
export interface AccessLogFilters {
since?: string;
until?: string;
user_id?: string;
q?: string;
kind?: string;
ok?: "true" | "false" | "";
ip?: string;
limit?: number;
before?: string;
}
export interface AccessLogRow {
id: string | number;
at: string;
source: "attempt" | "audit";
kind: string;
ok: boolean;
account: string | null;
user_id: string | null;
display_name: string | null;
ip: string | null;
detail: unknown;
}
export interface AccessLogAnswer {
rows: AccessLogRow[];
next_before: string | null;
}
/* ---------- Presence ---------- */
export interface PresenceSession {
session_id: string;
user_id: string;
display_name: string;
kind: "password" | "pin" | string;
device_name: string | null;
ip: string | null;
created_at: string;
last_seen_at: string | null;
locked: boolean;
screen: string | null;
packet_id: number | null;
}
export interface PresenceAnswer {
at: string;
sessions: PresenceSession[];
}
/* ---------- Stamp check ---------- */
export interface StampResult {
found: boolean;
valid: boolean;
reason: string | null;
print: {
id: number;
at: string;
by_name: string | null;
kind: string;
what: string | null;
pages: number | null;
packet_id: number | null;
packet_label: string | null;
} | null;
}
/* ---------- Directory flags and the reconcile ---------- */
export interface FlagUser {
user_id: string;
sam_account: string;
display_name: string;
}
export interface DirectoryFlagsAnswer {
checked_at: string | null;
missing: FlagUser[];
duplicate: Array<{ emp_no: string; users: FlagUser[] }>;
}
export interface RefreshRequested {
requested: boolean;
pending: number;
job: string;
}
export interface ReconcileReport {
groups_missing: string[] | number;
groups_extra: string[] | number;
users_seen: number;
created: number;
updated: number;
deactivated: number;
sessions_ended: number;
emp_missing: string[] | number;
emp_duplicate: string[] | number;
}
export interface ReconcileAnswer {
run: { id: number; started_at: string | null; finished_at: string | null; status: string } | null;
report: Partial<ReconcileReport> | null;
}
/* ---------- Notifications ---------- */
export type RecipientKind = "group" | "user" | "mailbox" | "dept_supervisor";
export interface Recipient {
id?: number;
kind: RecipientKind;
group_dn?: string | null;
group_name?: string | null;
user_id?: string | null;
display_name?: string | null;
address?: string | null;
}
export interface NotifyRule {
event_type: string;
label: string;
description: string;
security: boolean;
dept_scoped: boolean;
param: { name: "hours" | "days" | string; value: number; default: number } | null;
enabled: boolean;
delivery: "immediate" | "digest";
digest_time: string;
recipients: Recipient[];
exclusions: Array<{ user_id: string; display_name: string | null; self: boolean }>;
version: number;
last_sent: { at: string; result: string } | null;
}
export interface NotifyAnswer {
dry_run: boolean;
smtp: { host: string | null; port: number | null; from: string | null; starttls: boolean };
subject_prefix: string;
rules: NotifyRule[];
}
export interface RulePut {
enabled: boolean;
delivery: "immediate" | "digest";
digest_time: string;
param: number | null;
recipients: Array<{ kind: RecipientKind; group_dn?: string; group_name?: string; user_id?: string; address?: string }>;
exclusions: string[];
version: number;
}
export interface AdGroupHit {
dn: string;
name: string;
description: string | null;
}
export interface TestSendAnswer {
result: string;
subject: string;
recipients: Array<{ address: string; user_id?: string | null; why?: string }> | string[];
error: string | null;
sent_id: number | null;
}
export interface SentRow {
id: number;
at: string;
event_type: string;
digest: boolean;
test: boolean;
subject: string;
recipients: Array<{ address: string; user_id?: string | null; why?: string }>;
result: string;
error: string | null;
}
export interface SentAnswer {
rows: SentRow[];
/** The id to page from. */
next_before: string | number | null;
}
export interface MyNotification {
event_type: string;
label: string;
security: boolean;
delivery: "immediate" | "digest";
reaches: boolean;
why: string[];
opted_out: boolean;
can_opt_out: boolean;
}
/* ---------- Calls ---------- */
const A = "/api/admin";
export const admin9Api = {
departments: (signal?: AbortSignal) => api.get<AdminDepartment[]>(`${A}/departments`, signal),
patchDepartment: (code: string, patch: DeptPatch) => api.patch<AdminDepartment>(`${A}/departments/${enc(code)}`, patch),
addDepartment: (d: DeptNew) => api.post<DeptAdded>(`${A}/departments`, d),
users: (q: string, signal?: AbortSignal) => api.get<ProfileUser[]>(`${A}/users${qs({ q })}`, signal),
patchProfile: (id: string, patch: ProfilePatch) => api.patch<ProfileUser>(`${A}/users/${enc(id)}/profile`, patch),
importProfiles: (csv: string, apply: boolean) => api.post<ImportAnswer>(`${A}/users/import`, { csv, apply }),
checklists: (signal?: AbortSignal) => api.get<ChecklistsAnswer>(`${A}/checklists`, signal),
saveChecklists: (value: ChecklistLists, version: number) => api.put<ChecklistsAnswer>(`${A}/checklists`, { value, version }),
securitySettings: (signal?: AbortSignal) => api.get<SecuritySettingsAnswer>(`${A}/security/settings`, signal),
saveSecuritySettings: (values: Record<string, number>, version: number) =>
api.put<SecuritySettingsAnswer>(`${A}/security/settings`, { values, version }),
securityAlerts: (hours: number, signal?: AbortSignal) => api.get<SecurityAlert[]>(`${A}/security/alerts${qs({ hours })}`, signal),
integrity: (signal?: AbortSignal) => api.get<IntegrityAnswer>(`${A}/security/integrity`, signal),
accessLog: (f: AccessLogFilters, signal?: AbortSignal) =>
api.get<AccessLogAnswer>(`${A}/access-log${qs({ ...f })}`, signal),
accessLogCsvUrl: (f: AccessLogFilters) => `${A}/access-log.csv${qs({ ...f, before: undefined, limit: undefined })}`,
presence: (signal?: AbortSignal) => api.get<PresenceAnswer>(`${A}/presence`, signal),
stampVerify: (text: string) => api.post<StampResult>(`${A}/stamp-verify`, { text }),
stampVerifyPdf: (file: File) => {
const fd = new FormData();
fd.append("file", file);
return api.post<{ stamps: StampResult[] }>(`${A}/stamp-verify/pdf`, fd);
},
directoryFlags: (signal?: AbortSignal) => api.get<DirectoryFlagsAnswer>(`${A}/directory-flags`, signal),
refreshAd: () => api.post<RefreshRequested>(`${A}/ad/refresh`, {}),
reconcile: (signal?: AbortSignal) => api.get<ReconcileAnswer>(`${A}/ad/reconcile`, signal),
notifications: (signal?: AbortSignal) => api.get<NotifyAnswer>(`${A}/notifications`, signal),
saveRule: (eventType: string, body: RulePut) => api.put<NotifyRule>(`${A}/notifications/${enc(eventType)}`, body),
searchGroups: (q: string, signal?: AbortSignal) => api.get<AdGroupHit[]>(`${A}/notifications/groups${qs({ q })}`, signal),
testSend: (eventType: string, to?: string) =>
api.post<TestSendAnswer>(`${A}/notifications/${enc(eventType)}/test`, to ? { to } : {}),
sent: (f: { event_type?: string | undefined; limit?: number; before?: string | number | undefined }, signal?: AbortSignal) =>
api.get<SentAnswer>(`${A}/notifications/sent${qs(f)}`, signal),
};
export const accountNotifyApi = {
list: (signal?: AbortSignal) => api.get<MyNotification[]>("/api/account/notifications", signal),
setOptOut: (eventType: string, optOut: boolean) =>
api.put<MyNotification | null>(`/api/account/notifications/${enc(eventType)}`, { opt_out: optOut }),
};
-2
View File
@@ -4,7 +4,6 @@ import type {
AccessLogRow,
AccountInfo,
AdLookup,
AdRefreshResult,
AdminDevice,
AdminUser,
AdminUserDetail,
@@ -55,7 +54,6 @@ export const adminApi = {
devices: (signal?: AbortSignal) => api.get<AdminDevice[]>("/api/admin/devices", signal),
revokeDevice: (id: string) => api.del<null>(`/api/admin/devices/${enc(id)}`),
groups: (signal?: AbortSignal) => api.get<GroupMap>("/api/admin/groups", signal),
refresh: () => api.post<AdRefreshResult>("/api/admin/ad/refresh"),
signinAttempts: (limit = 100, signal?: AbortSignal) =>
api.get<SigninAttempt[]>(`/api/admin/signin-attempts?limit=${limit}`, signal),
};
-12
View File
@@ -149,18 +149,6 @@ export interface GroupMap {
extra_in_ou: string[];
}
/** POST /api/admin/ad/refresh. */
export interface AdRefreshResult {
groups_checked: number;
groups_missing: string[];
users_seen: number;
created: number;
updated: number;
deactivated: number;
emp_missing: string[];
emp_duplicate: string[];
}
/**
* One row of GET /api/admin/signin-attempts. The contract does not list the fields.
* These are the signin_attempts columns.
@@ -1,4 +1,4 @@
import { fireEvent, screen } from "@testing-library/react";
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { pinProblem } from "../../lib/pin";
import { makeMe, renderApp } from "../../test/auth";
@@ -12,6 +12,39 @@ const ACCOUNT = {
ad_synced_at: "2026-10-16T08:00:00Z",
};
const NOTIFY = [
{
event_type: "security_alert",
label: "Security alert",
security: true,
delivery: "immediate",
reaches: true,
why: ["group PS-Admins"],
opted_out: false,
can_opt_out: false,
},
{
event_type: "co_waiting",
label: "Change order waiting",
security: false,
delivery: "digest",
reaches: true,
why: ["Supervisor group of 03", "user"],
opted_out: false,
can_opt_out: true,
},
{
event_type: "version_deployed",
label: "Version deployed",
security: false,
delivery: "immediate",
reaches: false,
why: [],
opted_out: false,
can_opt_out: true,
},
];
function routes(me = makeMe()) {
return {
"/api/auth/me": { body: me },
@@ -33,6 +66,7 @@ function routes(me = makeMe()) {
"/api/account/access-log": {
body: [{ at: "2026-10-16T08:00:00Z", action: "auth.signin", detail: { kind: "password" }, ip: "10.0.0.5", ok: true }],
},
"/api/account/notifications": { body: NOTIFY },
"/api/auth/context": { body: { device: null, stack: "test", insecure_ldap: false, pin_digits: { min: 6, max: 12 } } },
};
}
@@ -107,4 +141,42 @@ describe("AccountPage", () => {
expect(await screen.findByText("Weld Parts Stall 3")).toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/account/device")).toEqual([{ name: "Weld Parts Stall 3" }]);
});
it("shows what mail reaches me and why, and opts out of one", async () => {
let opted = false;
const fetch = mockFetch({
...routes(),
"/api/account/notifications": () => ({ body: NOTIFY.map((n) => (n.event_type === "co_waiting" ? { ...n, opted_out: opted } : n)) }),
"PUT /api/account/notifications/co_waiting": (req) => {
opted = (req.body as { opt_out: boolean }).opt_out;
return { body: null, status: 204 };
},
});
renderApp("/account");
const table = await screen.findByRole("table", { name: "Notifications" });
const sec = (await within(table).findByText("Security alert")).closest("tr") as HTMLElement;
expect(within(sec).getByText("Security events cannot be turned off.")).toBeInTheDocument();
expect(within(sec).queryByRole("checkbox")).not.toBeInTheDocument();
expect(within(sec).getByText("group PS-Admins")).toBeInTheDocument();
const co = within(table).getByText("Change order waiting").closest("tr") as HTMLElement;
expect(within(co).getByText("Supervisor group of 03, user")).toBeInTheDocument();
expect(within(co).getByText("Daily digest")).toBeInTheDocument();
const ver = within(table).getByText("Version deployed").closest("tr") as HTMLElement;
expect(within(ver).getByText("No")).toBeInTheDocument();
expect(within(ver).getByText("Not on the list")).toBeInTheDocument();
fireEvent.click(within(co).getByLabelText("Opt out of Change order waiting"));
expect(await screen.findByText("Change order waiting: you will not get this mail.")).toBeInTheDocument();
expect(callsTo(fetch, "PUT", "/api/account/notifications/co_waiting")).toEqual([{ opt_out: true }]);
await waitFor(() => expect(within(co).getByLabelText("Opt out of Change order waiting")).toBeChecked());
expect(within(co).getByText("Opted out")).toBeInTheDocument();
});
it("a PIN session sees the notifications but cannot change them", async () => {
mockFetch(routes(makeMe({ session: { kind: "pin" } })));
renderApp("/account");
const table = await screen.findByRole("table", { name: "Notifications" });
expect(await within(table).findByLabelText("Opt out of Change order waiting")).toBeDisabled();
expect(screen.getByText("Sign in with your password to change these.")).toBeInTheDocument();
});
});
+98 -1
View File
@@ -1,5 +1,6 @@
import { useState, type FormEvent } from "react";
import { Link } from "react-router";
import { accountNotifyApi, type MyNotification } from "../../api/admin";
import { accountApi, authApi } from "../../auth/api";
import { roleChipClass, sortRoles } from "../../auth/roles";
import type { AccountInfo, DeviceRef, Me } from "../../auth/types";
@@ -359,7 +360,102 @@ function AccessLogSection() {
);
}
/** /account: the user's own AD details, PIN, device registration, sessions and access log. */
function NotificationsSection({ me }: { me: Me }) {
const [load, reload] = useLoad((s) => accountNotifyApi.list(s));
const [busy, setBusy] = useState<string | null>(null);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const isPinSession = me.session.kind === "pin";
const toggle = async (n: MyNotification, optOut: boolean) => {
setBusy(n.event_type);
setMsg(null);
try {
await accountNotifyApi.setOptOut(n.event_type, optOut);
setMsg({ ok: true, text: optOut ? `${n.label}: you will not get this mail.` : `${n.label}: you will get this mail again.` });
reload();
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
setBusy(null);
}
};
const rows = load.state === "ok" ? load.data : [];
return (
<section className="sec" aria-labelledby="acct-notify">
<div className="sec-h">
<h2 id="acct-notify">Notifications</h2>
<span className="ct">The mail Packet Station sends, and why it reaches you.</span>
</div>
{isPinSession ? <div className="callout">Sign in with your password to change these.</div> : null}
{msg ? (
<div className={msg.ok ? "callout" : "callout warn"} role={msg.ok ? "status" : "alert"}>
{msg.text}
</div>
) : null}
<Problem load={load} />
{load.state === "ok" ? (
<div className="tw">
<table aria-label="Notifications">
<thead>
<tr>
<th>Event</th>
<th>Delivery</th>
<th>Reaches you</th>
<th>Why</th>
<th>Your choice</th>
</tr>
</thead>
<tbody>
{rows.map((n) => (
<tr key={n.event_type}>
<td>
{n.label} {n.security ? <span className="pill st-bad">Security</span> : null}
</td>
<td className="small">{n.delivery === "digest" ? "Daily digest" : "At once"}</td>
<td>
{n.opted_out ? (
<span className="pill st-wait">Opted out</span>
) : n.reaches ? (
<span className="pill st-ok">Yes</span>
) : (
<span className="pill st-filed">No</span>
)}
</td>
<td className="small">{n.why.length ? n.why.join(", ") : <span className="muted">Not on the list</span>}</td>
<td>
{n.security || !n.can_opt_out ? (
<span className="small muted">{n.security ? "Security events cannot be turned off." : "-"}</span>
) : (
<label className="ckl">
<input
type="checkbox"
checked={n.opted_out}
disabled={isPinSession || busy === n.event_type}
onChange={(e) => void toggle(n, e.target.checked)}
/>
{`Opt out of ${n.label}`}
</label>
)}
</td>
</tr>
))}
{rows.length === 0 ? (
<tr>
<td colSpan={5} className="muted">
No notifications are turned on.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
) : null}
</section>
);
}
/** /account: the user's own AD details, PIN, device registration, notifications, sessions and access log. */
export function AccountPage() {
const { me } = useAuth();
const [account, reloadAccount] = useLoad((s) => accountApi.get(s));
@@ -386,6 +482,7 @@ export function AccountPage() {
<Details me={me} account={account.state === "ok" ? account.data : null} />
<PinSection me={me} onSaved={reloadAccount} />
{me.can_register_device && me.session.kind === "password" ? <DeviceSection device={device} onRegistered={setRegistered} /> : null}
<NotificationsSection me={me} />
<SessionsSection me={me} />
<AccessLogSection />
<div className="small muted">
@@ -0,0 +1,63 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { adminBase } from "../../test/admin";
import { renderApp } from "../../test/auth";
import { mockFetch, type MockRequest } from "../../test/fetchMock";
const ROW1 = {
id: 11,
at: "2026-10-21T09:00:00Z",
source: "attempt",
kind: "password",
ok: false,
account: "weld.walt",
user_id: "u-1",
display_name: "Walt Welder",
ip: "10.0.0.9",
detail: { reason: "bad_password" },
};
const ROW2 = { ...ROW1, id: 12, source: "audit", kind: "auth.signin", ok: true, at: "2026-10-20T09:00:00Z", detail: null };
describe("Admin Access log tab", () => {
it("loads, filters, pages and offers the CSV of the last search", async () => {
const seen: string[] = [];
const fetch = mockFetch({
...adminBase(),
"/api/admin/access-log": (req: MockRequest) => {
seen.push(req.url);
if (req.url.includes("before=")) return { body: { rows: [ROW2], next_before: null } };
return { body: { rows: [ROW1], next_before: "2026-10-21T09:00:00Z|11" } };
},
});
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Access log" }));
const table = await screen.findByRole("table", { name: "Access log" });
expect(await within(table).findByText("Walt Welder")).toBeInTheDocument();
expect(within(table).getByText("Failed")).toHaveClass("st-bad");
expect(within(table).getByText("reason: bad_password")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Download CSV" })).toHaveAttribute("href", "/api/admin/access-log.csv");
fireEvent.change(screen.getByLabelText("Name or account"), { target: { value: "walt" } });
fireEvent.change(screen.getByRole("combobox", { name: /Result/ }), { target: { value: "false" } });
fireEvent.change(screen.getByLabelText("Address"), { target: { value: "10.0.0.9" } });
fireEvent.click(screen.getByRole("button", { name: "Search access log" }));
await waitFor(() => expect(seen.some((u) => u.includes("q=walt") && u.includes("ok=false") && u.includes("ip=10.0.0.9"))).toBe(true));
expect(screen.getByRole("link", { name: "Download CSV" })).toHaveAttribute("href", "/api/admin/access-log.csv?q=walt&ok=false&ip=10.0.0.9");
fireEvent.click(await screen.findByRole("button", { name: "Load older rows" }));
expect(await within(table).findByText("auth.signin")).toBeInTheDocument();
expect(within(table).getByText("password")).toBeInTheDocument();
const last = seen[seen.length - 1] ?? "";
expect(last).toContain("before=2026-10-21T09%3A00%3A00Z%7C11");
expect(last).toContain("q=walt");
expect(screen.queryByRole("button", { name: "Load older rows" })).not.toBeInTheDocument();
expect(fetch).toHaveBeenCalled();
});
it("shows an error from the server", async () => {
mockFetch({ ...adminBase(), "/api/admin/access-log": { status: 500, body: { detail: "Database unavailable" } } });
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Access log" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Database unavailable");
});
});
+178
View File
@@ -0,0 +1,178 @@
// Admin: the access log for everyone, with filters and a CSV download (phase 9).
import { useEffect, useState, type FormEvent } from "react";
import { admin9Api, type AccessLogFilters, type AccessLogRow } from "../../api/admin";
import { errorText, fmtDetail, fmtTime } from "../../lib/format";
import { Notice, type Msg } from "./common";
const PAGE = 200;
/** A datetime-local value as an ISO time, or undefined. */
function iso(local: string): string | undefined {
if (!local) return undefined;
const d = new Date(local);
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
}
interface Form {
since: string;
until: string;
q: string;
kind: string;
ok: "" | "true" | "false";
ip: string;
}
const EMPTY: Form = { since: "", until: "", q: "", kind: "", ok: "", ip: "" };
function toFilters(f: Form): AccessLogFilters {
const out: AccessLogFilters = {};
const since = iso(f.since);
const until = iso(f.until);
if (since) out.since = since;
if (until) out.until = until;
if (f.q.trim()) out.q = f.q.trim();
if (f.kind.trim()) out.kind = f.kind.trim();
if (f.ok) out.ok = f.ok;
if (f.ip.trim()) out.ip = f.ip.trim();
return out;
}
export function AccessLogTab() {
const [form, setForm] = useState<Form>(EMPTY);
const [filters, setFilters] = useState<AccessLogFilters>({});
const [rows, setRows] = useState<AccessLogRow[]>([]);
const [next, setNext] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [msg, setMsg] = useState<Msg>(null);
const fetchPage = async (f: AccessLogFilters, before: string | null) => {
setLoading(true);
setMsg(null);
try {
const r = await admin9Api.accessLog({ ...f, limit: PAGE, ...(before ? { before } : {}) });
setRows((cur) => (before ? [...cur, ...r.rows] : r.rows));
setNext(r.next_before);
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
setLoading(false);
}
};
// The first page, once. Later pages come from the search and the load-more button.
useEffect(() => {
void fetchPage({}, null);
}, []);
const set = (patch: Partial<Form>) => setForm((cur) => ({ ...cur, ...patch }));
const search = (e: FormEvent) => {
e.preventDefault();
const f = toFilters(form);
setFilters(f);
void fetchPage(f, null);
};
const csvHref = admin9Api.accessLogCsvUrl(filters);
return (
<div className="stack">
<form className="panel stack" onSubmit={search} noValidate role="search" aria-label="Access log filters">
<div className="frow">
<label className="f">
From
<input className="in" type="datetime-local" value={form.since} onChange={(e) => set({ since: e.target.value })} />
</label>
<label className="f">
To
<input className="in" type="datetime-local" value={form.until} onChange={(e) => set({ until: e.target.value })} />
</label>
<label className="f">
Name or account
<input className="in" type="search" value={form.q} onChange={(e) => set({ q: e.target.value })} />
</label>
<label className="f">
Kind
<input className="in mono" placeholder="auth.signin, password, pin" value={form.kind} onChange={(e) => set({ kind: e.target.value })} />
</label>
<label className="f">
Result
<select className="in" aria-label="Result" value={form.ok} onChange={(e) => set({ ok: e.target.value === "true" ? "true" : e.target.value === "false" ? "false" : "" })}>
<option value="">All</option>
<option value="true">OK only</option>
<option value="false">Failed only</option>
</select>
</label>
<label className="f">
Address
<input className="in mono" value={form.ip} onChange={(e) => set({ ip: e.target.value })} />
</label>
</div>
<div className="chips">
<button className="btn pri" type="submit" disabled={loading}>
Search access log
</button>
<button
className="btn ghost"
type="button"
onClick={() => {
setForm(EMPTY);
setFilters({});
void fetchPage({}, null);
}}
>
Clear filters
</button>
<a className="btn" href={csvHref} download>
Download CSV
</a>
</div>
<div className="small muted">The CSV has the rows the last search matched, up to 50,000. The download is logged.</div>
</form>
<Notice msg={msg} />
<div className="tw">
<table aria-label="Access log">
<thead>
<tr>
<th>When</th>
<th>Who</th>
<th>Kind</th>
<th>Result</th>
<th>Address</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={`${r.source}-${r.id}`}>
<td className="small mono nowrap">{fmtTime(r.at)}</td>
<td>
{r.display_name ?? ""} {r.account ? <span className="mono small muted">{r.account}</span> : null}
</td>
<td className="mono small">{r.kind}</td>
<td>{r.ok ? <span className="pill st-ok">OK</span> : <span className="pill st-bad">Failed</span>}</td>
<td className="mono small">{r.ip ?? "-"}</td>
<td className="small">{fmtDetail(r.detail)}</td>
</tr>
))}
{!loading && rows.length === 0 ? (
<tr>
<td colSpan={6} className="muted">
Nothing matches.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
{loading ? <div className="empty slim">Loading</div> : null}
{next && !loading ? (
<div>
<button className="btn sm" type="button" onClick={() => void fetchPage(filters, next)}>
Load older rows
</button>
</div>
) : null}
</div>
);
}
+26 -12
View File
@@ -55,9 +55,25 @@ function base(me = ADMIN) {
"/api/departments": { body: DEPARTMENTS },
"/api/admin/users": { body: USERS },
"/api/admin/groups": { body: GROUPS },
"/api/admin/ad/reconcile": { body: { run: null, report: null } },
};
}
const REPORT = {
run: { id: 7, started_at: "2026-10-21T01:30:00Z", finished_at: "2026-10-21T01:31:00Z", status: "ok" },
report: {
groups_missing: ["PS-14-Electrical-Lead"],
groups_extra: ["PS-Extra-Legacy"],
users_seen: 16,
created: 2,
updated: 14,
deactivated: 1,
sessions_ended: 3,
emp_missing: ["noemp.nora"],
emp_duplicate: ["9001"],
},
};
describe("AdminPage", () => {
it("lists users with employee ID flags and opens the detail", async () => {
mockFetch({
@@ -96,21 +112,15 @@ describe("AdminPage", () => {
expect(callsTo(fetch, "DELETE", "/api/admin/users/u-1/pin")).toEqual([{ reason: "Forgot the PIN, asked by phone" }]);
});
it("flags a group missing in AD, lists extras, and refreshes", async () => {
it("flags a group missing in AD, lists extras, requests a refresh and shows the reconcile report", async () => {
let refreshed = false;
const fetch = mockFetch({
...base(),
"POST /api/admin/ad/refresh": {
body: {
groups_checked: 50,
groups_missing: ["PS-14-Electrical-Lead"],
users_seen: 16,
created: 2,
updated: 14,
deactivated: 1,
emp_missing: ["noemp.nora"],
emp_duplicate: ["9001"],
},
"POST /api/admin/ad/refresh": () => {
refreshed = true;
return { status: 202, body: { requested: true, pending: 1, job: "ad-reconcile" } };
},
"/api/admin/ad/reconcile": () => ({ body: refreshed ? REPORT : { run: null, report: null } }),
});
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Group map" }));
@@ -124,9 +134,13 @@ describe("AdminPage", () => {
expect(screen.getByText("PS-Extra-Legacy")).toBeInTheDocument();
expect(screen.getByText(/1 group is missing in AD/)).toBeInTheDocument();
expect(await screen.findByText(/Not run yet/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Refresh from AD now" }));
expect(await screen.findByText(/Refresh requested/)).toBeInTheDocument();
expect(await screen.findByText("Users seen")).toBeInTheDocument();
expect(screen.getByText("16")).toBeInTheDocument();
expect(screen.getByText("Sessions ended")).toBeInTheDocument();
expect(screen.getByText(/noemp\.nora/)).toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/admin/ad/refresh")).toHaveLength(1);
});
+106 -33
View File
@@ -1,14 +1,39 @@
import { useEffect, useState, type FormEvent, type KeyboardEvent } from "react";
import { adminApi } from "../../auth/api";
import { roleChipClass, roleFromKind, sortRoles } from "../../auth/roles";
import type { AdLookup, AdRefreshResult, AdminUser, GroupRow, PinState } from "../../auth/types";
import { admin9Api, type ReconcileAnswer, type RefreshRequested } from "../../api/admin";
import type { AdLookup, AdminUser, GroupRow, PinState } from "../../auth/types";
import { useAuth } from "../../auth/useAuth";
import { errorText, fmtTime } from "../../lib/format";
import { useLoad, type Load } from "../../lib/useLoad";
import { FeedTab, ResourcesTab } from "./FeedTabs";
import { AccessLogTab } from "./AccessLogTab";
import { ChecklistsTab } from "./ChecklistsTab";
import { DepartmentsTab } from "./DepartmentsTab";
import { NotificationsTab } from "./NotificationsTab";
import { PresenceTab } from "./PresenceTab";
import { ProfilesTab } from "./ProfilesTab";
import { SecurityTab } from "./SecurityTab";
import { StampVerifyTab } from "./StampVerifyTab";
import { StorageTab } from "./StorageTab";
type Tab = "users" | "lookup" | "devices" | "groups" | "attempts" | "feed" | "resources" | "storage";
type Tab =
| "users"
| "lookup"
| "devices"
| "groups"
| "attempts"
| "feed"
| "resources"
| "storage"
| "departments"
| "profiles"
| "checklists"
| "security"
| "accesslog"
| "presence"
| "stampverify"
| "notifications";
const TABS: ReadonlyArray<{ id: Tab; label: string }> = [
{ id: "users", label: "Users" },
@@ -19,6 +44,14 @@ const TABS: ReadonlyArray<{ id: Tab; label: string }> = [
{ id: "feed", label: "Feed" },
{ id: "resources", label: "VISUAL resources" },
{ id: "storage", label: "Storage" },
{ id: "departments", label: "Departments" },
{ id: "profiles", label: "Profiles" },
{ id: "checklists", label: "Checklists" },
{ id: "security", label: "Security" },
{ id: "accesslog", label: "Access log" },
{ id: "presence", label: "Who is in" },
{ id: "stampverify", label: "Stamp check" },
{ id: "notifications", label: "Notifications" },
];
const ROLES_NOTE = "Roles come from AD groups. Ask IT to change them.";
@@ -466,57 +499,82 @@ function InAdPill({ inAd }: { inAd: boolean | null }) {
return <span className="pill st-wait">AD unreachable</span>;
}
function RefreshResult({ r }: { r: AdRefreshResult }) {
type ReportValue = string[] | number | undefined;
const count = (v: ReportValue) => (Array.isArray(v) ? v.length : (v ?? 0));
const names = (v: ReportValue) => (Array.isArray(v) ? v : []);
function ReconcileReportView({ load, onReload }: { load: Load<ReconcileAnswer>; onReload: () => void }) {
if (load.state !== "ok") return <Problem load={load} />;
const { run, report } = load.data;
const r = report ?? {};
const tiles: Array<[number, string, boolean]> = [
[r.groups_checked, "Groups checked", false],
[r.groups_missing.length, "Groups missing", r.groups_missing.length > 0],
[r.users_seen, "Users seen", false],
[r.created, "Created", false],
[r.updated, "Updated", false],
[r.deactivated, "Deactivated", r.deactivated > 0],
[count(r.groups_missing), "Groups missing", count(r.groups_missing) > 0],
[count(r.groups_extra), "Extra groups", false],
[r.users_seen ?? 0, "Users seen", false],
[r.created ?? 0, "Created", false],
[r.updated ?? 0, "Updated", false],
[r.deactivated ?? 0, "Deactivated", (r.deactivated ?? 0) > 0],
[r.sessions_ended ?? 0, "Sessions ended", (r.sessions_ended ?? 0) > 0],
[count(r.emp_missing), "No employee ID", count(r.emp_missing) > 0],
[count(r.emp_duplicate), "Duplicate employee IDs", count(r.emp_duplicate) > 0],
];
return (
<div role="status">
<div className="kpis">
{tiles.map(([n, l, hot]) => (
<div key={l} className={hot ? "kpi hot" : n === 0 ? "kpi zero" : "kpi"}>
<div className="n">{n}</div>
<div className="l">{l}</div>
</div>
))}
<section aria-labelledby="recon-h" className="stack">
<div className="sec-h flush">
<h3 id="recon-h">Latest reconcile</h3>
<span className="ct">
{run ? `${run.status}, finished ${fmtTime(run.finished_at)}` : "Not run yet. It runs nightly at 01:30."}
</span>
<span className="grow" />
<button className="btn sm ghost" type="button" onClick={onReload}>
Show the latest report
</button>
</div>
{r.groups_missing.length ? (
<div className="callout warn">
Missing groups: <span className="mono">{r.groups_missing.join(", ")}</span>
{run ? (
<div className="kpis">
{tiles.map(([n, l, hot]) => (
<div key={l} className={hot ? "kpi hot" : n === 0 ? "kpi zero" : "kpi"}>
<div className="n">{n}</div>
<div className="l">{l}</div>
</div>
))}
</div>
) : null}
{r.emp_missing.length ? (
<div className="callout warn">
No employee ID in AD: <span className="mono">{r.emp_missing.join(", ")}</span>
{names(r.groups_missing).length ? (
<div className="callout warn flush">
Missing groups: <span className="mono">{names(r.groups_missing).join(", ")}</span>
</div>
) : null}
{r.emp_duplicate.length ? (
<div className="callout warn">
Employee IDs used by more than one account: <span className="mono">{r.emp_duplicate.join(", ")}</span>
{names(r.emp_missing).length ? (
<div className="callout warn flush">
No employee ID in AD: <span className="mono">{names(r.emp_missing).join(", ")}</span>
</div>
) : null}
</div>
{names(r.emp_duplicate).length ? (
<div className="callout warn flush">
Employee IDs used by more than one account: <span className="mono">{names(r.emp_duplicate).join(", ")}</span>
</div>
) : null}
</section>
);
}
function GroupsTab() {
const { roleLabel } = useAuth();
const [load, reload] = useLoad((s) => adminApi.groups(s));
const [report, reloadReport] = useLoad((s) => admin9Api.reconcile(s));
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<AdRefreshResult | null>(null);
const [requested, setRequested] = useState<RefreshRequested | null>(null);
const [error, setError] = useState<string | null>(null);
const refresh = async () => {
setBusy(true);
setError(null);
try {
setResult(await adminApi.refresh());
setRequested(await admin9Api.refreshAd());
reload();
reloadReport();
} catch (err) {
setError(errorText(err));
} finally {
@@ -537,7 +595,7 @@ function GroupsTab() {
</span>
<span className="grow" />
<button className="btn sm pri" type="button" disabled={busy} onClick={() => void refresh()}>
{busy ? "Reading AD" : "Refresh from AD now"}
{busy ? "Asking the worker" : "Refresh from AD now"}
</button>
</div>
{error ? (
@@ -545,7 +603,13 @@ function GroupsTab() {
{error}
</div>
) : null}
{result ? <RefreshResult r={result} /> : null}
{requested ? (
<div className="callout flush" role="status">
Refresh requested. The worker runs the AD reconcile within a minute
{requested.pending > 1 ? `, after ${requested.pending - 1} other ${requested.pending === 2 ? "request" : "requests"}` : ""}. Then show the latest report.
</div>
) : null}
<ReconcileReportView load={report} onReload={reloadReport} />
<Problem load={load} />
{map && !map.ad_reachable ? <div className="callout warn">AD is unreachable. Showing the expected groups only.</div> : null}
{map && missing > 0 ? (
@@ -654,7 +718,7 @@ function AttemptsTab() {
);
}
/** /admin: users, AD lookup, devices, the group map and failed sign-ins. Needs a password session. */
/** /admin: users, AD, devices, groups, sign-ins, the feed, storage and the phase 9 tabs. Needs a password session. */
export function AdminPage() {
const { me } = useAuth();
const [tab, setTab] = useState<Tab>("users");
@@ -680,7 +744,8 @@ export function AdminPage() {
return (
<>
{head}
<div className="tabs" role="tablist" aria-label="Admin">
{/* Sixteen tabs: they wrap into rows rather than scroll sideways on a narrow screen. */}
<div className="tabs" role="tablist" aria-label="Admin" style={{ flexWrap: "wrap" }}>
{TABS.map((t) => (
<button
key={t.id}
@@ -704,6 +769,14 @@ export function AdminPage() {
{tab === "feed" ? <FeedTab /> : null}
{tab === "resources" ? <ResourcesTab /> : null}
{tab === "storage" ? <StorageTab /> : null}
{tab === "departments" ? <DepartmentsTab /> : null}
{tab === "profiles" ? <ProfilesTab /> : null}
{tab === "checklists" ? <ChecklistsTab /> : null}
{tab === "security" ? <SecurityTab /> : null}
{tab === "accesslog" ? <AccessLogTab /> : null}
{tab === "presence" ? <PresenceTab /> : null}
{tab === "stampverify" ? <StampVerifyTab /> : null}
{tab === "notifications" ? <NotificationsTab /> : null}
</div>
</>
);
@@ -0,0 +1,94 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { adminBase } from "../../test/admin";
import { renderApp } from "../../test/auth";
import { callsTo, mockFetch } from "../../test/fetchMock";
const VALUE = {
weld: [
{ group: "Before welding", key: "dwg", text: "Drawing {drawing} Rev {rev} matches the packet" },
{ group: "Before welding", key: "wps", text: "WPS reviewed: {wps}" },
{ group: "After welding", key: "vt", text: "Visual inspection of all welds acceptable" },
],
plain: [{ group: "Before starting", key: "dwg", text: "Drawing {drawing} Rev {rev} matches the packet" }],
};
const DEFAULTS = { weld: VALUE.weld.slice(0, 2), plain: VALUE.plain };
async function openTab() {
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Checklists" }));
return screen.findByLabelText("Weld item 1 text");
}
describe("Admin Checklists tab", () => {
it("edits, adds, reorders and removes items, checks them, and saves with the version", async () => {
const fetch = mockFetch({
...adminBase(),
"GET /api/admin/checklists": { body: { value: VALUE, defaults: DEFAULTS, version: 4 } },
"PUT /api/admin/checklists": { body: { value: VALUE, defaults: DEFAULTS, version: 5 } },
});
await openTab();
expect(screen.getByLabelText("Weld item 1 key")).toHaveAttribute("readonly");
expect(screen.getByLabelText("Plain item 1 group")).toHaveValue("Before starting");
fireEvent.change(screen.getByLabelText("Weld item 3 text"), { target: { value: "Visual check of every weld" } });
fireEvent.click(screen.getByRole("button", { name: "Move weld item 3 up" }));
fireEvent.click(screen.getByRole("button", { name: "Remove weld item 1" }));
fireEvent.click(screen.getByRole("button", { name: "Add weld item" }));
const key = screen.getByLabelText("Weld item 3 key");
expect(key).not.toHaveAttribute("readonly");
fireEvent.change(key, { target: { value: "Clean" } });
fireEvent.change(screen.getByLabelText("Weld item 3 text"), { target: { value: "Slag removed near {part}" } });
fireEvent.click(screen.getByRole("button", { name: "Save checklists" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Weld list, item 3: the key is lower case");
fireEvent.change(key, { target: { value: "clean" } });
fireEvent.click(screen.getByRole("button", { name: "Save checklists" }));
expect(await screen.findByRole("alert")).toHaveTextContent("only {drawing}, {rev} and {wps}");
fireEvent.change(screen.getByLabelText("Weld item 3 text"), { target: { value: "Slag removed" } });
fireEvent.click(screen.getByRole("button", { name: "Save checklists" }));
expect(await screen.findByText(/Checklists saved/)).toBeInTheDocument();
expect(callsTo(fetch, "PUT", "/api/admin/checklists")).toEqual([
{
version: 4,
value: {
weld: [
{ group: "After welding", key: "vt", text: "Visual check of every weld" },
{ group: "Before welding", key: "wps", text: "WPS reviewed: {wps}" },
{ group: "Before welding", key: "clean", text: "Slag removed" },
],
plain: VALUE.plain,
},
},
]);
});
it("loads the other version after a 409", async () => {
let gets = 0;
const fetch = mockFetch({
...adminBase(),
"GET /api/admin/checklists": () => {
gets += 1;
return { body: { value: gets > 1 ? { ...VALUE, plain: [{ ...VALUE.plain[0], text: "Changed by Bob" }] } : VALUE, defaults: DEFAULTS, version: 3 + gets } };
},
"PUT /api/admin/checklists": { status: 409, body: { detail: "Changed by someone else", code: "version_conflict" } },
});
await openTab();
fireEvent.change(screen.getByLabelText("Plain item 1 text"), { target: { value: "Mine" } });
fireEvent.click(screen.getByRole("button", { name: "Save checklists" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Someone else saved the checklists first.");
await waitFor(() => expect(screen.getByLabelText("Plain item 1 text")).toHaveValue("Changed by Bob"));
expect(callsTo(fetch, "GET", "/api/admin/checklists")).toHaveLength(2);
});
it("copies the default list in", async () => {
mockFetch({ ...adminBase(), "GET /api/admin/checklists": { body: { value: VALUE, defaults: DEFAULTS, version: 1 } } });
await openTab();
expect(screen.getByLabelText("Weld item 3 text")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Use the default weld list" }));
expect(screen.queryByLabelText("Weld item 3 text")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Discard changes" }));
expect(screen.getByLabelText("Weld item 3 text")).toBeInTheDocument();
});
});
@@ -0,0 +1,231 @@
// Admin: the department checklists, one list for weld departments and one for the rest (phase 9).
import { useEffect, useState } from "react";
import { admin9Api, type ChecklistItem, type ChecklistLists, type ChecklistsAnswer } from "../../api/admin";
import { ApiError } from "../../api/client";
import { errorText } from "../../lib/format";
import { useLoad } from "../../lib/useLoad";
import { Notice, Problem, type Msg } from "./common";
type ListId = "weld" | "plain";
const LISTS: ReadonlyArray<{ id: ListId; title: string; one: string }> = [
{ id: "weld", title: "Weld departments", one: "weld" },
{ id: "plain", title: "Other departments", one: "plain" },
];
const RX_KEY = /^[a-z][a-z0-9_]{0,29}$/;
const MAX_ITEMS = 40;
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
const PLACEHOLDERS = new Set(["drawing", "rev", "wps"]);
interface Row extends ChecklistItem {
/** True for a key the saved list already has. Its key cannot change, because answers on open stages use it. */
saved: boolean;
}
type Draft = Record<ListId, Row[]>;
function toDraft(v: ChecklistLists): Draft {
return {
weld: v.weld.map((it) => ({ ...it, saved: true })),
plain: v.plain.map((it) => ({ ...it, saved: true })),
};
}
/** The first problem with one list, or null. Mirrors the server's rules. */
function listProblem(rows: readonly ChecklistItem[], title: string): string | null {
if (rows.length === 0) return `${title}: the list needs at least one item.`;
if (rows.length > MAX_ITEMS) return `${title}: at most ${MAX_ITEMS} items.`;
const seen = new Set<string>();
for (const [i, r] of rows.entries()) {
const n = i + 1;
if (!r.group.trim()) return `${title}, item ${n}: give it a group.`;
if (!r.text.trim()) return `${title}, item ${n}: give it a text.`;
if (!RX_KEY.test(r.key)) return `${title}, item ${n}: the key is lower case letters, digits and _, starting with a letter.`;
if (seen.has(r.key)) return `${title}, item ${n}: the key ${r.key} is used twice.`;
seen.add(r.key);
for (const m of r.text.matchAll(/\{([^}]*)\}/g)) {
if (!PLACEHOLDERS.has(m[1] ?? "")) return `${title}, item ${n}: only {drawing}, {rev} and {wps} can be filled in.`;
}
}
return null;
}
function ListEditor({
id,
title,
one,
rows,
onChange,
onDefaults,
}: {
id: ListId;
title: string;
one: string;
rows: Row[];
onChange: (rows: Row[]) => void;
onDefaults: () => void;
}) {
const set = (i: number, patch: Partial<Row>) => onChange(rows.map((r, j) => (j === i ? { ...r, ...patch } : r)));
const move = (i: number, d: number) => {
const j = i + d;
if (j < 0 || j >= rows.length) return;
const out = [...rows];
const a = out[i];
const b = out[j];
if (!a || !b) return;
out[i] = b;
out[j] = a;
onChange(out);
};
const add = () => {
const last = rows[rows.length - 1];
onChange([...rows, { group: last?.group ?? "", key: "", text: "", saved: false }]);
};
return (
<section className="panel stack" aria-labelledby={`ckl-${id}-h`}>
<div className="sec-h flush">
<h3 id={`ckl-${id}-h`}>{title}</h3>
<span className="ct">{rows.length} items</span>
<span className="grow" />
<button className="btn sm ghost" type="button" onClick={onDefaults}>
{`Use the default ${one} list`}
</button>
</div>
{rows.map((r, i) => {
const n = i + 1;
return (
<div className="frow" key={`${i}-${r.saved ? r.key : "new"}`}>
<label className="f">
{`${cap(one)} item ${n} group`}
<input className="in" value={r.group} maxLength={200} onChange={(e) => set(i, { group: e.target.value })} />
</label>
<label className="f">
{`${cap(one)} item ${n} key`}
<input
className="in mono"
value={r.key}
maxLength={30}
readOnly={r.saved}
title={r.saved ? "A saved key cannot change. Answers on open stages use it." : undefined}
onChange={(e) => set(i, { key: e.target.value })}
/>
</label>
<label className="f" style={{ gridColumn: "span 2" }}>
{`${cap(one)} item ${n} text`}
<input className="in" value={r.text} maxLength={200} onChange={(e) => set(i, { text: e.target.value })} />
</label>
<div className="end nowrap">
<button className="btn sm ghost" type="button" aria-label={`Move ${one} item ${n} up`} disabled={i === 0} onClick={() => move(i, -1)}>
Up
</button>{" "}
<button
className="btn sm ghost"
type="button"
aria-label={`Move ${one} item ${n} down`}
disabled={i === rows.length - 1}
onClick={() => move(i, 1)}
>
Down
</button>{" "}
<button className="btn sm rejb" type="button" aria-label={`Remove ${one} item ${n}`} onClick={() => onChange(rows.filter((_, j) => j !== i))}>
Remove
</button>
</div>
</div>
);
})}
<div>
<button className="btn sm" type="button" disabled={rows.length >= MAX_ITEMS} onClick={add}>
{`Add ${one} item`}
</button>
</div>
</section>
);
}
export function ChecklistsTab() {
const [load, reload] = useLoad((s) => admin9Api.checklists(s));
const [base, setBase] = useState<ChecklistsAnswer | null>(null);
const [draft, setDraft] = useState<Draft | null>(null);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<Msg>(null);
useEffect(() => {
if (load.state === "ok") {
setBase(load.data);
setDraft(toDraft(load.data.value));
}
}, [load]);
if (!base || !draft) return <Problem load={load} />;
const strip = (rows: Row[]): ChecklistItem[] => rows.map((r) => ({ group: r.group.trim(), key: r.key.trim(), text: r.text.trim() }));
const savedKeys = new Set([...base.value.weld, ...base.value.plain].map((r) => r.key));
const save = async () => {
const value: ChecklistLists = { weld: strip(draft.weld), plain: strip(draft.plain) };
const problem = listProblem(value.weld, "Weld list") ?? listProblem(value.plain, "Other list");
if (problem) {
setMsg({ ok: false, text: problem });
return;
}
setBusy(true);
setMsg(null);
try {
const r = await admin9Api.saveChecklists(value, base.version);
setBase(r);
setDraft(toDraft(r.value));
setMsg({ ok: true, text: "Checklists saved. New checklists use them. Open stages keep their answers." });
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
setMsg({ ok: false, text: "Someone else saved the checklists first. Their version is loaded now. Make your change again." });
reload();
} else {
setMsg({ ok: false, text: errorText(err) });
}
} finally {
setBusy(false);
}
};
const defaults = (id: ListId) =>
setDraft((cur) => (cur ? { ...cur, [id]: base.defaults[id].map((it) => ({ ...it, saved: savedKeys.has(it.key) })) } : cur));
return (
<div className="stack">
<div className="small muted">
Weld departments get the first list, the others the second. Text can use {"{drawing}"}, {"{rev}"} and {"{wps}"}. A removed item drops out of new
checklists only.
</div>
{LISTS.map((l) => (
<ListEditor
key={l.id}
id={l.id}
title={l.title}
one={l.one}
rows={draft[l.id]}
onChange={(rows) => setDraft((cur) => (cur ? { ...cur, [l.id]: rows } : cur))}
onDefaults={() => defaults(l.id)}
/>
))}
<Notice msg={msg} />
<div className="chips">
<button className="btn pri" type="button" disabled={busy} onClick={() => void save()}>
Save checklists
</button>
<button
className="btn ghost"
type="button"
disabled={busy}
onClick={() => {
setDraft(toDraft(base.value));
setMsg(null);
}}
>
Discard changes
</button>
</div>
</div>
);
}
@@ -0,0 +1,110 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ADMIN_DEPTS, adminBase } from "../../test/admin";
import { renderApp } from "../../test/auth";
import { callsTo, mockFetch } from "../../test/fetchMock";
async function openTab() {
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Departments" }));
return screen.findByRole("table", { name: "Departments" });
}
describe("Admin Departments tab", () => {
it("lists departments with their groups and active stations", async () => {
mockFetch({ ...adminBase(), "/api/admin/departments": { body: ADMIN_DEPTS } });
const table = await openTab();
expect(screen.getByRole("tab", { name: "Departments" })).toHaveAttribute("id", "admtab-departments");
const row = within(table).getByText("03").closest("tr") as HTMLElement;
expect(within(row).getByText("Stall 1, Stall 2")).toBeInTheDocument();
const sup = within(row).getByText("PS-03-WeldParts-Supervisor").closest("li") as HTMLElement;
expect(within(sup).getByText("Missing in AD")).toHaveClass("st-bad");
const cl = within(table).getByText("PS-Clerical").closest("li") as HTMLElement;
expect(within(cl).getByText("Not checked")).toBeInTheDocument();
});
it("edits the name and stations and keeps station ids", async () => {
const fetch = mockFetch({
...adminBase(),
"/api/admin/departments": { body: ADMIN_DEPTS },
"PATCH /api/admin/departments/03": { body: ADMIN_DEPTS[0] },
});
await openTab();
fireEvent.click(screen.getByRole("button", { name: "Edit department 03" }));
fireEvent.change(screen.getByLabelText("Department name"), { target: { value: "Weld Parts East" } });
fireEvent.click(screen.getByRole("button", { name: "Move station 2 up" }));
fireEvent.click(screen.getByRole("button", { name: "Add station" }));
fireEvent.change(screen.getByLabelText("Station 4 name"), { target: { value: "Stall 1" } });
fireEvent.click(screen.getByRole("button", { name: "Save department" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Two stations have the same name.");
fireEvent.change(screen.getByLabelText("Station 4 name"), { target: { value: "Stall 4" } });
fireEvent.click(screen.getByLabelText("Station 3 active"));
fireEvent.click(screen.getByRole("button", { name: "Save department" }));
expect(await screen.findByText("Department saved.")).toBeInTheDocument();
expect(callsTo(fetch, "PATCH", "/api/admin/departments/03")).toEqual([
{
name: "Weld Parts East",
active: true,
stations: [
{ id: 32, name: "Stall 2", sort: 1, active: true },
{ id: 31, name: "Stall 1", sort: 2, active: true },
{ id: 33, name: "Old Stall", sort: 3, active: true },
{ name: "Stall 4", sort: 4, active: true },
],
},
]);
});
it("adds a department and shows the groups IT must create", async () => {
const fetch = mockFetch({
...adminBase(),
"/api/admin/departments": { body: ADMIN_DEPTS },
"POST /api/admin/departments": {
status: 201,
body: {
department: { ...ADMIN_DEPTS[0], code: "15", slug: "Paint", name: "Paint", groups: [], stations: [] },
groups_to_create: ["PS-15-Paint-Worker", "PS-15-Paint-Lead", "PS-15-Paint-Supervisor"],
},
},
});
await openTab();
fireEvent.click(screen.getByRole("button", { name: "New department" }));
const form = screen.getByRole("form", { name: "Add department" });
fireEvent.change(within(form).getByLabelText("Code"), { target: { value: "1" } });
fireEvent.click(within(form).getByRole("button", { name: "Add department" }));
expect(await within(form).findByRole("alert")).toHaveTextContent("The code is 2 to 4");
expect(callsTo(fetch, "POST", "/api/admin/departments")).toHaveLength(0);
fireEvent.change(within(form).getByLabelText("Code"), { target: { value: "15" } });
fireEvent.change(within(form).getByLabelText("Short name"), { target: { value: "Paint" } });
fireEvent.change(within(form).getByLabelText("Name"), { target: { value: "Paint" } });
fireEvent.change(within(form).getByLabelText("Stations, one per line"), { target: { value: "Booth 1\n\nBooth 2\n" } });
expect(within(form).getByText(/PS-15-Paint-Worker, PS-15-Paint-Lead, PS-15-Paint-Supervisor/)).toBeInTheDocument();
fireEvent.click(within(form).getByRole("button", { name: "Add department" }));
const done = await screen.findByRole("status", { name: "Groups to create" });
expect(within(done).getByText("PS-15-Paint-Supervisor")).toBeInTheDocument();
await waitFor(() =>
expect(callsTo(fetch, "POST", "/api/admin/departments")).toEqual([
{ code: "15", slug: "Paint", name: "Paint", kind: "shop", weld: false, supervisor_group: true, stations: ["Booth 1", "Booth 2"] },
]),
);
});
it("shows the server's answer when a code is taken", async () => {
mockFetch({
...adminBase(),
"/api/admin/departments": { body: ADMIN_DEPTS },
"POST /api/admin/departments": { status: 409, body: { detail: "Code 03 is taken.", code: "conflict" } },
});
await openTab();
fireEvent.click(screen.getByRole("button", { name: "New department" }));
const form = screen.getByRole("form", { name: "Add department" });
fireEvent.change(within(form).getByLabelText("Code"), { target: { value: "03" } });
fireEvent.change(within(form).getByLabelText("Short name"), { target: { value: "Again" } });
fireEvent.change(within(form).getByLabelText("Name"), { target: { value: "Again" } });
fireEvent.click(within(form).getByRole("button", { name: "Add department" }));
expect(await within(form).findByRole("alert")).toHaveTextContent("Code 03 is taken.");
});
});
@@ -0,0 +1,342 @@
// Admin: departments, their stations and the AD groups each one needs (phase 9).
import { useState, type FormEvent } from "react";
import { admin9Api, type AdminDepartment, type DeptAdded, type DeptGroup } from "../../api/admin";
import { errorText } from "../../lib/format";
import { useLoad } from "../../lib/useLoad";
import { Notice, Problem, type Msg } from "./common";
const RX_CODE = /^[0-9A-Z]{2,4}$/;
const RX_SLUG = /^[A-Za-z][A-Za-z0-9]{1,30}$/;
const SHOP_LEVELS = ["Worker", "Lead", "Supervisor"] as const;
function OuPill({ g }: { g: DeptGroup }) {
if (g.in_ou === true) return <span className="pill st-ok">In AD</span>;
if (g.in_ou === false) return <span className="pill st-bad">Missing in AD</span>;
return <span className="pill st-wait">Not checked</span>;
}
type StationDraft = { id?: number; name: string; active: boolean };
function DeptEditor({ d, onSaved, onClose }: { d: AdminDepartment; onSaved: () => void; onClose: () => void }) {
const [name, setName] = useState(d.name);
const [active, setActive] = useState(d.active);
const [stations, setStations] = useState<StationDraft[]>(
[...d.stations].sort((a, b) => a.sort - b.sort).map((s) => ({ id: s.id, name: s.name, active: s.active })),
);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<Msg>(null);
const setStation = (i: number, patch: Partial<StationDraft>) =>
setStations((cur) => cur.map((s, j) => (j === i ? { ...s, ...patch } : s)));
const save = async (e: FormEvent) => {
e.preventDefault();
const names = stations.map((s) => s.name.trim());
if (!name.trim()) {
setMsg({ ok: false, text: "Give the department a name." });
return;
}
if (names.some((n) => !n)) {
setMsg({ ok: false, text: "Every station needs a name." });
return;
}
if (new Set(names.map((n) => n.toLowerCase())).size !== names.length) {
setMsg({ ok: false, text: "Two stations have the same name." });
return;
}
setBusy(true);
setMsg(null);
try {
await admin9Api.patchDepartment(d.code, {
name: name.trim(),
active,
stations: stations.map((s, i) => ({ ...(s.id !== undefined ? { id: s.id } : {}), name: s.name.trim(), sort: i + 1, active: s.active })),
});
setMsg({ ok: true, text: "Department saved." });
onSaved();
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
setBusy(false);
}
};
return (
<form className="panel stack" onSubmit={save} noValidate aria-labelledby="dept-edit-h">
<div className="sec-h">
<h3 id="dept-edit-h">
Edit {d.code} {d.name}
</h3>
<span className="grow" />
<button className="btn sm ghost" type="button" onClick={onClose}>
Close
</button>
</div>
<div className="frow">
<label className="f">
Department name
<input className="in" value={name} maxLength={80} onChange={(e) => setName(e.target.value)} />
</label>
<label className="ckl">
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
Department active
</label>
</div>
<h3>Stations</h3>
<div className="small muted">Stations are never deleted, because station limits point at them. Clear Active to hide one.</div>
{stations.map((s, i) => (
<div className="frow" key={s.id ?? `new-${i}`}>
<label className="f">
{`Station ${i + 1} name`}
<input className="in" value={s.name} maxLength={60} onChange={(e) => setStation(i, { name: e.target.value })} />
</label>
<label className="ckl">
<input type="checkbox" checked={s.active} aria-label={`Station ${i + 1} active`} onChange={(e) => setStation(i, { active: e.target.checked })} />
Active
</label>
<div className="end">
<button
className="btn sm ghost"
type="button"
aria-label={`Move station ${i + 1} up`}
disabled={i === 0}
onClick={() => setStations((cur) => swap(cur, i, i - 1))}
>
Up
</button>{" "}
<button
className="btn sm ghost"
type="button"
aria-label={`Move station ${i + 1} down`}
disabled={i === stations.length - 1}
onClick={() => setStations((cur) => swap(cur, i, i + 1))}
>
Down
</button>
</div>
</div>
))}
<div>
<button className="btn sm" type="button" onClick={() => setStations((cur) => [...cur, { name: "", active: true }])}>
Add station
</button>
</div>
<Notice msg={msg} />
<div>
<button className="btn pri" type="submit" disabled={busy}>
Save department
</button>
</div>
</form>
);
}
function swap<T>(list: T[], a: number, b: number): T[] {
const out = [...list];
const x = out[a];
const y = out[b];
if (x === undefined || y === undefined) return list;
out[a] = y;
out[b] = x;
return out;
}
function AddDepartment({ onAdded }: { onAdded: (r: DeptAdded) => void }) {
const [code, setCode] = useState("");
const [slug, setSlug] = useState("");
const [name, setName] = useState("");
const [kind, setKind] = useState<"shop" | "support">("shop");
const [weld, setWeld] = useState(false);
const [sup, setSup] = useState(true);
const [stations, setStations] = useState("");
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<Msg>(null);
const preview = kind === "shop" && RX_CODE.test(code) && RX_SLUG.test(slug) ? SHOP_LEVELS.map((l) => `PS-${code}-${slug}-${l}`) : [];
const submit = async (e: FormEvent) => {
e.preventDefault();
if (!RX_CODE.test(code)) {
setMsg({ ok: false, text: "The code is 2 to 4 capital letters or digits, like 15." });
return;
}
if (!RX_SLUG.test(slug)) {
setMsg({ ok: false, text: "The short name starts with a letter and has only letters and digits, like Paint." });
return;
}
if (!name.trim()) {
setMsg({ ok: false, text: "Give the department a name." });
return;
}
const list = stations
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean);
setBusy(true);
setMsg(null);
try {
const r = await admin9Api.addDepartment({ code, slug, name: name.trim(), kind, weld, supervisor_group: sup, stations: list });
setCode("");
setSlug("");
setName("");
setStations("");
onAdded(r);
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
setBusy(false);
}
};
return (
<form className="panel stack" onSubmit={submit} noValidate aria-labelledby="dept-add-h">
<h3 id="dept-add-h">Add department</h3>
<div className="frow">
<label className="f">
Code
<input className="in mono" value={code} maxLength={4} onChange={(e) => setCode(e.target.value.toUpperCase())} />
</label>
<label className="f">
Short name
<input className="in" value={slug} maxLength={31} onChange={(e) => setSlug(e.target.value)} />
</label>
<label className="f">
Name
<input className="in" value={name} maxLength={80} onChange={(e) => setName(e.target.value)} />
</label>
<label className="f">
Kind
<select className="in" aria-label="Kind" value={kind} onChange={(e) => setKind(e.target.value === "support" ? "support" : "shop")}>
<option value="shop">Shop</option>
<option value="support">Support</option>
</select>
</label>
</div>
<div className="frow">
<label className="ckl">
<input type="checkbox" checked={weld} onChange={(e) => setWeld(e.target.checked)} />
Weld department
</label>
<label className="ckl">
<input type="checkbox" checked={sup} onChange={(e) => setSup(e.target.checked)} />
Has a Supervisor group
</label>
</div>
<label className="f">
Stations, one per line
<textarea className="in" aria-label="Stations, one per line" value={stations} onChange={(e) => setStations(e.target.value)} />
</label>
{preview.length ? (
<div className="small">
IT will need to create these groups: <span className="mono">{preview.join(", ")}</span>
</div>
) : null}
<Notice msg={msg} />
<div>
<button className="btn pri" type="submit" disabled={busy}>
Add department
</button>
</div>
</form>
);
}
export function DepartmentsTab() {
const [load, reload] = useLoad((s) => admin9Api.departments(s));
const [editing, setEditing] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [added, setAdded] = useState<DeptAdded | null>(null);
const rows = load.state === "ok" ? [...load.data].sort((a, b) => a.sort - b.sort) : [];
const current = rows.find((d) => d.code === editing) ?? null;
return (
<div className="stack">
<div className="sec-h">
<span className="small muted">Each department maps to AD groups by its code and short name. Roles come from those groups.</span>
<span className="grow" />
<button className="btn sm pri" type="button" aria-expanded={adding} onClick={() => setAdding((a) => !a)}>
{adding ? "Close the new department form" : "New department"}
</button>
</div>
{added ? (
<div className="callout" role="status" aria-label="Groups to create">
<b>
Added {added.department.code} {added.department.name}.
</b>{" "}
{added.groups_to_create.length ? (
<>
Ask IT to create these groups in the PacketStation OU:
<ul className="chips plain">
{added.groups_to_create.map((g) => (
<li key={g} className="chip static mono">
{g}
</li>
))}
</ul>
</>
) : (
"No new groups are needed."
)}
</div>
) : null}
{adding ? (
<AddDepartment
onAdded={(r) => {
setAdded(r);
setAdding(false);
reload();
}}
/>
) : null}
<Problem load={load} />
{load.state === "ok" ? (
<div className="tw">
<table aria-label="Departments">
<thead>
<tr>
<th>Code</th>
<th>Name</th>
<th>Kind</th>
<th>Status</th>
<th>Groups</th>
<th>Stations</th>
<th />
</tr>
</thead>
<tbody>
{rows.map((d) => (
<tr key={d.code}>
<td className="mono">{d.code}</td>
<td>
{d.name} {d.weld ? <span className="pill st-ready">Weld</span> : null}
</td>
<td>{d.kind === "shop" ? "Shop" : "Support"}</td>
<td>{d.active ? <span className="pill st-ok">Active</span> : <span className="pill st-filed">Inactive</span>}</td>
<td>
<ul className="plain stack tight" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{d.groups.map((g) => (
<li key={g.name} className="small">
<span className="mono">{g.name}</span> <OuPill g={g} />
</li>
))}
</ul>
</td>
<td className="small">
{d.stations.filter((s) => s.active).map((s) => s.name).join(", ") || <span className="muted">None</span>}
</td>
<td>
<button className="btn sm ghost" type="button" aria-label={`Edit department ${d.code}`} onClick={() => setEditing(d.code)}>
Edit
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
{current ? <DeptEditor key={current.code} d={current} onSaved={reload} onClose={() => setEditing(null)} /> : null}
</div>
);
}
@@ -0,0 +1,277 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { adminBase } from "../../test/admin";
import { renderApp } from "../../test/auth";
import { callsTo, mockFetch, type MockRequest } from "../../test/fetchMock";
const ADMINS = { id: 1, kind: "group", group_dn: "CN=PS-Admins,OU=PacketStation,DC=pstest,DC=local", group_name: "PS-Admins", user_id: null, display_name: null, address: null };
const SECURITY_RULE = {
event_type: "security_alert",
label: "Security alert",
description: "Account cooldowns, disabled PINs and ended sessions.",
security: true,
dept_scoped: false,
param: null,
enabled: true,
delivery: "immediate",
digest_time: "07:00",
recipients: [ADMINS],
exclusions: [],
version: 1,
last_sent: { at: "2026-10-21T08:00:00Z", result: "sent" },
};
const CO_RULE = {
event_type: "co_waiting",
label: "Change order waiting",
description: "A change order pending review too long.",
security: false,
dept_scoped: true,
param: { name: "days", value: 3, default: 3 },
enabled: true,
delivery: "immediate",
digest_time: "07:00",
recipients: [ADMINS],
exclusions: [{ user_id: "u-5", display_name: "Sam Self", self: true }],
version: 4,
last_sent: null,
};
const ANSWER = {
dry_run: true,
smtp: { host: "app-03.pesco.com", port: 25, from: "[email protected]", starttls: true },
subject_prefix: "[Packet Station TEST]",
rules: [SECURITY_RULE, CO_RULE],
};
const SENT = {
rows: [
{
id: 3,
at: "2026-10-21T08:00:00Z",
event_type: "security_alert",
digest: false,
test: false,
subject: "[Packet Station TEST] Account cooldown: weld.walt",
recipients: [{ address: "[email protected]", user_id: "u-0", why: "group PS-Admins" }],
result: "sent",
error: null,
},
],
next_before: null,
};
const PICKABLE = [
{
id: "u-7",
sam_account: "sup.sue",
display_name: "Sue Super",
emp_no: "03100",
email: "[email protected]",
active: true,
roles: ["shop:03:supervisor"],
pin: { set: false, disabled: false },
flags: { emp_missing: false, emp_duplicate: false, disabled: false },
last_signin_at: null,
ad_synced_at: null,
},
];
function routes(extra: Parameters<typeof mockFetch>[0] = {}) {
return {
...adminBase(),
"GET /api/admin/notifications": { body: ANSWER },
"/api/admin/notifications/sent": { body: SENT },
"/api/admin/users": (req: MockRequest) => ({ body: req.url.includes("q=") ? PICKABLE : [] }),
...extra,
};
}
async function openTab() {
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Notifications" }));
return screen.findByRole("table", { name: "Notification rules" });
}
describe("Admin Notifications tab", () => {
it("lists one row per event type with the mail settings and the sent log", async () => {
mockFetch(routes());
const table = await openTab();
expect(screen.getByRole("tab", { name: "Notifications" })).toHaveAttribute("id", "admtab-notifications");
expect(screen.getByText(/Dry run: messages are logged/)).toBeInTheDocument();
expect(screen.getByText("app-03.pesco.com:25")).toBeInTheDocument();
const sec = within(table).getByText("Security alert").closest("tr") as HTMLElement;
expect(within(sec).getByText("Group PS-Admins")).toBeInTheDocument();
expect(within(sec).getByText("At once")).toBeInTheDocument();
const co = within(table).getByText("Change order waiting").closest("tr") as HTMLElement;
expect(within(co).getByText("After 3 days")).toBeInTheDocument();
expect(within(co).getByText("Never")).toBeInTheDocument();
const log = await screen.findByRole("table", { name: "Sent log" });
expect(await within(log).findByText("[email protected]")).toBeInTheDocument();
});
it("edits delivery, the parameter, recipients of every kind and exclusions, then saves with the version", async () => {
const fetch = mockFetch(
routes({
"/api/admin/notifications/groups": {
body: [{ dn: "CN=PS-03-WeldParts-Supervisor,OU=PacketStation,DC=pstest,DC=local", name: "PS-03-WeldParts-Supervisor", description: "Weld supervisors" }],
},
"PUT /api/admin/notifications/co_waiting": { body: { ...CO_RULE, version: 5 } },
}),
);
await openTab();
fireEvent.click(screen.getByRole("button", { name: "Edit Change order waiting" }));
const ed = await screen.findByRole("region", { name: "Change order waiting" });
fireEvent.change(within(ed).getByRole("combobox", { name: /Delivery/ }), { target: { value: "digest" } });
fireEvent.change(within(ed).getByLabelText("Digest time"), { target: { value: "06:30" } });
fireEvent.change(within(ed).getByLabelText("Raise after, days"), { target: { value: "5" } });
expect(within(ed).getByText("Default 3.")).toBeInTheDocument();
// AD group
fireEvent.change(within(ed).getByLabelText("Search AD groups"), { target: { value: "x" } });
fireEvent.click(within(ed).getByRole("button", { name: "Search groups" }));
expect(await within(ed).findByText("Type at least 2 letters.")).toBeInTheDocument();
fireEvent.change(within(ed).getByLabelText("Search AD groups"), { target: { value: "weld" } });
fireEvent.click(within(ed).getByRole("button", { name: "Search groups" }));
fireEvent.click(await within(ed).findByRole("button", { name: "Add group PS-03-WeldParts-Supervisor" }));
expect(callsTo(fetch, "GET", "/api/admin/notifications/groups")).toHaveLength(1);
// User
const kind = within(ed).getByRole("combobox", { name: /Add a recipient/ });
fireEvent.change(kind, { target: { value: "user" } });
fireEvent.change(within(ed).getByLabelText("Find a user to add"), { target: { value: "sue" } });
fireEvent.click(await within(ed).findByRole("button", { name: "Pick Sue Super" }));
// Mailbox
fireEvent.change(kind, { target: { value: "mailbox" } });
fireEvent.change(within(ed).getByLabelText("Mailbox address"), { target: { value: "not an address" } });
fireEvent.click(within(ed).getByRole("button", { name: "Add mailbox" }));
expect(await within(ed).findByText("That address does not look right.")).toBeInTheDocument();
fireEvent.change(within(ed).getByLabelText("Mailbox address"), { target: { value: "[email protected]" } });
fireEvent.click(within(ed).getByRole("button", { name: "Add mailbox" }));
// Department supervisor (offered because the event is department scoped)
fireEvent.change(kind, { target: { value: "dept_supervisor" } });
fireEvent.click(within(ed).getByRole("button", { name: "Add the department Supervisor group" }));
fireEvent.click(within(ed).getByRole("button", { name: "Remove recipient Group PS-Admins" }));
const recips = within(ed).getByRole("list", { name: "Recipients" });
expect(within(recips).getAllByRole("listitem")).toHaveLength(4);
// Exclusions: a self opt-out stays; add one
const excl = within(ed).getByRole("list", { name: "Exclusions" });
expect(within(excl).getByText("Opted out")).toBeInTheDocument();
fireEvent.change(within(ed).getByLabelText("Find a user to exclude"), { target: { value: "sue" } });
fireEvent.click(await within(ed).findByRole("button", { name: "Pick Sue Super" }));
expect(within(excl).getByRole("button", { name: "Remove exclusion Sue Super" })).toBeInTheDocument();
fireEvent.click(within(ed).getByRole("button", { name: "Save rule" }));
expect(await screen.findByText("Change order waiting: saved.")).toBeInTheDocument();
expect(callsTo(fetch, "PUT", "/api/admin/notifications/co_waiting")).toEqual([
{
enabled: true,
delivery: "digest",
digest_time: "06:30",
param: 5,
recipients: [
{ kind: "group", group_dn: "CN=PS-03-WeldParts-Supervisor,OU=PacketStation,DC=pstest,DC=local", group_name: "PS-03-WeldParts-Supervisor" },
{ kind: "user", user_id: "u-7" },
{ kind: "mailbox", address: "[email protected]" },
{ kind: "dept_supervisor" },
],
exclusions: ["u-5", "u-7"],
version: 4,
},
]);
});
it("allows no exclusions on a security event and offers no department kind", async () => {
mockFetch(routes());
await openTab();
fireEvent.click(screen.getByRole("button", { name: "Edit Security alert" }));
const ed = await screen.findByRole("region", { name: "Security alert" });
expect(within(ed).getByText(/Nobody can be excluded or opt out/)).toBeInTheDocument();
expect(within(ed).queryByLabelText("Find a user to exclude")).not.toBeInTheDocument();
expect(within(ed).queryByLabelText(/Raise after/)).not.toBeInTheDocument();
const kind = within(ed).getByRole("combobox", { name: /Add a recipient/ });
expect(within(kind).queryByRole("option", { name: "Department Supervisor group" })).not.toBeInTheDocument();
});
it("reloads on a 409", async () => {
const fetch = mockFetch(routes({ "PUT /api/admin/notifications/security_alert": { status: 409, body: { detail: "Changed", code: "version_conflict" } } }));
await openTab();
fireEvent.click(screen.getByRole("button", { name: "Edit Security alert" }));
const ed = await screen.findByRole("region", { name: "Security alert" });
fireEvent.click(within(ed).getByLabelText("Rule enabled"));
fireEvent.click(within(ed).getByRole("button", { name: "Save rule" }));
expect(await screen.findByText(/someone else saved it first/)).toBeInTheDocument();
await waitFor(() => expect(callsTo(fetch, "GET", "/api/admin/notifications")).toHaveLength(2));
expect(callsTo(fetch, "PUT", "/api/admin/notifications/security_alert")).toEqual([
{ enabled: false, delivery: "immediate", digest_time: "07:00", param: null, recipients: [{ kind: "group", group_dn: ADMINS.group_dn, group_name: "PS-Admins" }], exclusions: [], version: 1 },
]);
});
it("sends a test to an address and shows the result and error", async () => {
const fetch = mockFetch(
routes({
"POST /api/admin/notifications/security_alert/test": {
body: { result: "failed", subject: "[Packet Station TEST] Test: Security alert", recipients: [{ address: "[email protected]" }], error: "connection refused", sent_id: 9 },
},
}),
);
await openTab();
fireEvent.click(screen.getByRole("button", { name: "Edit Security alert" }));
const ed = await screen.findByRole("region", { name: "Security alert" });
fireEvent.change(within(ed).getByLabelText("Send the test to"), { target: { value: "[email protected]" } });
fireEvent.click(within(ed).getByRole("button", { name: "Send a test" }));
const res = await within(ed).findByRole("status", { name: "Test result" });
expect(res).toHaveTextContent("failed");
expect(res).toHaveTextContent("Error: connection refused");
expect(res).toHaveTextContent("To: [email protected]");
expect(callsTo(fetch, "POST", "/api/admin/notifications/security_alert/test")).toEqual([{ to: "[email protected]" }]);
await waitFor(() => expect(callsTo(fetch, "GET", "/api/admin/notifications/sent").length).toBeGreaterThanOrEqual(2));
});
it("sends a test to the saved recipients with no address", async () => {
const fetch = mockFetch(
routes({
"POST /api/admin/notifications/co_waiting/test": {
body: { result: "sent", subject: "s", recipients: [{ address: "[email protected]", why: "group PS-Admins" }], error: null, sent_id: 10 },
},
}),
);
await openTab();
fireEvent.click(screen.getByRole("button", { name: "Edit Change order waiting" }));
const ed = await screen.findByRole("region", { name: "Change order waiting" });
fireEvent.click(within(ed).getByRole("button", { name: "Send a test" }));
expect(await within(ed).findByText("To: [email protected] (group PS-Admins)")).toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/admin/notifications/co_waiting/test")).toEqual([{}]);
});
it("says when AD is down during a group search", async () => {
mockFetch(routes({ "/api/admin/notifications/groups": { status: 503, body: { detail: "Directory unavailable" } } }));
await openTab();
fireEvent.click(screen.getByRole("button", { name: "Edit Security alert" }));
const ed = await screen.findByRole("region", { name: "Security alert" });
fireEvent.change(within(ed).getByLabelText("Search AD groups"), { target: { value: "admins" } });
fireEvent.click(within(ed).getByRole("button", { name: "Search groups" }));
expect(await within(ed).findByText("AD is unreachable. Try again later.")).toBeInTheDocument();
});
it("filters the sent log by event type", async () => {
const urls: string[] = [];
mockFetch(
routes({
"/api/admin/notifications/sent": (req: MockRequest) => {
urls.push(req.url);
return { body: SENT };
},
}),
);
await openTab();
fireEvent.change(screen.getByRole("combobox", { name: /Event filter/ }), { target: { value: "co_waiting" } });
await waitFor(() => expect(urls.some((u) => u.includes("event_type=co_waiting"))).toBe(true));
});
});
@@ -0,0 +1,591 @@
// Admin: who gets mail for each event type, how, and what was sent (phase 9).
import { useEffect, useState, type FormEvent } from "react";
import {
admin9Api,
type AdGroupHit,
type NotifyAnswer,
type NotifyRule,
type Recipient,
type RecipientKind,
type RulePut,
type SentRow,
type TestSendAnswer,
} from "../../api/admin";
import { ApiError } from "../../api/client";
import { errorText, fmtTime } from "../../lib/format";
import { useLoad } from "../../lib/useLoad";
import { Notice, Problem, UserPicker, type Msg } from "./common";
const RX_ADDRESS = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const RX_TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
const KIND_LABEL: Record<RecipientKind, string> = {
group: "AD group",
user: "User",
mailbox: "Mailbox",
dept_supervisor: "Department Supervisor group",
};
function recipientText(r: Recipient): string {
switch (r.kind) {
case "group":
return `Group ${r.group_name ?? r.group_dn ?? ""}`;
case "user":
return `User ${r.display_name ?? r.user_id ?? ""}`;
case "mailbox":
return `Mailbox ${r.address ?? ""}`;
case "dept_supervisor":
return "Supervisor group of the event's department";
}
}
function deliveryText(r: NotifyRule): string {
return r.delivery === "digest" ? `Daily digest at ${r.digest_time}` : "At once";
}
function resultPill(result: string) {
const cls = result === "sent" ? "pill st-ok" : result === "dry_run" || result === "no_recipients" ? "pill st-wait" : "pill st-bad";
return <span className={cls}>{result.replace(/_/g, " ")}</span>;
}
/* ---------- AD group search ---------- */
function GroupSearch({ onPick }: { onPick: (g: AdGroupHit) => void }) {
const [q, setQ] = useState("");
const [hits, setHits] = useState<AdGroupHit[] | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const search = async () => {
if (q.trim().length < 2) {
setError("Type at least 2 letters.");
return;
}
setBusy(true);
setError(null);
try {
setHits(await admin9Api.searchGroups(q.trim()));
} catch (err) {
setHits(null);
setError(err instanceof ApiError && err.status === 503 ? "AD is unreachable. Try again later." : errorText(err));
} finally {
setBusy(false);
}
};
return (
<div className="stack tight">
<div className="frow">
<label className="f">
Search AD groups
<input
className="in"
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void search();
}
}}
/>
</label>
<div className="end">
<button className="btn sm" type="button" disabled={busy} onClick={() => void search()}>
Search groups
</button>
</div>
</div>
{error ? <div className="callout warn flush">{error}</div> : null}
{hits && hits.length === 0 ? <div className="small muted">No group matches.</div> : null}
{hits && hits.length ? (
<ul className="stack tight" aria-label="Groups found" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{hits.map((g) => (
<li key={g.dn}>
<button className="btn sm ghost" type="button" aria-label={`Add group ${g.name}`} onClick={() => onPick(g)}>
Add
</button>{" "}
<span className="mono small">{g.name}</span> {g.description ? <span className="small muted">{g.description}</span> : null}
</li>
))}
</ul>
) : null}
</div>
);
}
/* ---------- One rule ---------- */
type Excl = { user_id: string; display_name: string | null; self: boolean };
function RuleEditor({
rule,
onSaved,
onConflict,
onTested,
onClose,
}: {
rule: NotifyRule;
onSaved: () => void;
onConflict: () => void;
onTested: () => void;
onClose: () => void;
}) {
const [enabled, setEnabled] = useState(rule.enabled);
const [delivery, setDelivery] = useState(rule.delivery);
const [digestTime, setDigestTime] = useState(rule.digest_time);
const [param, setParam] = useState(rule.param ? String(rule.param.value) : "");
const [recipients, setRecipients] = useState<Recipient[]>(rule.recipients);
const [exclusions, setExclusions] = useState<Excl[]>(rule.exclusions);
const [addKind, setAddKind] = useState<RecipientKind>("group");
const [mailbox, setMailbox] = useState("");
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<Msg>(null);
const [testTo, setTestTo] = useState("");
const [test, setTest] = useState<TestSendAnswer | null>(null);
const [testMsg, setTestMsg] = useState<Msg>(null);
const kinds: RecipientKind[] = rule.dept_scoped ? ["group", "user", "mailbox", "dept_supervisor"] : ["group", "user", "mailbox"];
const addRecipient = (r: Recipient) => {
const same = (a: Recipient) =>
a.kind === r.kind &&
(r.kind === "group"
? a.group_dn?.toLowerCase() === r.group_dn?.toLowerCase()
: r.kind === "user"
? a.user_id === r.user_id
: r.kind === "mailbox"
? a.address?.toLowerCase() === r.address?.toLowerCase()
: true);
setRecipients((cur) => (cur.some(same) ? cur : [...cur, r]));
};
const save = async (e: FormEvent) => {
e.preventDefault();
if (delivery === "digest" && !RX_TIME.test(digestTime)) {
setMsg({ ok: false, text: "Give the digest time as HH:MM." });
return;
}
let p: number | null = null;
if (rule.param) {
p = Number(param);
if (!param.trim() || !Number.isInteger(p) || p < 1) {
setMsg({ ok: false, text: `Give a whole number of ${rule.param.name}, 1 or more.` });
return;
}
}
const body: RulePut = {
enabled,
delivery,
digest_time: digestTime,
param: p,
recipients: recipients.map((r) => {
if (r.kind === "group") return { kind: r.kind, group_dn: r.group_dn ?? "", group_name: r.group_name ?? "" };
if (r.kind === "user") return { kind: r.kind, user_id: r.user_id ?? "" };
if (r.kind === "mailbox") return { kind: r.kind, address: r.address ?? "" };
return { kind: r.kind };
}),
exclusions: rule.security ? [] : exclusions.map((x) => x.user_id),
version: rule.version,
};
setBusy(true);
setMsg(null);
try {
await admin9Api.saveRule(rule.event_type, body);
onSaved();
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
onConflict();
} else {
setMsg({ ok: false, text: errorText(err) });
}
} finally {
setBusy(false);
}
};
const sendTest = async () => {
const to = testTo.trim();
if (to && !RX_ADDRESS.test(to)) {
setTestMsg({ ok: false, text: "That address does not look right." });
return;
}
setTestMsg(null);
setTest(null);
try {
setTest(await admin9Api.testSend(rule.event_type, to || undefined));
onTested();
} catch (err) {
setTestMsg({ ok: false, text: errorText(err) });
}
};
const testRecipients = (t: TestSendAnswer) =>
t.recipients.map((r) => (typeof r === "string" ? r : r.why ? `${r.address} (${r.why})` : r.address)).join(", ");
return (
<section className="panel stack" aria-labelledby="rule-h">
<div className="sec-h flush">
<h3 id="rule-h">{rule.label}</h3>
{rule.security ? <span className="pill st-bad">Security</span> : null}
<span className="grow" />
<button className="btn sm ghost" type="button" onClick={onClose}>
Close
</button>
</div>
<div className="small muted">{rule.description}</div>
<form className="stack" onSubmit={save} noValidate>
<div className="frow">
<label className="ckl">
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
Rule enabled
</label>
<label className="f">
Delivery
<select className="in" aria-label="Delivery" value={delivery} onChange={(e) => setDelivery(e.target.value === "digest" ? "digest" : "immediate")}>
<option value="immediate">At once</option>
<option value="digest">Daily digest</option>
</select>
</label>
<label className="f">
Digest time
<input className="in mono" type="time" value={digestTime} disabled={delivery !== "digest"} onChange={(e) => setDigestTime(e.target.value)} />
</label>
{rule.param ? (
<div className="stack tight">
<label className="f" style={{ alignSelf: "stretch" }}>
{rule.param.name === "days" ? "Raise after, days" : "Raise after, hours"}
<input className="in mono" type="number" min={1} value={param} onChange={(e) => setParam(e.target.value)} />
</label>
<span className="small muted">Default {rule.param.default}.</span>
</div>
) : null}
</div>
<h3>Recipients</h3>
{recipients.length === 0 ? <div className="callout warn flush">No recipients. Nothing is sent.</div> : null}
<ul className="stack tight" aria-label="Recipients" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{recipients.map((r, i) => {
const text = recipientText(r);
return (
<li key={`${r.kind}-${r.group_dn ?? r.user_id ?? r.address ?? ""}-${i}`}>
<button
className="btn sm ghost"
type="button"
aria-label={`Remove recipient ${text}`}
onClick={() => setRecipients((cur) => cur.filter((_, j) => j !== i))}
>
Remove
</button>{" "}
{text}
</li>
);
})}
</ul>
<div className="panel stack">
<label className="f">
Add a recipient
<select className="in" aria-label="Add a recipient" value={addKind} onChange={(e) => setAddKind(e.target.value as RecipientKind)}>
{kinds.map((k) => (
<option key={k} value={k}>
{KIND_LABEL[k]}
</option>
))}
</select>
</label>
{addKind === "group" ? (
<GroupSearch onPick={(g) => addRecipient({ kind: "group", group_dn: g.dn, group_name: g.name })} />
) : null}
{addKind === "user" ? (
<UserPicker label="Find a user to add" onPick={(u) => addRecipient({ kind: "user", user_id: u.id, display_name: u.display_name })} />
) : null}
{addKind === "mailbox" ? (
<div className="frow">
<label className="f">
Mailbox address
<input className="in" type="email" value={mailbox} onChange={(e) => setMailbox(e.target.value)} />
</label>
<div className="end">
<button
className="btn sm"
type="button"
onClick={() => {
const a = mailbox.trim();
if (!RX_ADDRESS.test(a)) {
setMsg({ ok: false, text: "That address does not look right." });
return;
}
addRecipient({ kind: "mailbox", address: a });
setMailbox("");
setMsg(null);
}}
>
Add mailbox
</button>
</div>
</div>
) : null}
{addKind === "dept_supervisor" ? (
<div>
<button className="btn sm" type="button" onClick={() => addRecipient({ kind: "dept_supervisor" })}>
Add the department Supervisor group
</button>
</div>
) : null}
</div>
<h3>Exclusions</h3>
{rule.security ? (
<div className="callout flush">Security events reach everyone on the list. Nobody can be excluded or opt out.</div>
) : (
<>
{exclusions.length === 0 ? <div className="small muted">Nobody is excluded.</div> : null}
<ul className="stack tight" aria-label="Exclusions" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{exclusions.map((x) => (
<li key={x.user_id}>
{x.self ? (
<span className="pill st-wait">Opted out</span>
) : (
<button
className="btn sm ghost"
type="button"
aria-label={`Remove exclusion ${x.display_name ?? x.user_id}`}
onClick={() => setExclusions((cur) => cur.filter((y) => y.user_id !== x.user_id))}
>
Remove
</button>
)}{" "}
{x.display_name ?? x.user_id}
</li>
))}
</ul>
<UserPicker
label="Find a user to exclude"
exclude={exclusions.map((x) => x.user_id)}
onPick={(u) => setExclusions((cur) => [...cur, { user_id: u.id, display_name: u.display_name, self: false }])}
/>
</>
)}
<Notice msg={msg} />
<div>
<button className="btn pri" type="submit" disabled={busy}>
Save rule
</button>
</div>
</form>
<h3>Send a test</h3>
<div className="small muted">A test goes out at once, even in dry run. Leave the address empty to send it to the saved recipients.</div>
<div className="frow">
<label className="f">
Send the test to
<input className="in" type="email" placeholder="Optional" value={testTo} onChange={(e) => setTestTo(e.target.value)} />
</label>
<div className="end">
<button className="btn" type="button" onClick={() => void sendTest()}>
Send a test
</button>
</div>
</div>
<Notice msg={testMsg} />
{test ? (
<div className={test.result === "sent" ? "callout flush" : "callout warn flush"} role="status" aria-label="Test result">
<div>
Result: {resultPill(test.result)} <span className="mono small">{test.subject}</span>
</div>
{test.recipients.length ? <div className="small">To: {testRecipients(test)}</div> : <div className="small">No recipients.</div>}
{test.error ? <div className="small">Error: {test.error}</div> : null}
</div>
) : null}
</section>
);
}
/* ---------- Sent log ---------- */
function SentLog({ rules, tick }: { rules: NotifyRule[]; tick: number }) {
const [eventType, setEventType] = useState("");
const [rows, setRows] = useState<SentRow[]>([]);
const [next, setNext] = useState<string | number | null>(null);
const [error, setError] = useState<string | null>(null);
const page = async (before: string | number | null) => {
try {
const r = await admin9Api.sent({ event_type: eventType || undefined, limit: 50, before: before ?? undefined });
setRows((cur) => (before ? [...cur, ...r.rows] : r.rows));
setNext(r.next_before);
setError(null);
} catch (err) {
setError(errorText(err));
}
};
useEffect(() => {
void page(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [eventType, tick]);
const label = (t: string) => (t === "digest" ? "Digest" : (rules.find((r) => r.event_type === t)?.label ?? t));
return (
<section className="sec stack" aria-labelledby="sent-h">
<div className="sec-h flush">
<h3 id="sent-h">Sent log</h3>
<label className="f">
Event filter
<select className="in" aria-label="Event filter" value={eventType} onChange={(e) => setEventType(e.target.value)}>
<option value="">All events</option>
<option value="digest">Digests</option>
{rules.map((r) => (
<option key={r.event_type} value={r.event_type}>
{r.label}
</option>
))}
</select>
</label>
</div>
{error ? <div className="callout warn flush">{error}</div> : null}
<div className="tw">
<table aria-label="Sent log">
<thead>
<tr>
<th>When</th>
<th>Event</th>
<th>Subject</th>
<th>Recipients</th>
<th>Result</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td className="small mono nowrap">{fmtTime(r.at)}</td>
<td>
{label(r.event_type)} {r.test ? <span className="pill st-ready">Test</span> : null}
</td>
<td className="small">{r.subject}</td>
<td className="small">{r.recipients.map((x) => x.address).join(", ") || <span className="muted">None</span>}</td>
<td>
{resultPill(r.result)}
{r.error ? <div className="small muted">{r.error}</div> : null}
</td>
</tr>
))}
{rows.length === 0 ? (
<tr>
<td colSpan={5} className="muted">
Nothing sent yet.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
{next ? (
<div>
<button className="btn sm" type="button" onClick={() => void page(next)}>
Load older messages
</button>
</div>
) : null}
</section>
);
}
/* ---------- The tab ---------- */
function Header({ n }: { n: NotifyAnswer }) {
return (
<div className="panel small" aria-label="Mail settings" role="group">
{n.dry_run ? (
<div className="callout warn flush">Dry run: messages are logged, not sent. A test send still goes out.</div>
) : null}
<div>
Relay <span className="mono">{n.smtp.host ?? "not set"}{n.smtp.port ? `:${n.smtp.port}` : ""}</span>, {n.smtp.starttls ? "STARTTLS" : "no STARTTLS"}, from{" "}
<span className="mono">{n.smtp.from ?? "-"}</span>. Subjects start with <span className="mono">{n.subject_prefix}</span>.
</div>
</div>
);
}
export function NotificationsTab() {
const [load, reload] = useLoad((s) => admin9Api.notifications(s));
const [open, setOpen] = useState<string | null>(null);
const [msg, setMsg] = useState<Msg>(null);
const [tick, setTick] = useState(0);
if (load.state !== "ok") return <Problem load={load} />;
const n = load.data;
const rule = n.rules.find((r) => r.event_type === open) ?? null;
return (
<div className="stack">
<Header n={n} />
<div className="tw">
<table aria-label="Notification rules">
<thead>
<tr>
<th>Event</th>
<th>Status</th>
<th>Delivery</th>
<th>Recipients</th>
<th>Last sent</th>
<th />
</tr>
</thead>
<tbody>
{n.rules.map((r) => (
<tr key={r.event_type} className={open === r.event_type ? "rowlink on" : undefined}>
<td>
{r.label} {r.security ? <span className="pill st-bad">Security</span> : null}
{r.param ? (
<div className="small muted">
After {r.param.value} {r.param.name}
</div>
) : null}
</td>
<td>{r.enabled ? <span className="pill st-ok">On</span> : <span className="pill st-filed">Off</span>}</td>
<td className="small">{deliveryText(r)}</td>
<td className="small">{r.recipients.map(recipientText).join(", ") || <span className="muted">None</span>}</td>
<td className="small">
{r.last_sent ? (
<>
{fmtTime(r.last_sent.at)} {resultPill(r.last_sent.result)}
</>
) : (
<span className="muted">Never</span>
)}
</td>
<td>
<button className="btn sm ghost" type="button" aria-label={`Edit ${r.label}`} onClick={() => setOpen(r.event_type)}>
Edit
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Notice msg={msg} />
{rule ? (
<RuleEditor
key={`${rule.event_type}-${rule.version}`}
rule={rule}
onSaved={() => {
setMsg({ ok: true, text: `${rule.label}: saved.` });
reload();
setTick((t) => t + 1);
}}
onConflict={() => {
setMsg({ ok: false, text: `${rule.label}: someone else saved it first. Their version is loaded now.` });
reload();
}}
onTested={() => setTick((t) => t + 1)}
onClose={() => setOpen(null)}
/>
) : null}
<SentLog rules={n.rules} tick={tick} />
</div>
);
}
@@ -0,0 +1,64 @@
import { fireEvent, screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { adminBase } from "../../test/admin";
import { renderApp } from "../../test/auth";
import { callsTo, mockFetch } from "../../test/fetchMock";
const PRESENCE = {
at: "2026-10-21T09:00:00Z",
sessions: [
{
session_id: "s-1",
user_id: "u-1",
display_name: "Walt Welder",
kind: "pin",
device_name: "Weld Parts Stall 3",
ip: "10.0.0.9",
created_at: "2026-10-21T07:00:00Z",
last_seen_at: "2026-10-21T08:59:00Z",
locked: false,
screen: "stall",
packet_id: 42,
},
{
session_id: "s-2",
user_id: "u-2",
display_name: "Clara Clerk",
kind: "password",
device_name: null,
ip: "10.0.0.5",
created_at: "2026-10-21T08:00:00Z",
last_seen_at: "2026-10-21T08:30:00Z",
locked: true,
screen: null,
packet_id: null,
},
],
};
describe("Admin Who is in tab", () => {
it("lists live sessions with screen, packet and lock state, and refreshes", async () => {
const fetch = mockFetch({ ...adminBase(), "/api/admin/presence": { body: PRESENCE } });
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Who is in" }));
expect(screen.getByRole("tab", { name: "Who is in" })).toHaveAttribute("id", "admtab-presence");
const table = await screen.findByRole("table", { name: "Who is in" });
const walt = within(table).getByText("Walt Welder").closest("tr") as HTMLElement;
expect(within(walt).getByText("PIN")).toBeInTheDocument();
expect(within(walt).getByText("Weld Parts Stall 3")).toBeInTheDocument();
expect(within(walt).getByRole("link", { name: "Packet 42" })).toHaveAttribute("href", "/packets/42");
const clara = within(table).getByText("Clara Clerk").closest("tr") as HTMLElement;
expect(within(clara).getByText("Locked")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Refresh who is in" }));
await screen.findByRole("table", { name: "Who is in" });
expect(callsTo(fetch, "GET", "/api/admin/presence").length).toBeGreaterThanOrEqual(1);
});
it("says when nobody is signed in", async () => {
mockFetch({ ...adminBase(), "/api/admin/presence": { body: { at: "2026-10-21T09:00:00Z", sessions: [] } } });
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Who is in" }));
expect(await screen.findByText("Nobody is signed in.")).toBeInTheDocument();
});
});
@@ -0,0 +1,81 @@
// Admin: who is signed in now, and which screen each one has open (phase 9).
import { useEffect } from "react";
import { Link } from "react-router";
import { admin9Api } from "../../api/admin";
import { fmtAgo, fmtTime } from "../../lib/format";
import { useLoad } from "../../lib/useLoad";
import { Problem } from "./common";
const REFRESH_MS = 30_000;
export function PresenceTab() {
const [load, reload] = useLoad((s) => admin9Api.presence(s));
useEffect(() => {
const t = window.setInterval(reload, REFRESH_MS);
return () => window.clearInterval(t);
}, [reload]);
const rows = load.state === "ok" ? load.data.sessions : [];
return (
<div className="stack">
<div className="sec-h">
<span className="small muted">
Live sessions seen within the idle time.{load.state === "ok" ? ` As of ${fmtTime(load.data.at)}.` : ""} Refreshes every 30 seconds.
</span>
<span className="grow" />
<button className="btn sm ghost" type="button" onClick={reload}>
Refresh who is in
</button>
</div>
<Problem load={load} />
{load.state === "ok" ? (
<div className="tw">
<table aria-label="Who is in">
<thead>
<tr>
<th>Name</th>
<th>Sign-in</th>
<th>Device</th>
<th>Address</th>
<th>Screen</th>
<th>Started</th>
<th>Last seen</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{rows.map((s) => (
<tr key={s.session_id}>
<td>{s.display_name}</td>
<td>{s.kind === "pin" ? "PIN" : "Password"}</td>
<td>{s.device_name ?? <span className="muted">-</span>}</td>
<td className="mono small">{s.ip ?? "-"}</td>
<td className="small">
{s.screen ?? <span className="muted">-</span>}
{s.packet_id ? (
<>
{" "}
<Link to={`/packets/${s.packet_id}`}>Packet {s.packet_id}</Link>
</>
) : null}
</td>
<td className="small mono nowrap">{fmtTime(s.created_at)}</td>
<td className="small mono nowrap">{fmtAgo(s.last_seen_at)}</td>
<td>{s.locked ? <span className="pill st-wait">Locked</span> : <span className="pill st-ok">Active</span>}</td>
</tr>
))}
{rows.length === 0 ? (
<tr>
<td colSpan={8} className="muted">
Nobody is signed in.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,200 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ADMIN_DEPTS, adminBase } from "../../test/admin";
import { renderApp } from "../../test/auth";
import { callsTo, mockFetch, type MockRequest } from "../../test/fetchMock";
const USERS = [
{
id: "u-1",
sam_account: "weld.walt",
display_name: "Walt Welder",
emp_no: "03001",
email: null,
active: true,
roles: ["shop:03:worker"],
pin: { set: true, disabled: false },
flags: { emp_missing: false, emp_duplicate: false, disabled: false },
last_signin_at: null,
ad_synced_at: null,
welder_stamp: "W12",
profile_active: true,
station_limits: { "03": [31] },
directory_flags: {},
},
{
id: "u-2",
sam_account: "old.olga",
display_name: "Olga Old",
emp_no: "03002",
email: null,
active: true,
roles: ["shop:03:worker"],
pin: { set: false, disabled: false },
flags: { emp_missing: false, emp_duplicate: false, disabled: false },
last_signin_at: null,
ad_synced_at: null,
welder_stamp: null,
profile_active: false,
station_limits: {},
directory_flags: {},
},
];
const FLAGS = {
checked_at: "2026-10-21T01:31:00Z",
missing: [{ user_id: "u-9", sam_account: "noemp.nora", display_name: "Nora NoEmp" }],
duplicate: [
{
emp_no: "9001",
users: [
{ user_id: "u-7", sam_account: "dup.dave", display_name: "Dave Dup" },
{ user_id: "u-8", sam_account: "dup.dora", display_name: "Dora Dup" },
],
},
],
};
function routes(extra: Record<string, unknown> = {}) {
return {
...adminBase(),
"/api/admin/users": { body: USERS },
"/api/admin/departments": { body: ADMIN_DEPTS },
"/api/admin/directory-flags": { body: FLAGS },
...extra,
} as Parameters<typeof mockFetch>[0];
}
async function openTab() {
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Profiles" }));
return screen.findByRole("table", { name: "Profiles" });
}
describe("Admin Profiles tab", () => {
it("lists profiles with stamp and limits, and shows the directory flags", async () => {
mockFetch(routes());
const table = await openTab();
const walt = within(table).getByText("Walt Welder").closest("tr") as HTMLElement;
expect(within(walt).getByText("W12")).toBeInTheDocument();
expect(await within(walt).findByText("03:Stall 1")).toBeInTheDocument();
const olga = within(table).getByText("Olga Old").closest("tr") as HTMLElement;
expect(within(olga).getByText("Inactive")).toBeInTheDocument();
expect(within(olga).getByText("No limit")).toBeInTheDocument();
const missing = await screen.findByRole("list", { name: "Users with no employee ID" });
expect(within(missing).getByText("Nora NoEmp")).toBeInTheDocument();
const dup = screen.getByRole("list", { name: "Duplicate employee IDs" });
expect(dup).toHaveTextContent("9001: Dave Dup (dup.dave), Dora Dup (dup.dora)");
});
it("saves only what changed in a profile", async () => {
const fetch = mockFetch(routes({ "PATCH /api/admin/users/u-1/profile": { body: USERS[0] } }));
await openTab();
fireEvent.click(screen.getByRole("button", { name: "Edit profile of Walt Welder" }));
const form = await screen.findByRole("form", { name: "Profile of Walt Welder" });
fireEvent.change(within(form).getByLabelText("Welder stamp"), { target: { value: "W 12" } });
fireEvent.click(within(form).getByRole("button", { name: "Save profile" }));
expect(await within(form).findByRole("alert")).toHaveTextContent("1 to 12 letters, digits or dashes");
fireEvent.change(within(form).getByLabelText("Welder stamp"), { target: { value: "W-12" } });
fireEvent.click(within(form).getByLabelText("03 Stall 2"));
fireEvent.click(within(form).getByLabelText("Profile active"));
expect(within(form).queryByLabelText("03 Old Stall")).not.toBeInTheDocument();
fireEvent.click(within(form).getByRole("button", { name: "Save profile" }));
expect(await within(form).findByText("Profile saved.")).toBeInTheDocument();
expect(callsTo(fetch, "PATCH", "/api/admin/users/u-1/profile")).toEqual([
{ welder_stamp: "W-12", profile_active: false, station_limits: { "03": [31, 32] } },
]);
});
it("previews a CSV with errors and keeps Apply off", async () => {
const fetch = mockFetch(
routes({
"POST /api/admin/users/import": {
body: {
rows: [
{ line: 2, employee_id: "03001", user_id: "u-1", display_name: "Walt Welder", changes: { welder_stamp: { from: "W12", to: "W13" } }, errors: [] },
{ line: 3, employee_id: "99999", user_id: null, display_name: null, changes: {}, errors: ["unknown employee ID"] },
],
ok: 1,
errors: 1,
applied: false,
},
},
}),
);
await openTab();
const apply = screen.getByRole("button", { name: "Apply import" });
expect(apply).toBeDisabled();
fireEvent.change(screen.getByLabelText("CSV text"), { target: { value: "employee_id,welder_stamp\n03001,W13\n99999,X1\n" } });
fireEvent.click(screen.getByRole("button", { name: "Preview import" }));
const table = await screen.findByRole("table", { name: "Import preview" });
expect(within(table).getByText("welder stamp: W12 to W13")).toBeInTheDocument();
expect(within(table).getByText("unknown employee ID")).toBeInTheDocument();
expect(screen.getByText("1 row ready, 1 with errors.")).toBeInTheDocument();
expect(apply).toBeDisabled();
expect(callsTo(fetch, "POST", "/api/admin/users/import")).toEqual([{ csv: "employee_id,welder_stamp\n03001,W13\n99999,X1\n", apply: false }]);
});
it("applies a clean preview, and needs a new preview after an edit", async () => {
const clean = {
rows: [{ line: 2, employee_id: "03002", user_id: "u-2", display_name: "Olga Old", changes: { active: { from: false, to: true } }, errors: [] }],
ok: 1,
errors: 0,
applied: false,
};
const fetch = mockFetch(
routes({
"POST /api/admin/users/import": (req: MockRequest) => ({ body: { ...clean, applied: (req.body as { apply: boolean }).apply } }),
}),
);
await openTab();
const csv = "employee_id,active\n03002,yes\n";
fireEvent.change(screen.getByLabelText("CSV text"), { target: { value: csv } });
fireEvent.click(screen.getByRole("button", { name: "Preview import" }));
expect(await screen.findByText("active: no to yes")).toBeInTheDocument();
const apply = screen.getByRole("button", { name: "Apply import" });
await waitFor(() => expect(apply).toBeEnabled());
fireEvent.change(screen.getByLabelText("CSV text"), { target: { value: csv + "\n" } });
expect(apply).toBeDisabled();
expect(screen.getByText(/Preview it again/)).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("CSV text"), { target: { value: csv } });
await waitFor(() => expect(apply).toBeEnabled());
fireEvent.click(apply);
expect(await screen.findByText("Applied. 1 user updated.")).toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/admin/users/import")).toEqual([
{ csv, apply: false },
{ csv, apply: true },
]);
});
it("shows the rows of a 422 on apply and changes nothing", async () => {
let calls = 0;
mockFetch(
routes({
"POST /api/admin/users/import": () => {
calls += 1;
const body = {
rows: [{ line: 2, employee_id: "03001", user_id: "u-1", display_name: "Walt Welder", changes: {}, errors: calls > 1 ? ["stamp taken"] : [] }],
ok: calls > 1 ? 0 : 1,
errors: calls > 1 ? 1 : 0,
applied: false,
};
return calls > 1 ? { status: 422, body } : { body };
},
}),
);
await openTab();
fireEvent.change(screen.getByLabelText("CSV text"), { target: { value: "employee_id,welder_stamp\n03001,W99\n" } });
fireEvent.click(screen.getByRole("button", { name: "Preview import" }));
const apply = screen.getByRole("button", { name: "Apply import" });
await waitFor(() => expect(apply).toBeEnabled());
fireEvent.click(apply);
expect(await screen.findByText("stamp taken")).toBeInTheDocument();
expect(screen.getByRole("alert")).toHaveTextContent("Nothing was applied.");
expect(apply).toBeDisabled();
});
});
+386
View File
@@ -0,0 +1,386 @@
// Admin: the Packet Station profile of each user (welder stamp, station limits, active), the CSV import, and the
// directory flags for missing and duplicate employee IDs (phase 9).
import { useEffect, useState, type ChangeEvent, type FormEvent } from "react";
import { admin9Api, type AdminDepartment, type ImportAnswer, type ProfileUser } from "../../api/admin";
import { ApiError } from "../../api/client";
import { errorText, fmtTime } from "../../lib/format";
import { useLoad } from "../../lib/useLoad";
import { Notice, Problem, type Msg } from "./common";
const RX_STAMP = /^[A-Za-z0-9-]{1,12}$/;
function show(v: unknown): string {
if (v === null || v === undefined || v === "") return "none";
if (Array.isArray(v)) return v.length ? v.join("; ") : "none";
if (typeof v === "boolean") return v ? "yes" : "no";
if (typeof v === "object") return JSON.stringify(v);
return String(v);
}
/* ---------- Directory flags ---------- */
export function DirectoryFlagsPanel() {
const [load] = useLoad((s) => admin9Api.directoryFlags(s));
if (load.state !== "ok") return <Problem load={load} />;
const f = load.data;
const clean = f.missing.length === 0 && f.duplicate.length === 0;
return (
<section className="panel stack" aria-labelledby="dirflags-h">
<div className="sec-h flush">
<h3 id="dirflags-h">Directory flags</h3>
<span className="ct">{f.checked_at ? `From the reconcile ${fmtTime(f.checked_at)}` : "Not checked yet"}</span>
</div>
{clean ? <div className="small muted">Every active user has a unique employee ID in AD.</div> : null}
{f.missing.length ? (
<div>
<b>No employee ID in AD:</b>
<ul aria-label="Users with no employee ID" className="chips plain">
{f.missing.map((u) => (
<li key={u.user_id} className="chip static">
{u.display_name} <span className="mono small muted">{u.sam_account}</span>
</li>
))}
</ul>
</div>
) : null}
{f.duplicate.length ? (
<div>
<b>Employee IDs on more than one account:</b>
<ul aria-label="Duplicate employee IDs" className="stack tight" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{f.duplicate.map((d) => (
<li key={d.emp_no}>
<span className="mono">{d.emp_no}</span>: {d.users.map((u) => `${u.display_name} (${u.sam_account})`).join(", ")}
</li>
))}
</ul>
</div>
) : null}
{!clean ? <div className="small muted">Ask IT to fix the employeeID attribute in AD. The CSV import skips these users.</div> : null}
</section>
);
}
/* ---------- One user's profile ---------- */
function ProfileEditor({ u, depts, onSaved, onClose }: { u: ProfileUser; depts: AdminDepartment[]; onSaved: () => void; onClose: () => void }) {
const [stamp, setStamp] = useState(u.welder_stamp ?? "");
const [active, setActive] = useState(u.profile_active ?? true);
const [limits, setLimits] = useState<Record<string, number[]>>(u.station_limits ?? {});
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<Msg>(null);
const shop = depts.filter((d) => d.kind === "shop" && d.stations.some((s) => s.active));
const toggle = (code: string, id: number, on: boolean) =>
setLimits((cur) => {
const list = cur[code] ?? [];
return { ...cur, [code]: on ? [...list.filter((x) => x !== id), id] : list.filter((x) => x !== id) };
});
const save = async (e: FormEvent) => {
e.preventDefault();
const s = stamp.trim();
if (s && !RX_STAMP.test(s)) {
setMsg({ ok: false, text: "A welder stamp is 1 to 12 letters, digits or dashes." });
return;
}
const before = u.station_limits ?? {};
const changed: Record<string, number[]> = {};
for (const code of new Set([...Object.keys(before), ...Object.keys(limits)])) {
const a = [...(before[code] ?? [])].sort((x, y) => x - y).join(",");
const b = [...(limits[code] ?? [])].sort((x, y) => x - y);
if (a !== b.join(",")) changed[code] = b;
}
const patch: { welder_stamp?: string | null; profile_active?: boolean; station_limits?: Record<string, number[]> } = {};
if ((u.welder_stamp ?? "") !== s) patch.welder_stamp = s || null;
if ((u.profile_active ?? true) !== active) patch.profile_active = active;
if (Object.keys(changed).length) patch.station_limits = changed;
if (!Object.keys(patch).length) {
setMsg({ ok: true, text: "Nothing changed." });
return;
}
setBusy(true);
setMsg(null);
try {
await admin9Api.patchProfile(u.id, patch);
setMsg({ ok: true, text: "Profile saved." });
onSaved();
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
setBusy(false);
}
};
return (
<form className="panel stack" onSubmit={save} noValidate aria-labelledby="prof-h">
<div className="sec-h">
<h3 id="prof-h">Profile of {u.display_name}</h3>
<span className="mono small muted">{u.sam_account}</span>
<span className="grow" />
<button className="btn sm ghost" type="button" onClick={onClose}>
Close
</button>
</div>
<div className="frow">
<label className="f">
Welder stamp
<input className="in mono" value={stamp} maxLength={12} onChange={(e) => setStamp(e.target.value)} />
</label>
<label className="ckl">
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
Profile active
</label>
</div>
<div className="small muted">An inactive profile is left out of the welder picker and the worker filters. Roles still come from AD.</div>
<h3>Station limits</h3>
<div className="small muted">No station ticked in a department means no limit there.</div>
{shop.map((d) => (
<fieldset key={d.code} className="panel">
<legend className="small">
{d.code} {d.name}
</legend>
<div className="chips">
{d.stations
.filter((s) => s.active)
.map((s) => (
<label key={s.id} className="ckl">
<input
type="checkbox"
aria-label={`${d.code} ${s.name}`}
checked={(limits[d.code] ?? []).includes(s.id)}
onChange={(e) => toggle(d.code, s.id, e.target.checked)}
/>
{s.name}
</label>
))}
</div>
</fieldset>
))}
<Notice msg={msg} />
<div>
<button className="btn pri" type="submit" disabled={busy}>
Save profile
</button>
</div>
</form>
);
}
/* ---------- CSV import ---------- */
function CsvImport({ onApplied }: { onApplied: () => void }) {
const [csv, setCsv] = useState("");
const [preview, setPreview] = useState<ImportAnswer | null>(null);
const [previewed, setPreviewed] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<Msg>(null);
const onFile = async (e: ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (!f) return;
if (f.size > 1024 * 1024) {
setMsg({ ok: false, text: "The file is over 1 MB." });
return;
}
setCsv(await f.text());
setPreview(null);
setMsg(null);
};
const run = async (apply: boolean) => {
if (!csv.trim()) {
setMsg({ ok: false, text: "Paste the CSV or choose a file first." });
return;
}
setBusy(true);
setMsg(null);
try {
const r = await admin9Api.importProfiles(csv, apply);
setPreview(r);
setPreviewed(csv);
if (apply && r.applied) {
const n = r.rows.filter((row) => Object.keys(row.changes).length > 0).length;
setMsg({ ok: true, text: n === 1 ? "Applied. 1 user updated." : `Applied. ${n} users updated.` });
onApplied();
}
} catch (err) {
if (err instanceof ApiError && err.status === 422 && err.body && typeof err.body === "object" && "rows" in err.body) {
setPreview(err.body as ImportAnswer);
setPreviewed(csv);
setMsg({ ok: false, text: "Nothing was applied. Fix the errors and preview again." });
} else {
setMsg({ ok: false, text: errorText(err) });
}
} finally {
setBusy(false);
}
};
const fresh = preview !== null && previewed === csv;
const canApply = fresh && !preview.applied && preview.errors === 0 && preview.rows.length > 0;
return (
<section className="panel stack" aria-labelledby="csv-h">
<h3 id="csv-h">Import profiles from CSV</h3>
<div className="small muted">
Columns: <span className="mono">employee_id</span> (required), <span className="mono">welder_stamp</span>, <span className="mono">active</span> (yes or no),{" "}
<span className="mono">stations</span> (<span className="mono">03:Stall 1;03:Stall 2</span>). An empty cell leaves a value as it is. A dash clears it.
</div>
<label className="f">
CSV file
<input className="in" type="file" accept=".csv,text/csv" onChange={(e) => void onFile(e)} />
</label>
<label className="f">
CSV text
<textarea className="in mono" rows={6} aria-label="CSV text" value={csv} onChange={(e) => setCsv(e.target.value)} />
</label>
<div className="chips">
<button className="btn" type="button" disabled={busy} onClick={() => void run(false)}>
Preview import
</button>
<button className="btn pri" type="button" disabled={busy || !canApply} onClick={() => void run(true)}>
Apply import
</button>
</div>
<Notice msg={msg} />
{preview && !fresh ? <div className="small muted">The CSV changed. Preview it again before you apply.</div> : null}
{preview ? (
<>
<div className="small" role="status">
{preview.ok} {preview.ok === 1 ? "row" : "rows"} ready, {preview.errors} with errors.
{preview.applied ? " Applied." : ""}
</div>
<div className="tw">
<table aria-label="Import preview">
<thead>
<tr>
<th>Line</th>
<th>Employee ID</th>
<th>User</th>
<th>Changes</th>
<th>Errors</th>
</tr>
</thead>
<tbody>
{preview.rows.map((r) => (
<tr key={r.line}>
<td className="mono small">{r.line}</td>
<td className="mono">{r.employee_id}</td>
<td>{r.display_name ?? <span className="muted">-</span>}</td>
<td className="small">
{Object.keys(r.changes).length ? (
Object.entries(r.changes).map(([field, c]) => (
<div key={field}>
{field.replace(/_/g, " ")}: {show(c.from)} to {show(c.to)}
</div>
))
) : (
<span className="muted">No change</span>
)}
</td>
<td className="small">
{r.errors.length ? (
r.errors.map((e) => (
<div key={e} className="st-bad">
{e}
</div>
))
) : (
<span className="muted">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
) : null}
</section>
);
}
/* ---------- The tab ---------- */
function stationNames(u: ProfileUser, depts: AdminDepartment[]): string {
const out: string[] = [];
for (const [code, ids] of Object.entries(u.station_limits ?? {})) {
const d = depts.find((x) => x.code === code);
for (const id of ids) out.push(`${code}:${d?.stations.find((s) => s.id === id)?.name ?? id}`);
}
return out.join(", ");
}
export function ProfilesTab() {
const [q, setQ] = useState("");
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<string | null>(null);
const [load, reload] = useLoad((s) => admin9Api.users(query, s), query);
const [depts] = useLoad((s) => admin9Api.departments(s));
useEffect(() => {
const t = window.setTimeout(() => setQuery(q.trim()), 250);
return () => window.clearTimeout(t);
}, [q]);
const rows = load.state === "ok" ? load.data : [];
const deptList = depts.state === "ok" ? depts.data : [];
const current = rows.find((u) => u.id === selected) ?? null;
return (
<div className="stack">
<DirectoryFlagsPanel />
<form className="frow" role="search" onSubmit={(e) => e.preventDefault()}>
<label className="f">
Search profiles
<input className="in" type="search" placeholder="Name, account or employee ID" value={q} onChange={(e) => setQ(e.target.value)} />
</label>
</form>
<Problem load={load} />
{load.state === "ok" ? (
<div className="tw">
<table aria-label="Profiles">
<thead>
<tr>
<th>Name</th>
<th>Employee ID</th>
<th>Welder stamp</th>
<th>Station limits</th>
<th>Profile</th>
<th />
</tr>
</thead>
<tbody>
{rows.map((u) => (
<tr key={u.id} className={selected === u.id ? "rowlink on" : undefined}>
<td>
{u.display_name} <span className="mono small muted">{u.sam_account}</span>
</td>
<td className="mono">{u.emp_no ?? <span className="muted">Missing</span>}</td>
<td className="mono">{u.welder_stamp ?? <span className="muted">-</span>}</td>
<td className="small">{stationNames(u, deptList) || <span className="muted">No limit</span>}</td>
<td>{u.profile_active === false ? <span className="pill st-filed">Inactive</span> : <span className="pill st-ok">Active</span>}</td>
<td>
<button className="btn sm ghost" type="button" aria-label={`Edit profile of ${u.display_name}`} onClick={() => setSelected(u.id)}>
Edit
</button>
</td>
</tr>
))}
{rows.length === 0 ? (
<tr>
<td colSpan={6} className="muted">
No users match.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
) : null}
{current ? <ProfileEditor key={current.id} u={current} depts={deptList} onSaved={reload} onClose={() => setSelected(null)} /> : null}
<CsvImport onApplied={reload} />
</div>
);
}
@@ -0,0 +1,98 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { adminBase } from "../../test/admin";
import { renderApp } from "../../test/auth";
import { callsTo, mockFetch } from "../../test/fetchMock";
const SETTINGS = {
values: { session_idle_minutes: 15, signin_account_limit: 5, signin_burst_threshold: 20 },
defaults: { session_idle_minutes: 15, signin_account_limit: 5, signin_burst_threshold: 20 },
bounds: { session_idle_minutes: [1, 60], signin_account_limit: [1, 15], signin_burst_threshold: [5, 200] },
version: 2,
};
const ALERTS = [
{ at: "2026-10-21T09:00:00Z", kind: "lockout", user_id: "u-1", display_name: "Walt Welder", account: "weld.walt", ip: "10.0.0.9", detail: { minutes: 10 } },
{ at: "2026-10-21T08:00:00Z", kind: "admin_change", user_id: "u-0", display_name: "Ann Admin", account: "admin.ann", ip: "10.0.0.2", detail: { action: "admin.profile_updated" } },
];
const INTEGRITY = {
run: { id: 9, started_at: "2026-10-21T04:00:00Z", finished_at: "2026-10-21T04:02:00Z", status: "ok", counts: { seals: 120, assets: 40 } },
findings: [{ kind: "asset_hash", subject: "asset 77", detail: { expected: "ab", got: "cd" }, at: "2026-10-21T04:01:00Z" }],
};
function routes(extra: Parameters<typeof mockFetch>[0] = {}) {
return {
...adminBase(),
"GET /api/admin/security/settings": { body: SETTINGS },
"/api/admin/security/alerts": { body: ALERTS },
"/api/admin/security/integrity": { body: INTEGRITY },
...extra,
};
}
async function openTab() {
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Security" }));
return screen.findByLabelText("Lock after idle, minutes");
}
describe("Admin Security tab", () => {
it("shows each setting with its default and bounds, checks bounds, and saves only changes", async () => {
const fetch = mockFetch(
routes({
"PUT /api/admin/security/settings": { body: { ...SETTINGS, values: { ...SETTINGS.values, signin_account_limit: 4 }, version: 3 } },
}),
);
const idle = await openTab();
expect(idle).toHaveValue(15);
expect(idle).toHaveAccessibleDescription("Default 15. Allowed 1 to 60.");
const limit = screen.getByLabelText("Wrong passwords per account before a cooldown");
fireEvent.change(limit, { target: { value: "20" } });
fireEvent.click(screen.getByRole("button", { name: "Save security settings" }));
expect(await screen.findByRole("alert")).toHaveTextContent("from 1 to 15");
expect(callsTo(fetch, "PUT", "/api/admin/security/settings")).toHaveLength(0);
fireEvent.change(limit, { target: { value: "4" } });
fireEvent.click(screen.getByRole("button", { name: "Save security settings" }));
expect(await screen.findByText(/Security settings saved/)).toBeInTheDocument();
expect(callsTo(fetch, "PUT", "/api/admin/security/settings")).toEqual([{ values: { signin_account_limit: 4 }, version: 2 }]);
});
it("reloads after a 409", async () => {
const fetch = mockFetch(routes({ "PUT /api/admin/security/settings": { status: 409, body: { detail: "Changed", code: "version_conflict" } } }));
await openTab();
fireEvent.change(screen.getByLabelText("Lock after idle, minutes"), { target: { value: "20" } });
fireEvent.click(screen.getByRole("button", { name: "Save security settings" }));
await waitFor(() => expect(callsTo(fetch, "GET", "/api/admin/security/settings")).toHaveLength(2));
expect(await screen.findByRole("alert")).toHaveTextContent("Someone else saved the settings first.");
});
it("lists alerts with kind filters and a period, and shows the integrity result", async () => {
const fetch = mockFetch(routes());
await openTab();
const table = await screen.findByRole("table", { name: "Security alerts" });
expect(within(table).getByText("Account cooldowns")).toBeInTheDocument();
expect(within(table).getByText("Admin changes")).toBeInTheDocument();
const kinds = screen.getByRole("group", { name: "Alert kinds" });
fireEvent.click(within(kinds).getByRole("button", { name: /Admin changes/ }));
expect(within(kinds).getByRole("button", { name: /Admin changes/ })).toHaveAttribute("aria-pressed", "false");
expect(within(table).queryByText("Admin changes")).not.toBeInTheDocument();
expect(within(table).getByText("Walt Welder")).toBeInTheDocument();
fireEvent.change(screen.getByRole("combobox", { name: /Period/ }), { target: { value: "168" } });
await waitFor(() => expect(fetch.mock.calls.some(([u]) => String(u) === "/api/admin/security/alerts?hours=168")).toBe(true));
expect(fetch.mock.calls.some(([u]) => String(u) === "/api/admin/security/alerts?hours=24")).toBe(true);
const findings = await screen.findByRole("table", { name: "Integrity findings" });
expect(within(findings).getByText("asset 77")).toBeInTheDocument();
expect(screen.getByText("1 finding")).toHaveClass("st-bad");
expect(screen.getByText("seals: 120, assets: 40")).toBeInTheDocument();
});
it("says when the integrity check has not run", async () => {
mockFetch(routes({ "/api/admin/security/integrity": { body: { run: null, findings: [] } } }));
await openTab();
expect(await screen.findByText(/has not run yet/)).toBeInTheDocument();
});
});
+268
View File
@@ -0,0 +1,268 @@
// Admin: the security settings, the alerts of the last day and the latest integrity check (phase 9).
import { useEffect, useState, type FormEvent } from "react";
import { admin9Api, type SecuritySettingsAnswer } from "../../api/admin";
import { ApiError } from "../../api/client";
import { errorText, fmtDetail, fmtTime } from "../../lib/format";
import { useLoad } from "../../lib/useLoad";
import { Notice, Problem, type Msg } from "./common";
/** Labels for the known keys. A key the server adds later shows by its name. */
const LABELS: Record<string, string> = {
session_idle_minutes: "Lock after idle, minutes",
session_lock_minutes: "Sign out after locked, minutes",
session_desk_minutes: "Desk idle sign-out, minutes",
session_password_hours: "Password session, hours at most",
session_pin_hours: "PIN session, hours at most",
roles_refresh_minutes: "Re-read AD roles every, minutes",
signin_account_limit: "Wrong passwords per account before a cooldown",
signin_account_max_failures: "Wrong passwords per account before a cooldown",
signin_account_window_minutes: "Account failure window, minutes",
signin_account_cooldown_minutes: "Account cooldown, minutes",
signin_ip_limit: "Failures per address before a block",
signin_ip_max_failures: "Failures per address before a block",
signin_ip_window_minutes: "Address failure window, minutes",
signin_burst_threshold: "Failed sign-ins across accounts in 5 minutes that raise an alert",
};
const labelFor = (k: string) => LABELS[k] ?? k.replace(/_/g, " ");
function SettingsForm() {
const [load, reload] = useLoad((s) => admin9Api.securitySettings(s));
const [base, setBase] = useState<SecuritySettingsAnswer | null>(null);
const [vals, setVals] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<Msg>(null);
useEffect(() => {
if (load.state !== "ok") return;
setBase(load.data);
setVals(Object.fromEntries(Object.entries(load.data.values).map(([k, v]) => [k, String(v)])));
}, [load]);
if (!base) return <Problem load={load} />;
const keys = Object.keys(base.bounds).length ? Object.keys(base.bounds) : Object.keys(base.values);
const save = async (e: FormEvent) => {
e.preventDefault();
const changed: Record<string, number> = {};
for (const k of keys) {
const raw = (vals[k] ?? "").trim();
const n = Number(raw);
const [lo, hi] = base.bounds[k] ?? [Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY];
if (!raw || !Number.isInteger(n) || n < lo || n > hi) {
setMsg({ ok: false, text: `${labelFor(k)}: give a whole number from ${lo} to ${hi}.` });
return;
}
if (n !== base.values[k]) changed[k] = n;
}
if (!Object.keys(changed).length) {
setMsg({ ok: true, text: "Nothing changed." });
return;
}
setBusy(true);
setMsg(null);
try {
const r = await admin9Api.saveSecuritySettings(changed, base.version);
setBase(r);
setVals(Object.fromEntries(Object.entries(r.values).map(([k, v]) => [k, String(v)])));
setMsg({ ok: true, text: "Security settings saved. They apply within a minute." });
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
setMsg({ ok: false, text: "Someone else saved the settings first. Their values are loaded now." });
setBase(null);
reload();
} else {
setMsg({ ok: false, text: errorText(err) });
}
} finally {
setBusy(false);
}
};
return (
<form className="panel stack" onSubmit={save} noValidate aria-labelledby="secset-h">
<h3 id="secset-h">Settings</h3>
<div className="frow">
{keys.map((k) => {
const b = base.bounds[k];
const def = base.defaults[k];
return (
<div key={k} className="stack tight">
<label className="f" style={{ alignSelf: "stretch" }}>
{labelFor(k)}
<input
className="in mono"
type="number"
inputMode="numeric"
min={b?.[0]}
max={b?.[1]}
value={vals[k] ?? ""}
aria-describedby={`sec-${k}-hint`}
onChange={(e) => setVals((cur) => ({ ...cur, [k]: e.target.value }))}
/>
</label>
<span id={`sec-${k}-hint`} className="small muted">
Default {def ?? "-"}.{b ? ` Allowed ${b[0]} to ${b[1]}.` : ""}
</span>
</div>
);
})}
</div>
<Notice msg={msg} />
<div>
<button className="btn pri" type="submit" disabled={busy}>
Save security settings
</button>
</div>
</form>
);
}
const KINDS: ReadonlyArray<{ id: string; label: string }> = [
{ id: "lockout", label: "Account cooldowns" },
{ id: "pin_failures", label: "Wrong PINs" },
{ id: "stepup_wrong_pin", label: "Wrong unlock or signature" },
{ id: "denied", label: "Denied actions" },
{ id: "signin_burst", label: "Sign-in bursts" },
{ id: "admin_change", label: "Admin changes" },
{ id: "it_edit", label: "IT edits" },
{ id: "integrity", label: "Integrity" },
];
const kindLabel = (k: string) => KINDS.find((x) => x.id === k)?.label ?? k.replace(/_/g, " ");
function Alerts() {
const [hours, setHours] = useState(24);
const [load, reload] = useLoad((s) => admin9Api.securityAlerts(hours, s), String(hours));
const [off, setOff] = useState<Set<string>>(new Set());
const toggle = (k: string) =>
setOff((cur) => {
const next = new Set(cur);
if (next.has(k)) next.delete(k);
else next.add(k);
return next;
});
const rows = load.state === "ok" ? load.data.filter((a) => !off.has(a.kind)) : [];
const counts = new Map<string, number>();
if (load.state === "ok") for (const a of load.data) counts.set(a.kind, (counts.get(a.kind) ?? 0) + 1);
return (
<section className="sec stack" aria-labelledby="secalerts-h">
<div className="sec-h flush">
<h3 id="secalerts-h">Alerts</h3>
<label className="f">
Period
<select className="in" aria-label="Period" value={hours} onChange={(e) => setHours(Number(e.target.value))}>
<option value={24}>Last 24 hours</option>
<option value={72}>Last 3 days</option>
<option value={168}>Last 7 days</option>
</select>
</label>
<span className="grow" />
<button className="btn sm ghost" type="button" onClick={reload}>
Refresh alerts
</button>
</div>
<div className="chips" role="group" aria-label="Alert kinds">
{KINDS.map((k) => (
<button key={k.id} type="button" className="chip sm" aria-pressed={!off.has(k.id)} onClick={() => toggle(k.id)}>
{k.label} <span className="mono small">{counts.get(k.id) ?? 0}</span>
</button>
))}
</div>
<Problem load={load} />
{load.state === "ok" ? (
<div className="tw">
<table aria-label="Security alerts">
<thead>
<tr>
<th>When</th>
<th>Kind</th>
<th>Who</th>
<th>Address</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
{rows.map((a, i) => (
<tr key={`${a.at}-${a.kind}-${i}`}>
<td className="small mono nowrap">{fmtTime(a.at)}</td>
<td>{kindLabel(a.kind)}</td>
<td>
{a.display_name ?? ""} {a.account ? <span className="mono small muted">{a.account}</span> : null}
</td>
<td className="mono small">{a.ip ?? "-"}</td>
<td className="small">{fmtDetail(a.detail)}</td>
</tr>
))}
{rows.length === 0 ? (
<tr>
<td colSpan={5} className="muted">
No alerts in this period.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
) : null}
</section>
);
}
function Integrity() {
const [load] = useLoad((s) => admin9Api.integrity(s));
if (load.state !== "ok") return <Problem load={load} />;
const { run, findings } = load.data;
return (
<section className="panel stack" aria-labelledby="integ-h">
<div className="sec-h flush">
<h3 id="integ-h">Integrity check</h3>
{run ? (
<>
<span className={findings.length || run.status === "failed" ? "pill st-bad" : "pill st-ok"}>
{findings.length ? `${findings.length} ${findings.length === 1 ? "finding" : "findings"}` : run.status === "failed" ? "Failed" : "Clean"}
</span>
<span className="ct">Finished {fmtTime(run.finished_at)}</span>
</>
) : null}
</div>
{!run ? <div className="small muted">The integrity check has not run yet. It runs nightly at 04:00.</div> : null}
{run?.counts ? <div className="small">{fmtDetail(run.counts)}</div> : null}
{findings.length ? (
<div className="tw">
<table aria-label="Integrity findings">
<thead>
<tr>
<th>Kind</th>
<th>Subject</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
{findings.map((f, i) => (
<tr key={`${f.kind}-${f.subject}-${i}`}>
<td className="mono small">{f.kind.replace(/_/g, " ")}</td>
<td className="mono small">{f.subject}</td>
<td className="small">{fmtDetail(f.detail)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</section>
);
}
export function SecurityTab() {
return (
<div className="stack">
<SettingsForm />
<Alerts />
<Integrity />
</div>
);
}
@@ -0,0 +1,86 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { adminBase } from "../../test/admin";
import { renderApp } from "../../test/auth";
import { callsTo, mockFetch } from "../../test/fetchMock";
const PRINT = {
id: 5,
at: "2026-10-20T10:00:00Z",
by_name: "Clara Clerk",
kind: "databook",
what: "Data book",
pages: 12,
packet_id: 42,
packet_label: "L662001/1",
};
async function openTab() {
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Stamp check" }));
return screen.findByLabelText("Stamp line or code");
}
describe("Admin Stamp check tab", () => {
it("checks a typed stamp line", async () => {
const fetch = mockFetch({
...adminBase(),
"POST /api/admin/stamp-verify": { body: { found: true, valid: true, reason: null, print: PRINT } },
});
const box = await openTab();
fireEvent.click(screen.getByRole("button", { name: "Check stamp" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Type or paste the stamp line first.");
fireEvent.change(box, { target: { value: " PS 0123456789abcdef " } });
fireEvent.click(screen.getByRole("button", { name: "Check stamp" }));
const result = await screen.findByRole("status", { name: "Stamp result" });
expect(result).toHaveTextContent("Genuine.");
expect(within(result).getByText("Clara Clerk")).toBeInTheDocument();
expect(within(result).getByText("L662001/1")).toBeInTheDocument();
expect(within(result).getByText("Data book, 12 pages")).toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/admin/stamp-verify")).toEqual([{ text: "PS 0123456789abcdef" }]);
});
it("says when a stamp is not found or does not verify", async () => {
let n = 0;
mockFetch({
...adminBase(),
"POST /api/admin/stamp-verify": () => {
n += 1;
return n === 1
? { body: { found: false, valid: false, reason: null, print: null } }
: { body: { found: true, valid: false, reason: "The signature does not match.", print: PRINT } };
},
});
const box = await openTab();
fireEvent.change(box, { target: { value: "ffffffffffffffff" } });
fireEvent.click(screen.getByRole("button", { name: "Check stamp" }));
expect(await screen.findByText(/Not found\./)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Check stamp" }));
expect(await screen.findByText(/Does not verify\./)).toBeInTheDocument();
expect(screen.getByText(/The signature does not match\./)).toBeInTheDocument();
});
it("uploads a PDF and lists each stamp found", async () => {
const fetch = mockFetch({
...adminBase(),
"POST /api/admin/stamp-verify/pdf": {
body: { stamps: [{ found: true, valid: true, reason: null, print: PRINT }, { found: false, valid: false, reason: null, print: null }] },
},
});
await openTab();
const input = screen.getByLabelText("PDF of a printed copy");
fireEvent.change(input, { target: { files: [new File(["x"], "copy.txt", { type: "text/plain" })] } });
fireEvent.click(screen.getByRole("button", { name: "Check PDF" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Only a PDF can be checked.");
fireEvent.change(input, { target: { files: [new File(["%PDF-1.7"], "copy.pdf", { type: "application/pdf" })] } });
fireEvent.click(screen.getByRole("button", { name: "Check PDF" }));
const results = await screen.findByRole("status", { name: "PDF stamp results" });
expect(results).toHaveTextContent("Stamp 1: Genuine.");
expect(results).toHaveTextContent("Stamp 2: Not found.");
await waitFor(() => expect(callsTo(fetch, "POST", "/api/admin/stamp-verify/pdf")).toHaveLength(1));
const init = fetch.mock.calls.find(([u]) => String(u) === "/api/admin/stamp-verify/pdf")?.[1];
expect(init?.body).toBeInstanceOf(FormData);
expect((init?.body as FormData).get("file")).toBeInstanceOf(File);
});
});
@@ -0,0 +1,151 @@
// Admin: check the stamp on a printed or saved copy against the print log (phase 9).
import { useState, type FormEvent } from "react";
import { admin9Api, type StampResult } from "../../api/admin";
import { errorText, fmtTime } from "../../lib/format";
import { Notice, type Msg } from "./common";
const PDF_MAX = 25 * 1024 * 1024;
function StampCard({ r, n }: { r: StampResult; n?: number }) {
const p = r.print;
const head = n !== undefined ? `Stamp ${n}: ` : "";
if (!r.found) {
return (
<div className="callout warn flush">
<b>{head}Not found.</b> No print in the log has this stamp.{r.reason ? ` ${r.reason}` : ""}
</div>
);
}
return (
<div className={r.valid ? "callout flush" : "callout warn flush"}>
<b>{r.valid ? `${head}Genuine.` : `${head}Does not verify.`}</b>
{!r.valid && r.reason ? ` ${r.reason}` : ""}
{p ? (
<dl className="kv" style={{ marginTop: 8 }}>
<div>
<dt>Printed</dt>
<dd className="mono small">{fmtTime(p.at)}</dd>
</div>
<div>
<dt>By</dt>
<dd>{p.by_name ?? "-"}</dd>
</div>
<div>
<dt>What</dt>
<dd>
{p.what ?? p.kind}
{p.pages ? `, ${p.pages} ${p.pages === 1 ? "page" : "pages"}` : ""}
</dd>
</div>
<div>
<dt>Packet</dt>
<dd>{p.packet_label ?? (p.packet_id ? String(p.packet_id) : "-")}</dd>
</div>
</dl>
) : null}
</div>
);
}
export function StampVerifyTab() {
const [text, setText] = useState("");
const [file, setFile] = useState<File | null>(null);
const [one, setOne] = useState<StampResult | null>(null);
const [many, setMany] = useState<StampResult[] | null>(null);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<Msg>(null);
const checkText = async (e: FormEvent) => {
e.preventDefault();
if (!text.trim()) {
setMsg({ ok: false, text: "Type or paste the stamp line first." });
return;
}
setBusy(true);
setMsg(null);
setOne(null);
try {
setOne(await admin9Api.stampVerify(text.trim()));
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
setBusy(false);
}
};
const checkPdf = async (e: FormEvent) => {
e.preventDefault();
if (!file) {
setMsg({ ok: false, text: "Choose a PDF first." });
return;
}
if (file.type && file.type !== "application/pdf") {
setMsg({ ok: false, text: "Only a PDF can be checked." });
return;
}
if (file.size > PDF_MAX) {
setMsg({ ok: false, text: "The PDF is over 25 MB." });
return;
}
setBusy(true);
setMsg(null);
setMany(null);
try {
setMany((await admin9Api.stampVerifyPdf(file)).stamps);
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
setBusy(false);
}
};
return (
<div className="stack">
<div className="small muted">Every printed copy carries a stamp line with a 16 character code. Check it here to see whether the copy came from Packet Station.</div>
<form className="panel stack" onSubmit={checkText} noValidate aria-labelledby="stamp-text-h">
<h3 id="stamp-text-h">Check a stamp line</h3>
<label className="f">
Stamp line or code
<textarea
className="in mono"
rows={2}
aria-label="Stamp line or code"
value={text}
onChange={(e) => setText(e.target.value)}
/>
</label>
<div>
<button className="btn pri" type="submit" disabled={busy}>
Check stamp
</button>
</div>
{one ? (
<div role="status" aria-label="Stamp result">
<StampCard r={one} />
</div>
) : null}
</form>
<form className="panel stack" onSubmit={checkPdf} noValidate aria-labelledby="stamp-pdf-h">
<h3 id="stamp-pdf-h">Check a PDF</h3>
<label className="f">
PDF of a printed copy
<input className="in" type="file" accept="application/pdf,.pdf" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
</label>
<div>
<button className="btn pri" type="submit" disabled={busy}>
Check PDF
</button>
</div>
{many ? (
<div className="stack" role="status" aria-label="PDF stamp results">
{many.length === 0 ? <div className="callout warn flush">No stamp line found in this PDF.</div> : null}
{many.map((r, i) => (
<StampCard key={i} r={r} n={i + 1} />
))}
</div>
) : null}
</form>
<Notice msg={msg} />
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
// Small pieces the phase 9 Admin tabs share.
import { useEffect, useState } from "react";
import { admin9Api, type ProfileUser } from "../../api/admin";
import { errorText } from "../../lib/format";
import type { Load } from "../../lib/useLoad";
export function Problem({ load }: { load: Load<unknown> }) {
if (load.state === "loading") return <div className="empty slim">Loading</div>;
if (load.state === "error") return <div className="callout warn">{load.message}</div>;
return null;
}
export type Msg = { ok: boolean; text: string } | null;
export function Notice({ msg }: { msg: Msg }) {
if (!msg) return null;
return (
<div className={msg.ok ? "callout flush" : "callout warn flush"} role={msg.ok ? "status" : "alert"}>
{msg.text}
</div>
);
}
/** Search the users Packet Station knows and pick one. `label` names the search box. */
export function UserPicker({ label, onPick, exclude = [] }: { label: string; onPick: (u: ProfileUser) => void; exclude?: readonly string[] }) {
const [q, setQ] = useState("");
const [rows, setRows] = useState<ProfileUser[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const term = q.trim();
if (term.length < 2) {
setRows([]);
return;
}
const ctl = new AbortController();
const t = window.setTimeout(() => {
admin9Api
.users(term, ctl.signal)
.then((r) => {
setRows(r.slice(0, 10));
setError(null);
})
.catch((err: unknown) => {
if (!ctl.signal.aborted) setError(errorText(err));
});
}, 250);
return () => {
window.clearTimeout(t);
ctl.abort();
};
}, [q]);
const shown = rows.filter((u) => !exclude.includes(u.id));
return (
<div className="stack tight">
<label className="f">
{label}
<input className="in" type="search" placeholder="Name or account, 2 letters or more" value={q} onChange={(e) => setQ(e.target.value)} />
</label>
{error ? <div className="callout warn flush">{error}</div> : null}
{shown.length ? (
<ul className="chips plain">
{shown.map((u) => (
<li key={u.id}>
<button
type="button"
className="chip sm"
aria-label={`Pick ${u.display_name}`}
onClick={() => {
onPick(u);
setQ("");
}}
>
{u.display_name} <span className="mono small muted">{u.sam_account}</span>
</button>
</li>
))}
</ul>
) : null}
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
// Shared fixtures for the Admin tab tests.
import { DEPARTMENTS, makeMe } from "./auth";
import type { Route } from "./fetchMock";
export const ADMIN_ME = makeMe({
user: { id: "aaaaaaaa-0000-0000-0000-000000000001", display_name: "Ann Admin", sam_account: "admin.ann" },
roles: ["admin"],
screens: ["clerical", "warehouse", "supervisor", "stall", "qc", "pm", "admin"],
});
/** What every Admin render needs: the session, the departments and the Users tab it opens on. */
export function adminBase(): Record<string, Route> {
return {
"/api/auth/me": { body: ADMIN_ME },
"/api/departments": { body: DEPARTMENTS },
"/api/admin/users": { body: [] },
};
}
export const ADMIN_DEPTS = [
{
code: "03",
slug: "WeldParts",
name: "Weld Parts",
kind: "shop",
sort: 10,
active: true,
weld: true,
supervisor_group: true,
created_at: null,
groups: [
{ name: "PS-03-WeldParts-Worker", role: "shop_worker", in_ou: true },
{ name: "PS-03-WeldParts-Lead", role: "shop_lead", in_ou: true },
{ name: "PS-03-WeldParts-Supervisor", role: "shop_supervisor", in_ou: false },
],
stations: [
{ id: 31, name: "Stall 1", sort: 1, active: true },
{ id: 32, name: "Stall 2", sort: 2, active: true },
{ id: 33, name: "Old Stall", sort: 3, active: false },
],
},
{
code: "CL",
slug: "Clerical",
name: "Clerical",
kind: "support",
sort: 1,
active: true,
weld: false,
supervisor_group: false,
created_at: null,
groups: [{ name: "PS-Clerical", role: "clerical", in_ou: null }],
stations: [],
},
];
+1 -1
View File
@@ -22,7 +22,7 @@ dependencies = [
"apscheduler>=3.11,<4",
"httpx>=0.28",
"tzdata>=2026.5",
"pypdf==6.21.0",
"pypdf==6.21.2",
"python-multipart==0.0.32",
"pypdfium2==5.14.0",
"openpyxl==3.1.5",
+5 -2
View File
@@ -1,7 +1,10 @@
# scripts
Backup and restore scripts arrive in phase 9: a per-stack `pg_dump` backup with a
systemd timer on docker-1, and a restore script with a rehearsal recorded in docs/DEPLOY.md.
## backup/
The nightly per-stack `pg_dump` backup with its systemd timer, and the restore script. See
[backup/README.md](backup/README.md) for what they write and Spencer's install steps. The restore
rehearsal is recorded in docs/DEPLOY.md.
## Export-PescoCaBundle.ps1
+99
View File
@@ -0,0 +1,99 @@
# Backup and restore
Host scripts for docker-1. They run outside the app, so a broken app still backs up.
| File | What it is |
|---|---|
| `ps-backup.sh` | Nightly `pg_dump -Fc` of each stack's database plus `pg_dumpall --globals-only --no-role-passwords`, checked with `pg_restore --list`, 7 kept, `last-success` and `last-failure` markers. |
| `ps-restore.sh` | Restores a dump into a fresh database, applies the globals, copies assets into a volume, checks asset hashes, prints row counts. |
| `packetstation-backup.service` | systemd oneshot that runs `ps-backup.sh` as root. |
| `packetstation-backup.timer` | 02:00 America/Denver every day, `Persistent=true`. |
| `backup.conf.example` | The stack list for `/etc/packetstation/backup.conf`. |
Test: `tests/server/test_backup_scripts.py` runs `ps-backup.sh` with a fake `docker` on PATH.
## What a backup is
In each stack's folder (`/var/backups/packetstation/prod`, `/home/spencerm/packetstation/test/backups`):
- `ps-<UTC stamp>.dump`: the app database, custom format. Mode 0600.
- `ps-<UTC stamp>.globals.sql`: the roles, without passwords. Mode 0600.
- `last-success`, `last-failure`: one line, `<ISO UTC time> <dump name>` (a failure adds the reason). Mode 0644. The worker's backup-check job compares their modification times.
Each dump is written under a temporary name (`.ps-….partial`), checked, then renamed. A failed run leaves no partial file and writes `last-failure`. The script tries every stack and exits 1 if any failed, 2 for a usage or config error.
The script connects inside the postgres container as `postgres` over the local socket, which the image trusts. It reads no password.
Why `--no-role-passwords`: a password hash in a backup file is something to crack offline, and the file also lands in the PBS copy of the VM. The role passwords are in the stack's secrets already. On a fresh volume `deploy/postgres/initdb/01-roles.sh` creates the roles with them. On an existing cluster the roles are already there.
Ownership: run as root, the new files take the owner and group of the stack's folder, so the test folder stays spencerm's. The worker mounts the folder read-only as uid 10001. It only needs to list the folder and stat the dumps, plus read the markers, so dumps can stay 0600.
## Install (Spencer, with sudo, on docker-1)
Copy the files from the synced checkout in `~/packetstation/repo`. The installed copies are root's, so a later sync of the checkout does not change what root runs.
```bash
cd ~/packetstation/repo
sudo install -o root -g root -m 0755 scripts/backup/ps-backup.sh /usr/local/sbin/ps-backup.sh
sudo install -o root -g root -m 0755 scripts/backup/ps-restore.sh /usr/local/sbin/ps-restore.sh
sudo install -d -o root -g root -m 0755 /etc/packetstation
sudo install -o root -g root -m 0644 scripts/backup/backup.conf.example /etc/packetstation/backup.conf
sudo install -o root -g root -m 0644 scripts/backup/packetstation-backup.service /etc/systemd/system/
sudo install -o root -g root -m 0644 scripts/backup/packetstation-backup.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemd-analyze verify /etc/systemd/system/packetstation-backup.service
sudo systemctl enable --now packetstation-backup.timer
systemctl list-timers packetstation-backup.timer # next run 08:00 or 09:00 UTC, 02:00 in Denver
sudo systemctl start packetstation-backup.service # one run now
journalctl -u packetstation-backup.service -n 20 --no-pager
ls -la ~/packetstation/test/backups
```
The config lists the test stack only. When the production stack exists (phase 10), make its folder and uncomment its line:
```bash
sudo install -d -o root -g root -m 0755 /var/backups/packetstation
sudo install -d -o root -g 10001 -m 0750 /var/backups/packetstation/prod
sudoedit /etc/packetstation/backup.conf # uncomment the prod line
sudo systemctl start packetstation-backup.service
```
Group 10001 lets the production worker (uid 10001, group 10001) list the folder and read the markers. Its `stack.env` sets `BACKUP_DIR=/var/backups/packetstation/prod`.
After changing a script in the repo, install it again with the same `install` line.
## Run by hand
As spencerm (in the docker group), against the test stack:
```bash
scripts/backup/ps-backup.sh --stack test project:packetstation-test packetstation /home/spencerm/packetstation/test/backups
```
`--stack NAME CONTAINER DATABASE FOLDER` can repeat. CONTAINER is a container name or `project:<compose project>`. `--keep N` (or `KEEP=N`) changes the 7.
## Restore
The script never stops, restarts or reconfigures a stack. Restore into a new database, check it, then decide how to switch.
```bash
B=/home/spencerm/packetstation/test/backups
D=$(ls $B/ps-*.dump | tail -1)
# 1. Into a fresh database (refuses an existing one; --replace drops it first, and DROP refuses while anyone is connected).
ps-restore.sh --project packetstation-test --dump "$D" --globals "${D%.dump}.globals.sql" --db ps_restored
# 2. Assets: from a folder or a tar (for example a file-level restore out of PBS of the assets volume).
# Existing files are never overwritten. Files end up owned by 10001:10001.
ps-restore.sh --project packetstation-test --assets /path/to/assets.tar --assets-volume packetstation-test_assets
# 3. Check every asset row's sha256 against the files in the volume.
ps-restore.sh --project packetstation-test --verify-assets --db ps_restored --assets-volume packetstation-test_assets
# Row counts of any database, for comparing.
ps-restore.sh --project packetstation-test --counts-only --db packetstation
```
To put the restored database into service, stop the app and worker of that stack, rename the databases as `postgres` (`ALTER DATABASE packetstation RENAME TO packetstation_old; ALTER DATABASE ps_restored RENAME TO packetstation;`), start the stack again, and drop the old one once the app is checked. That step is deliberate and manual.
The globals file holds no passwords. If a role had to be created from it on a new cluster, set its password from the stack's secret file with `\password` in psql, or start the stack on an empty volume so initdb creates the roles.
+5
View File
@@ -0,0 +1,5 @@
# /etc/packetstation/backup.conf: the stacks ps-backup.sh backs up, one per line.
# name container (or project:<compose project>) database folder
test project:packetstation-test packetstation /home/spencerm/packetstation/test/backups
# Uncomment once the production stack runs (phase 10). Until then it would fail every night.
#prod project:packetstation packetstation /var/backups/packetstation/prod
@@ -0,0 +1,25 @@
# Packet Station nightly database backup (scripts/backup/ps-backup.sh).
# Installed by root on docker-1: see scripts/backup/README.md. Started by packetstation-backup.timer.
# Runs as root because it talks to the Docker daemon and writes /var/backups/packetstation.
[Unit]
Description=Packet Station database backup (pg_dump of each stack)
Documentation=file:///home/spencerm/packetstation/repo/scripts/backup/README.md
Wants=docker.service
After=docker.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/ps-backup.sh --config /etc/packetstation/backup.conf
Environment=KEEP=7
# Gentle on the host: the dump runs while the shop is closed, but the stacks stay up.
Nice=10
IOSchedulingClass=idle
TimeoutStartSec=1h
# Hardening. /usr, /boot and /etc are read-only to the script; it writes only the backup folders.
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=full
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
+13
View File
@@ -0,0 +1,13 @@
# Packet Station nightly backup at 02:00 Denver time, an hour before the 03:00 Proxmox Backup
# Server run, so PBS captures the fresh dumps. The host clock is UTC; the zone makes the intent
# explicit and follows daylight saving. Persistent=true runs a missed backup at the next boot.
[Unit]
Description=Packet Station nightly database backup
[Timer]
OnCalendar=*-*-* 02:00:00 America/Denver
Persistent=true
Unit=packetstation-backup.service
[Install]
WantedBy=timers.target
+299
View File
@@ -0,0 +1,299 @@
#!/usr/bin/env bash
# Packet Station nightly backup. Runs on the docker host, never inside the app.
#
# For each configured stack it writes, into that stack's backup folder:
# ps-<UTC stamp>.dump pg_dump -Fc of the app database (custom format)
# ps-<UTC stamp>.globals.sql pg_dumpall --globals-only --no-role-passwords (roles, no password hashes)
# last-success / last-failure one line: "<ISO UTC time> <dump name>" (last-failure adds the reason)
#
# Each dump is written under a temporary name, checked with `pg_restore --list` run inside the
# postgres container, and only then renamed into place. The newest KEEP dumps (default 7) are kept,
# with their globals files. Any failure writes last-failure and the script exits non-zero after
# trying the remaining stacks.
#
# Usage:
# ps-backup.sh [--config FILE] [STACK ...]
# ps-backup.sh --stack NAME CONTAINER DATABASE FOLDER [--stack ...]
#
# Config file (default /etc/packetstation/backup.conf, or PS_BACKUP_CONFIG), one stack per line:
# # name container database folder
# prod project:packetstation packetstation /var/backups/packetstation/prod
# test project:packetstation-test packetstation /home/spencerm/packetstation/test/backups
# CONTAINER is a container name, or project:<compose project> to find that project's postgres
# service by its compose labels. With STACK names given, only those stacks run.
#
# pg_dump connects inside the container as the postgres superuser over the local socket, which
# the official image trusts. No password is read, passed or printed.
#
# Globals: --no-role-passwords, so no password hash ever lands in a file (or in the PBS copy of
# it). The roles' passwords come from the stack's secrets: initdb (deploy/postgres/initdb)
# recreates them on a fresh volume. The globals file is also 0600, like the dumps.
#
# Files: dumps and globals are 0600. The markers are 0644 (a time and a file name, nothing else)
# so the worker, which mounts the folder read-only as uid 10001, can read them. When run as root
# the new files take the folder's owner and group, so a stack folder owned by spencerm stays
# readable by spencerm.
set -euo pipefail
umask 077
KEEP="${KEEP:-7}"
PG_USER="${PG_USER:-postgres}"
DOCKER="${DOCKER:-docker}"
log() { printf '%s ps-backup: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >&2; }
usage() {
sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//' >&2
exit 2
}
# ------------------------------------------------------------------ arguments
config="${PS_BACKUP_CONFIG:-/etc/packetstation/backup.conf}"
config_given=0
declare -a only=()
declare -a stacks=() # each entry: "name|container|database|folder"
while [[ $# -gt 0 ]]; do
case "$1" in
--config)
[[ $# -ge 2 ]] || usage
config="$2"; config_given=1; shift 2 ;;
--stack)
[[ $# -ge 5 ]] || usage
stacks+=("$2|$3|$4|$5"); shift 5 ;;
--keep)
[[ $# -ge 2 ]] || usage
KEEP="$2"; shift 2 ;;
-h|--help) usage ;;
-*) log "unknown option $1"; usage ;;
*) only+=("$1"); shift ;;
esac
done
if ! [[ "$KEEP" =~ ^[1-9][0-9]*$ ]]; then
log "KEEP must be a positive whole number"
exit 2
fi
if [[ ${#stacks[@]} -eq 0 ]]; then
if [[ ! -r "$config" ]]; then
log "no --stack given and the config $config is missing or unreadable"
exit 2
fi
while read -r name container database folder extra; do
[[ -z "${name:-}" || "${name:0:1}" == "#" ]] && continue
if [[ -z "${folder:-}" || -n "${extra:-}" ]]; then
log "config line for '$name' needs exactly: name container database folder"
exit 2
fi
stacks+=("$name|$container|$database|$folder")
done < "$config"
elif [[ $config_given -eq 1 ]]; then
log "use --config or --stack, not both"
exit 2
fi
if [[ ${#stacks[@]} -eq 0 ]]; then
log "no stacks configured"
exit 2
fi
# ------------------------------------------------------------------ helpers
# Resolve "project:<name>" to the running postgres container of that compose project.
resolve_container() {
local spec="$1"
if [[ "$spec" == project:* ]]; then
local project="${spec#project:}" ids
ids="$("$DOCKER" ps -q \
--filter "label=com.docker.compose.project=$project" \
--filter "label=com.docker.compose.service=postgres")"
if [[ -z "$ids" ]]; then
echo "no running postgres container for compose project $project" >&2
return 1
fi
if [[ "$(printf '%s\n' "$ids" | wc -l)" -ne 1 ]]; then
echo "more than one postgres container for compose project $project" >&2
return 1
fi
printf '%s\n' "$ids"
else
printf '%s\n' "$spec"
fi
}
# Write a marker atomically: temp file in the same folder, then rename.
write_marker() {
local folder="$1" marker="$2" text="$3" tmp
tmp="$(mktemp "$folder/.${marker}.XXXXXX")"
printf '%s\n' "$text" > "$tmp"
chmod 0644 "$tmp"
take_folder_owner "$folder" "$tmp"
mv -f "$tmp" "$folder/$marker"
}
take_folder_owner() {
local folder="$1"; shift
if [[ "$(id -u)" == "0" ]]; then
chown --reference="$folder" "$@" 2>/dev/null || true
fi
}
# Keep the newest $KEEP dumps (names sort by their UTC stamp), and drop the globals of any removed dump.
rotate() {
local folder="$1" f base
local -a dumps=()
while IFS= read -r f; do dumps+=("$f"); done < <(
find "$folder" -maxdepth 1 -type f -name 'ps-*.dump' -printf '%f\n' | LC_ALL=C sort
)
local excess=$(( ${#dumps[@]} - KEEP ))
local i
for (( i = 0; i < excess; i++ )); do
base="${dumps[$i]%.dump}"
rm -f -- "$folder/${dumps[$i]}" "$folder/$base.globals.sql"
log "removed old dump ${dumps[$i]}"
done
# Globals with no dump next to them (a run that failed after the globals step) go too.
while IFS= read -r f; do
base="${f%.globals.sql}"
[[ -e "$folder/$base.dump" ]] || rm -f -- "$folder/$f"
done < <(find "$folder" -maxdepth 1 -type f -name 'ps-*.globals.sql' -printf '%f\n')
}
# ------------------------------------------------------------------ one stack
# Set by backup_stack for the cleanup trap. Each stack runs in its own subshell (run_stack), so
# these never leak from one stack to the next.
cur_tmp_dump=""
cur_tmp_globals=""
cur_folder=""
cur_dump_name=""
failure_written=0
cleanup_tmp() {
if [[ -n "$cur_tmp_dump" ]]; then rm -f -- "$cur_tmp_dump"; fi
if [[ -n "$cur_tmp_globals" ]]; then rm -f -- "$cur_tmp_globals"; fi
cur_tmp_dump=""
cur_tmp_globals=""
}
# Record the failure and leave the stack's subshell.
fail_stack() {
local reason="$1"
cleanup_tmp
log "FAILED: $reason"
if [[ -n "$cur_folder" && -d "$cur_folder" ]]; then
write_marker "$cur_folder" last-failure "$(date -u +%Y-%m-%dT%H:%M:%SZ) ${cur_dump_name:--} $reason" || true
fi
failure_written=1
exit 1
}
# Any command that fails without an explicit check still ends up as last-failure.
on_stack_exit() {
local rc=$?
cleanup_tmp
if [[ $rc -ne 0 && $failure_written -eq 0 && -n "$cur_folder" && -d "$cur_folder" ]]; then
log "FAILED: unexpected error (exit $rc)"
write_marker "$cur_folder" last-failure \
"$(date -u +%Y-%m-%dT%H:%M:%SZ) ${cur_dump_name:--} unexpected error (exit $rc)" || true
fi
}
backup_stack() {
local name="$1" spec="$2" database="$3" folder="$4"
local stamp globals_name container started entries size
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
cur_dump_name="ps-$stamp.dump"
globals_name="ps-$stamp.globals.sql"
started=$SECONDS
log "stack $name: database $database into $folder"
if [[ ! -d "$folder" ]]; then
# 0750: the worker reaches the folder through its group (see scripts/backup/README.md).
mkdir -p -- "$folder"
chmod 0750 -- "$folder" 2>/dev/null || log "stack $name: could not set mode 0750 on $folder"
fi
cur_folder="$folder"
[[ -w "$folder" ]] || fail_stack "$folder is not writable"
if command -v flock >/dev/null 2>&1; then
exec 9>"$folder/.ps-backup.lock"
flock -n 9 || fail_stack "another backup of this stack is running"
fi
container="$(resolve_container "$spec" 2>&1)" || fail_stack "$container"
cur_tmp_dump="$(mktemp "$folder/.$cur_dump_name.XXXXXX.partial")"
cur_tmp_globals="$(mktemp "$folder/.$globals_name.XXXXXX.partial")"
"$DOCKER" exec "$container" pg_dump -U "$PG_USER" -d "$database" -Fc > "$cur_tmp_dump" \
|| fail_stack "pg_dump failed"
[[ -s "$cur_tmp_dump" ]] || fail_stack "pg_dump wrote nothing"
"$DOCKER" exec "$container" pg_dumpall -U "$PG_USER" --globals-only --no-role-passwords \
> "$cur_tmp_globals" || fail_stack "pg_dumpall --globals-only failed"
[[ -s "$cur_tmp_globals" ]] || fail_stack "pg_dumpall wrote nothing"
# The check reads the dump from stdin with the container's own pg_restore. Entry lines start
# with the entry's id; comment lines start with a semicolon.
entries="$("$DOCKER" exec -i "$container" pg_restore --list < "$cur_tmp_dump" | grep -c '^[0-9]')" \
|| fail_stack "pg_restore --list could not read the dump"
[[ "$entries" -ge 1 ]] || fail_stack "pg_restore --list found no entries"
chmod 0600 "$cur_tmp_dump" "$cur_tmp_globals"
take_folder_owner "$folder" "$cur_tmp_dump" "$cur_tmp_globals"
mv -f -- "$cur_tmp_globals" "$folder/$globals_name"
cur_tmp_globals=""
mv -f -- "$cur_tmp_dump" "$folder/$cur_dump_name"
cur_tmp_dump=""
size="$(stat -c %s "$folder/$cur_dump_name")"
write_marker "$folder" last-success "$(date -u +%Y-%m-%dT%H:%M:%SZ) $cur_dump_name"
rotate "$folder"
log "stack $name: ok, $cur_dump_name, $size bytes, $entries entries, $(( SECONDS - started )) s"
}
# One stack in its own subshell with errexit on, so a failure ends that stack only.
run_stack() (
set -euo pipefail
trap on_stack_exit EXIT
backup_stack "$@"
)
# ------------------------------------------------------------------ main
failures=0
ran=0
for entry in "${stacks[@]}"; do
IFS='|' read -r s_name s_container s_database s_folder <<< "$entry"
if [[ ${#only[@]} -gt 0 ]]; then
wanted=0
for o in "${only[@]}"; do
if [[ "$o" == "$s_name" ]]; then wanted=1; fi
done
[[ $wanted -eq 1 ]] || continue
fi
ran=$(( ran + 1 ))
# Not in an if or ||: that would switch errexit off inside the subshell.
set +e
run_stack "$s_name" "$s_container" "$s_database" "$s_folder"
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
failures=$(( failures + 1 ))
fi
done
if [[ $ran -eq 0 ]]; then
log "no configured stack matched: ${only[*]}"
exit 2
fi
if [[ $failures -gt 0 ]]; then
log "$failures of $ran stacks failed"
exit 1
fi
log "all $ran stacks backed up"
+276
View File
@@ -0,0 +1,276 @@
#!/usr/bin/env bash
# Packet Station restore. Runs on the docker host. Restores a ps-backup.sh dump into a fresh
# database on a stack's postgres container, optionally applies the globals file, copies assets
# back into an assets volume, and prints the row count of every table at the end.
#
# It never stops, restarts or reconfigures a stack. Pointing the app at the restored database
# (or renaming it into place) is a separate, deliberate step: see scripts/backup/README.md.
#
# Usage:
# ps-restore.sh --container NAME | --project PROJECT
# [--dump FILE --db NAME [--replace]]
# [--globals FILE]
# [--assets DIR_OR_TAR --assets-volume VOLUME]
# [--verify-assets --db NAME --assets-volume VOLUME]
# [--counts-only --db NAME]
#
# --container / --project the postgres container, by name or by compose project.
# --dump FILE a ps-*.dump file (pg_dump -Fc).
# --db NAME the database to restore into. It must not exist, unless --replace,
# which drops it first. DROP DATABASE refuses while anyone is connected.
# --owner ROLE owner of the new database (default ps_migrate, as initdb makes it).
# --app-role ROLE role given CONNECT and TEMPORARY on it (default ps_app).
# --globals FILE a ps-*.globals.sql file. Roles that already exist are reported and
# left alone. The file holds no passwords: set them from the secrets.
# --assets SRC a folder, or a .tar / .tar.gz / .tgz, holding the files that belong
# under ASSETS_DIR. Copied into --assets-volume through a throwaway
# container (no network) that runs the postgres image already on the host.
# Existing files are never overwritten.
# --assets-volume VOLUME the docker volume to copy into (for example packetstation-test_assets).
# --assets-owner UID:GID owner for the copied files (default 10001:10001, the image's ps user).
# --verify-assets compare sha256 of every file in --assets-volume with assets.sha256 in --db.
# --counts-only only print the row counts of --db.
#
# Connects inside the container as the postgres superuser over the local socket. No password
# is read, passed or printed.
set -euo pipefail
umask 077
PG_USER="${PG_USER:-postgres}"
DOCKER="${DOCKER:-docker}"
log() { printf '%s ps-restore: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >&2; }
die() { log "$*"; exit 1; }
usage() { sed -n '2,36p' "$0" | sed 's/^# \{0,1\}//' >&2; exit 2; }
container=""
project=""
dump=""
db=""
replace=0
owner="ps_migrate"
app_role="ps_app"
globals=""
assets=""
assets_volume=""
assets_owner="10001:10001"
verify_assets=0
counts_only=0
while [[ $# -gt 0 ]]; do
case "$1" in
--container) container="${2:?}"; shift 2 ;;
--project) project="${2:?}"; shift 2 ;;
--dump) dump="${2:?}"; shift 2 ;;
--db) db="${2:?}"; shift 2 ;;
--replace) replace=1; shift ;;
--owner) owner="${2:?}"; shift 2 ;;
--app-role) app_role="${2:?}"; shift 2 ;;
--globals) globals="${2:?}"; shift 2 ;;
--assets) assets="${2:?}"; shift 2 ;;
--assets-volume) assets_volume="${2:?}"; shift 2 ;;
--assets-owner) assets_owner="${2:?}"; shift 2 ;;
--verify-assets) verify_assets=1; shift ;;
--counts-only) counts_only=1; shift ;;
-h|--help) usage ;;
*) log "unknown argument $1"; usage ;;
esac
done
ident_re='^[a-z_][a-z0-9_]{0,62}$'
[[ -z "$db" || "$db" =~ $ident_re ]] || die "--db must be a plain lower-case name (letters, digits, _)"
[[ "$owner" =~ $ident_re ]] || die "--owner must be a plain lower-case role name"
[[ "$app_role" =~ $ident_re ]] || die "--app-role must be a plain lower-case role name"
[[ "$assets_owner" =~ ^[0-9]+:[0-9]+$ ]] || die "--assets-owner must be UID:GID"
if [[ -n "$project" ]]; then
[[ -z "$container" ]] || die "use --container or --project, not both"
container="$("$DOCKER" ps -q \
--filter "label=com.docker.compose.project=$project" \
--filter "label=com.docker.compose.service=postgres")"
[[ -n "$container" ]] || die "no running postgres container for compose project $project"
[[ "$(printf '%s\n' "$container" | wc -l)" -eq 1 ]] || die "more than one postgres container for $project"
fi
[[ -n "$container" ]] || die "--container or --project is required"
if [[ -n "$dump" || $counts_only -eq 1 || $verify_assets -eq 1 ]]; then
[[ -n "$db" ]] || die "--db is required"
fi
if [[ -n "$assets" || $verify_assets -eq 1 ]]; then
[[ -n "$assets_volume" ]] || die "--assets-volume is required"
fi
if [[ -z "$dump" && -z "$globals" && -z "$assets" && $verify_assets -eq 0 && $counts_only -eq 0 ]]; then
die "nothing to do: give --dump, --globals, --assets, --verify-assets or --counts-only"
fi
# psql inside the container, reading SQL from stdin. Variables go through -v and :'name'.
psql_c() {
local dbname="$1"; shift
"$DOCKER" exec -i "$container" psql -X -q -v ON_ERROR_STOP=1 -U "$PG_USER" -d "$dbname" "$@"
}
db_exists() {
local n
n="$(psql_c postgres -At -v name="$1" <<'SQL'
SELECT count(*) FROM pg_database WHERE datname = :'name';
SQL
)"
[[ "$n" == "1" ]]
}
role_exists() {
local n
n="$(psql_c postgres -At -v name="$1" <<'SQL'
SELECT count(*) FROM pg_roles WHERE rolname = :'name';
SQL
)"
[[ "$n" == "1" ]]
}
print_counts() {
local dbname="$1"
log "row counts in $dbname"
psql_c "$dbname" -A -F $'\t' -P footer=off <<'SQL'
SELECT c.relname AS table_name,
(xpath('/row/n/text()',
query_to_xml(format('SELECT count(*) AS n FROM %I.%I', n.nspname, c.relname), false, true, '')
))[1]::text::bigint AS row_count
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p')
ORDER BY c.relname;
SQL
}
# A throwaway container on no network, running the image the postgres container already uses,
# so nothing is downloaded.
helper_image() {
"$DOCKER" inspect -f '{{.Config.Image}}' "$container"
}
# ------------------------------------------------------------------ globals
if [[ -n "$globals" ]]; then
[[ -r "$globals" ]] || die "cannot read $globals"
if grep -qiE "PASSWORD[[:space:]]+'(SCRAM|md5)" "$globals"; then
die "$globals holds password hashes; ps-backup.sh writes globals without them. Refusing."
fi
log "applying globals from $(basename -- "$globals") (existing roles are left as they are)"
# Without ON_ERROR_STOP: CREATE ROLE for a role that exists fails and the rest goes on.
out="$("$DOCKER" exec -i "$container" psql -X -q -U "$PG_USER" -d postgres < "$globals" 2>&1 || true)"
exists="$(printf '%s\n' "$out" | grep -c 'already exists' || true)"
other="$(printf '%s\n' "$out" | grep 'ERROR' | grep -vc 'already exists' || true)"
log "globals: $exists statements skipped (already exists), $other other errors"
if [[ "$other" -gt 0 ]]; then
printf '%s\n' "$out" | grep 'ERROR' | grep -v 'already exists' >&2
die "globals had errors"
fi
fi
# ------------------------------------------------------------------ database
if [[ -n "$dump" ]]; then
[[ -r "$dump" ]] || die "cannot read $dump"
started=$SECONDS
if db_exists "$db"; then
[[ $replace -eq 1 ]] || die "database $db exists. Give --replace to drop it first."
log "dropping database $db (--replace)"
psql_c postgres -v name="$db" <<'SQL'
SELECT format('DROP DATABASE %I', :'name') \gexec
SQL
fi
role_exists "$owner" || die "role $owner does not exist (apply --globals or start the stack once)"
log "creating database $db owned by $owner"
# The same shape as deploy/postgres/initdb/01-roles.sh.
psql_c postgres -v name="$db" -v owner="$owner" <<'SQL'
SELECT format('CREATE DATABASE %I OWNER %I ENCODING %L TEMPLATE template0', :'name', :'owner', 'UTF8') \gexec
SELECT format('REVOKE ALL ON DATABASE %I FROM PUBLIC', :'name') \gexec
SQL
if role_exists "$app_role"; then
psql_c postgres -v name="$db" -v app="$app_role" <<'SQL'
SELECT format('GRANT CONNECT, TEMPORARY ON DATABASE %I TO %I', :'name', :'app') \gexec
SQL
else
log "role $app_role does not exist; no CONNECT grant made"
fi
log "restoring $(basename -- "$dump") ($(stat -c %s "$dump") bytes) into $db"
# One transaction: a restore that fails leaves an empty database, never a half-restored one.
"$DOCKER" exec -i "$container" pg_restore -U "$PG_USER" -d "$db" --exit-on-error --single-transaction \
< "$dump" || die "pg_restore failed; database $db is left empty. Drop it with --replace on the next try."
log "database restored in $(( SECONDS - started )) s"
fi
# ------------------------------------------------------------------ assets
if [[ -n "$assets" ]]; then
[[ -e "$assets" ]] || die "$assets does not exist"
"$DOCKER" volume inspect "$assets_volume" >/dev/null 2>&1 || die "volume $assets_volume does not exist"
image="$(helper_image)"
started=$SECONDS
src="$(cd -- "$(dirname -- "$assets")" && pwd)/$(basename -- "$assets")"
if [[ -d "$assets" ]]; then
log "copying assets from folder $src into volume $assets_volume"
"$DOCKER" run --rm --network none --user 0:0 --entrypoint sh \
-v "$assets_volume:/dst" -v "$src:/src:ro" "$image" -c \
'set -eu; cp -a -n /src/. /dst/; chown -R "$1" /dst' sh "$assets_owner"
else
case "$assets" in
*.tar.gz|*.tgz) tar_flags="-xzf" ;;
*.tar) tar_flags="-xf" ;;
*) die "--assets must be a folder, .tar, .tar.gz or .tgz" ;;
esac
log "copying assets from $(basename -- "$src") into volume $assets_volume"
"$DOCKER" run --rm --network none --user 0:0 --entrypoint sh \
-v "$assets_volume:/dst" -v "$src:/src.tar:ro" "$image" -c \
'set -eu; tar "$1" /src.tar -C /dst --skip-old-files --no-same-owner; chown -R "$2" /dst' \
sh "$tar_flags" "$assets_owner"
fi
log "assets copied in $(( SECONDS - started )) s"
fi
# ------------------------------------------------------------------ verify assets
if [[ $verify_assets -eq 1 ]]; then
"$DOCKER" volume inspect "$assets_volume" >/dev/null 2>&1 || die "volume $assets_volume does not exist"
image="$(helper_image)"
work="$(mktemp -d)"
trap 'rm -rf -- "$work"' EXIT
# sha256sum check format: "<hex> <path>".
psql_c "$db" -At <<'SQL' > "$work/assets.sha256"
SELECT encode(sha256, 'hex') || ' ' || storage_key FROM assets ORDER BY storage_key;
SQL
rows="$(wc -l < "$work/assets.sha256")"
chmod 0755 "$work"
chmod 0644 "$work/assets.sha256"
log "checking $rows asset rows of $db against volume $assets_volume"
set +e
"$DOCKER" run --rm --network none --user 0:0 --entrypoint sh \
-v "$assets_volume:/dst:ro" -v "$work:/work:ro" "$image" -c '
cd /dst || exit 3
sha256sum -c /work/assets.sha256 2>/dev/null
find . -type f ! -path "./tmp/*" | sed "s|^\./||" | sort > /tmp/files
cut -c67- /work/assets.sha256 | sort > /tmp/rows
comm -23 /tmp/files /tmp/rows | sed "s|^|ORPHAN: |"
exit 0' > "$work/result"
rc=$?
set -e
[[ $rc -eq 0 ]] || die "the check container failed (exit $rc)"
ok="$(grep -c ': OK$' "$work/result" || true)"
bad="$(grep -c ': FAILED$' "$work/result" || true)"
missing="$(grep -c ': FAILED open or read$' "$work/result" || true)"
orphans="$(grep -c '^ORPHAN: ' "$work/result" || true)"
log "assets: $rows rows, $ok match, $bad differ, $missing missing, $orphans files with no row"
grep -v ': OK$' "$work/result" >&2 || true
if [[ "$ok" -ne "$rows" ]]; then
die "asset check failed"
fi
fi
# ------------------------------------------------------------------ counts
if [[ -n "$db" && ( -n "$dump" || $counts_only -eq 1 ) ]]; then
print_counts "$db"
fi
+61 -12
View File
@@ -22,10 +22,12 @@ from ..auth import groups as groupmap
from ..auth import pin as pins
from ..auth.ldap import LdapUnavailable
from ..auth.signin import ldap_down
from ..domain import assets, scan
from ..domain import assets, profiles, scan
from .account import active_sessions
from .deps import Db, Principal, admin_principal, directory_of, get_db, settings_of
from .errors import ApiError
from .jobs import request_run, run_out
from .packets import WRITE
router = APIRouter(prefix="/api/admin", tags=["admin"])
@@ -61,7 +63,7 @@ USER_SELECT = (
)
def _user_row(r: dict[str, Any], roles: list[str]) -> dict[str, Any]:
def _user_row(r: dict[str, Any], roles: list[str], limits: dict[str, list[int]] | None = None) -> dict[str, Any]:
flags = r.get("directory_flags") or {}
return {
"id": str(r["id"]),
@@ -79,6 +81,10 @@ def _user_row(r: dict[str, Any], roles: list[str]) -> dict[str, Any]:
},
"last_signin_at": r["last_signin_at"],
"ad_synced_at": r["ad_synced_at"],
"welder_stamp": r.get("welder_stamp"),
"profile_active": bool(r.get("profile_active", True)),
"station_limits": limits or {},
"directory_flags": {k: v for k, v in flags.items() if k != "groups"},
}
@@ -100,8 +106,10 @@ def users(
).fetchall()
else:
rows = cur.execute(USER_SELECT + " ORDER BY lower(u.display_name) LIMIT 500").fetchall()
roles = _roles_by_user(conn, [r["id"] for r in rows])
return [_user_row(r, roles.get(r["id"], [])) for r in rows]
ids = [r["id"] for r in rows]
roles = _roles_by_user(conn, ids)
limits = profiles.station_limits(conn, ids)
return [_user_row(r, roles.get(r["id"], []), limits.get(r["id"])) for r in rows]
@router.get("/users/{user_id}")
@@ -116,7 +124,9 @@ def user_detail(
r = cur.execute(USER_SELECT + " WHERE u.id = %s", (user_id,)).fetchone()
if r is None:
raise ApiError(404, "not_found", "No such user.")
out = _user_row(r, _roles_by_user(conn, [user_id]).get(user_id, []))
out = _user_row(
r, _roles_by_user(conn, [user_id]).get(user_id, []), profiles.station_limits(conn, [user_id]).get(user_id)
)
directory = directory_of(request)
try:
u = directory.get_user_by_guid(user_id)
@@ -238,7 +248,7 @@ def revoke_device(
return Response(status_code=204)
def _group_map_view(request: Request) -> dict[str, Any]:
def _groups_cache(request: Request) -> dict[str, Any]:
cache: dict[str, Any] | None = getattr(request.app.state, "groups_cache", None)
if cache is None or time.monotonic() - cache["t"] > GROUPS_CACHE_SECONDS:
try:
@@ -247,6 +257,17 @@ def _group_map_view(request: Request) -> dict[str, Any]:
in_ad = None
cache = {"t": time.monotonic(), "checked_at": datetime.now(UTC), "in_ad": in_ad}
request.app.state.groups_cache = cache
return cache
def groups_in_ou(request: Request) -> list[str] | None:
"""The group names in the PacketStation OU (cached GROUPS_CACHE_SECONDS), or None when AD is not reachable."""
names: list[str] | None = _groups_cache(request)["in_ad"]
return names
def _group_map_view(request: Request) -> dict[str, Any]:
cache = _groups_cache(request)
names: list[str] | None = cache["in_ad"]
lower = {n.lower() for n in names} if names is not None else None
gm = groupmap.current()
@@ -269,18 +290,46 @@ def group_map(request: Request, p: Principal = Depends(admin_principal)) -> dict
return _group_map_view(request)
@router.post("/ad/refresh")
AD_RECONCILE_JOB = "ad-reconcile"
REPORT_KEYS: dict[str, Any] = {
"groups_missing": list,
"groups_extra": list,
"users_seen": int,
"created": int,
"updated": int,
"deactivated": int,
"sessions_ended": int,
"emp_missing": list,
"emp_duplicate": list,
}
@router.post("/ad/refresh", status_code=202, dependencies=WRITE)
def ad_refresh(
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
try:
res = userdir.refresh_from_ad(d.conn, directory_of(request), actor=p.actor, ip=p.ip)
except LdapUnavailable as e:
raise ldap_down(e) from None
"""Ask the worker for an ad-reconcile run now. The result shows on GET /ad/reconcile."""
res = request_run(d.conn, p, AD_RECONCILE_JOB, action="ad.refresh_requested")
request.app.state.groups_cache = None
return res.as_dict()
return {**res, "job": AD_RECONCILE_JOB}
@router.get("/ad/reconcile")
def ad_reconcile(p: Principal = Depends(admin_principal), d: Db = Depends(get_db)) -> dict[str, Any]: # noqa: B008
"""The newest finished ad-reconcile run and its report."""
with d.conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(
"SELECT * FROM job_runs WHERE job = %s AND finished_at IS NOT NULL ORDER BY id DESC LIMIT 1",
(AD_RECONCILE_JOB,),
).fetchone()
pending = cur.execute(
"SELECT count(*) AS n FROM job_requests WHERE job = %s AND claimed_at IS NULL", (AD_RECONCILE_JOB,)
).fetchone()
counts = (r or {}).get("counts") or {}
report = {k: counts.get(k, kind()) for k, kind in REPORT_KEYS.items()}
return {"run": run_out(r), "report": report, "pending": int(pending["n"]) if pending else 0}
@router.get("/signin-attempts")
+287
View File
@@ -0,0 +1,287 @@
"""Admin: departments and stations, people's profiles (one at a time and the CSV import), checklist templates.
Every route needs the admin role and a password session. Every change is audited (`admin.*`).
"""
from __future__ import annotations
import re
import uuid
from typing import Any
from fastapi import APIRouter, Depends, Request
from fastapi.encoders import jsonable_encoder
from psycopg.types.json import Jsonb
from pydantic import BaseModel, ConfigDict, Field
from .. import audit
from ..auth import groups as groupmap
from ..domain import checklists, profiles
from ..domain import departments as depts
from .admin import groups_in_ou
from .deps import Db, Principal, admin_principal, get_db
from .errors import ApiError
from .packets import WRITE
router = APIRouter(prefix="/api/admin", tags=["admin"])
IMPORT_MAX_BODY = profiles.CSV_MAX_BYTES * 2 + 10_000 # JSON escaping can double the text
def import_limit(request: Request) -> None:
length = request.headers.get("content-length")
if length is not None and length.strip().isdigit() and int(length) > IMPORT_MAX_BODY:
raise ApiError(413, "too_large", "The file is larger than 1 MB.")
def reload_groups(request: Request, conn: Any) -> None:
"""Rebuild the group map from the departments table and drop the cached OU listing."""
found = groupmap.load_departments(conn)
if found:
groupmap.use_departments(found)
request.app.state.groups_cache = None
# ---------------------------------------------------------------- departments
class StationIn(BaseModel):
model_config = ConfigDict(extra="forbid")
id: int | None = None
name: str = Field(max_length=200)
sort: int = Field(0, ge=0, le=100_000)
active: bool = True
class DepartmentPatch(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str | None = Field(None, max_length=200)
active: bool | None = None
stations: list[StationIn] | None = Field(None, max_length=depts.STATIONS_MAX)
class DepartmentNew(BaseModel):
model_config = ConfigDict(extra="forbid")
code: str = Field(max_length=10)
slug: str = Field(max_length=40)
name: str = Field(max_length=200)
kind: str = Field(pattern="^(shop|support)$")
weld: bool = False
supervisor_group: bool = True
stations: list[str] = Field(default_factory=list, max_length=depts.STATIONS_MAX)
@router.get("/departments")
def department_list(
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> list[dict[str, Any]]:
return depts.list_all(d.conn, groups_in_ou(request))
@router.patch("/departments/{code}", dependencies=WRITE)
def department_update(
code: str,
body: DepartmentPatch,
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
conn = d.conn
depts.update(
conn,
p,
code[:10],
name=body.name,
active=body.active,
stations=[s.model_dump() for s in body.stations] if body.stations is not None else None,
)
reload_groups(request, conn)
out = depts.get(conn, code[:10], groups_in_ou(request))
assert out is not None
return out
@router.post("/departments", status_code=201, dependencies=WRITE)
def department_add(
body: DepartmentNew,
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
conn = d.conn
_, groups = depts.add(
conn,
p,
code=body.code,
slug=body.slug,
name=body.name,
kind=body.kind,
weld=body.weld,
supervisor_group=body.supervisor_group,
stations=body.stations,
)
reload_groups(request, conn)
dept = depts.get(conn, body.code.strip(), groups_in_ou(request))
return {"department": dept, "groups_to_create": groups}
# ---------------------------------------------------------------- profiles
class ProfilePatch(BaseModel):
model_config = ConfigDict(extra="forbid")
welder_stamp: str | None = Field(None, max_length=40)
profile_active: bool | None = None
station_limits: dict[str, list[int]] | None = None
class ImportBody(BaseModel):
model_config = ConfigDict(extra="forbid")
csv: str
apply: bool = False
@router.patch("/users/{user_id}/profile", dependencies=WRITE)
def profile_update(
user_id: uuid.UUID,
body: ProfilePatch,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
fields = body.model_dump(exclude_unset=True)
out = profiles.update(d.conn, p, user_id, fields)
return {"user_id": str(user_id), **out}
@router.post("/users/import", dependencies=[Depends(import_limit)])
def profile_import(
body: ImportBody,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
return profiles.import_profiles(d.conn, p, body.csv, apply=body.apply)
# ---------------------------------------------------------------- checklists
RX_BRACES = re.compile(r"\{[^{}]*\}")
ALLOWED_PLACEHOLDERS = ("{drawing}", "{rev}", "{wps}")
class ChecklistsPut(BaseModel):
model_config = ConfigDict(extra="forbid")
value: dict[str, Any]
version: int = Field(ge=0)
def check_templates(value: Any) -> tuple[dict[str, list[dict[str, str]]], list[str]]:
"""The checklist templates cleaned, and one error line per problem."""
errors: list[str] = []
out: dict[str, list[dict[str, str]]] = {}
if not isinstance(value, dict):
return {}, ["value must be an object with weld and plain lists"]
extra = sorted(k for k in value if k not in ("weld", "plain"))
if extra:
errors.append(f"unknown list: {', '.join(extra)}")
for name in ("weld", "plain"):
items = value.get(name)
if not isinstance(items, list) or not items:
errors.append(f"{name}: at least one item")
continue
if len(items) > checklists.ITEMS_MAX:
errors.append(f"{name}: at most {checklists.ITEMS_MAX} items")
seen: set[str] = set()
clean: list[dict[str, str]] = []
for i, it in enumerate(items, start=1):
where = f"{name} item {i}"
if not isinstance(it, dict):
errors.append(f"{where}: must be an object with group, key and text")
continue
group = " ".join(str(it.get("group") or "").split())
key = str(it.get("key") or "").strip()
text = " ".join(str(it.get("text") or "").split())
if not checklists.RX_KEY.match(key):
errors.append(f"{where}: the key is a lower-case letter, then letters, digits or _ (at most 30)")
elif key in seen:
errors.append(f"{where}: key {key} is used twice")
seen.add(key)
if not group:
errors.append(f"{where}: the group is empty")
if not text:
errors.append(f"{where}: the text is empty")
if len(group) > checklists.TEXT_MAX or len(text) > checklists.TEXT_MAX:
errors.append(f"{where}: at most {checklists.TEXT_MAX} characters")
bad = [m for m in RX_BRACES.findall(text) if m not in ALLOWED_PLACEHOLDERS]
stray = RX_BRACES.sub("", text)
if bad:
errors.append(f"{where}: unknown placeholder {', '.join(bad)} (use {{drawing}}, {{rev}} or {{wps}})")
elif "{" in stray or "}" in stray:
errors.append(f"{where}: a brace without its pair")
clean.append({"group": group, "key": key, "text": text})
out[name] = clean
return out, errors
def _checklists_out(conn: Any) -> dict[str, Any]:
row = conn.execute("SELECT version FROM settings WHERE key = %s", (checklists.SETTINGS_KEY,)).fetchone()
return {
"value": checklists.load_templates(conn),
"defaults": checklists.DEFAULT_TEMPLATES,
"version": int(row[0]) if row else 0,
}
@router.get("/checklists")
def checklists_get(p: Principal = Depends(admin_principal), d: Db = Depends(get_db)) -> dict[str, Any]: # noqa: B008
return _checklists_out(d.conn)
@router.put("/checklists", dependencies=WRITE)
def checklists_put(
body: ChecklistsPut,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
conn = d.conn
value, errors = check_templates(body.value)
if errors:
raise ApiError(422, "bad_checklists", errors[0], extra={"errors": errors})
with conn.transaction():
row = conn.execute(
"SELECT version FROM settings WHERE key = %s FOR UPDATE", (checklists.SETTINGS_KEY,)
).fetchone()
current = int(row[0]) if row else 0
if body.version != current:
raise ApiError(
409,
"version_conflict",
"Someone else changed the checklists. Reload and try again.",
extra={"current": jsonable_encoder(_checklists_out(conn))},
)
before = checklists.load_templates(conn)
conn.execute(
"INSERT INTO settings (key, value, version, updated_at, updated_by) VALUES (%s, %s, 1, now(), %s)"
" ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, version = settings.version + 1,"
" updated_at = now(), updated_by = EXCLUDED.updated_by",
(checklists.SETTINGS_KEY, Jsonb(value), p.user_id),
)
audit.append(
conn,
action="admin.checklists_updated",
user_id=p.user_id,
session_id=p.session_id,
role_snapshot=p.role_snapshot,
subject_type="settings",
subject_id=checklists.SETTINGS_KEY,
detail={"before": before, "after": value},
ip=p.ip,
)
return _checklists_out(conn)
+634
View File
@@ -0,0 +1,634 @@
"""Admin: the Security tab (settings, alerts, the integrity result), the access log and its CSV, who is in now, the
stamp check, and the directory flags.
Every route needs the admin role and a password session. Every change is audited (`admin.*`).
"""
from __future__ import annotations
import io
import ipaddress
import json
import re
import uuid
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Depends, Query, Request, Response
from fastapi.encoders import jsonable_encoder
from psycopg.rows import dict_row
from pydantic import BaseModel, ConfigDict, Field
from starlette.concurrency import run_in_threadpool
from starlette.datastructures import UploadFile
from .. import audit
from ..auth import secsettings
from ..domain import security_alerts
from ..pdf import stamps, tracecsv
from .account import LOG_PREFIXES
from .deps import Db, Principal, admin_principal, get_db, idle_minutes, settings_of
from .errors import ApiError
from .jobs import run_out
from .packets import WRITE
router = APIRouter(prefix="/api/admin", tags=["admin"])
CSV = "text/csv; charset=utf-8"
PDF_MAX_BYTES = 25 * 1024 * 1024
FORM_SLACK = 64 * 1024
# ---------------------------------------------------------------- security settings
class SecPut(BaseModel):
model_config = ConfigDict(extra="forbid")
values: dict[str, Any]
version: int = Field(ge=0)
def _sec_out(conn: Any, request: Request) -> dict[str, Any]:
s = settings_of(request)
stored, version, updated_at, updated_by = secsettings.load_versioned(conn)
defaults = secsettings.defaults(s)
return {
"values": {**defaults, **stored},
"defaults": defaults,
"bounds": {k: list(v) for k, v in secsettings.BOUNDS.items()},
"version": version,
"updated_at": updated_at,
"updated_by": str(updated_by) if updated_by else None,
}
@router.get("/security/settings")
def security_settings(
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
return _sec_out(d.conn, request)
@router.put("/security/settings", dependencies=WRITE)
def security_settings_put(
body: SecPut,
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
conn = d.conn
try:
secsettings.save(conn, settings_of(request), who=p, values=body.values, version=body.version)
except secsettings.SecSettingsError as e:
raise ApiError(422, "bad_settings", e.errors[0], extra={"errors": e.errors}) from None
except secsettings.VersionConflict:
raise ApiError(
409,
"version_conflict",
"Someone else changed the security settings. Reload and try again.",
extra={"current": jsonable_encoder(_sec_out(conn, request))},
) from None
return _sec_out(conn, request)
# ---------------------------------------------------------------- alerts and integrity
@router.get("/security/alerts")
def security_alert_list(
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
hours: int = Query(24, ge=1, le=168),
) -> list[dict[str, Any]]:
return security_alerts.alerts(d.conn, hours)
@router.get("/security/integrity")
def security_integrity(p: Principal = Depends(admin_principal), d: Db = Depends(get_db)) -> dict[str, Any]: # noqa: B008
with d.conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(
"SELECT * FROM job_runs WHERE job = 'integrity' AND finished_at IS NOT NULL ORDER BY id DESC LIMIT 1"
).fetchone()
findings = (
cur.execute(
"SELECT kind, subject, detail, at FROM integrity_findings WHERE run_id = %s ORDER BY id", (r["id"],)
).fetchall()
if r
else []
)
run = None
if r is not None:
full = run_out(r) or {}
run = {k: full.get(k) for k in ("id", "started_at", "finished_at", "status", "counts")}
return {
"run": run,
"findings": [
{"kind": f["kind"], "subject": f["subject"], "detail": f["detail"] or {}, "at": f["at"]} for f in findings
],
}
# ---------------------------------------------------------------- access log
ACCESS_LIMIT_MAX = 1000
CSV_ROWS_MAX = 50_000
FAIL_ACTIONS = ("auth.signin_failed", "auth.lockout", "auth.signin_burst")
RX_CURSOR = re.compile(r"^(?P<at>[0-9T:.+\-Z ]{10,40}),(?P<src>[01]),(?P<id>[0-9]{1,19})$")
ACCESS_SQL = """
SELECT x.at, x.src, x.id, x.source, x.kind, x.ok, coalesce(x.account, u.sam_account) AS account, x.user_id,
u.display_name, host(x.ip) AS ip, x.detail
FROM (
SELECT a.at, 0 AS src, a.id, 'attempt' AS source, a.kind, a.ok, a.account, a.user_id, a.ip,
jsonb_strip_nulls(jsonb_build_object('reason', a.reason, 'session_id', a.session_id)) AS detail
FROM signin_attempts a
UNION ALL
SELECT e.at, 1, e.id, 'audit', e.action, NOT (e.action = ANY(%(fail)s)), e.detail->>'account', e.user_id, e.ip,
coalesce(e.detail, '{{}}'::jsonb)
FROM audit_events e WHERE e.action LIKE ANY(%(prefixes)s)
) x LEFT JOIN users u ON u.id = x.user_id
WHERE true{where}
ORDER BY x.at DESC, x.src DESC, x.id DESC
LIMIT %(limit)s
"""
def _access_where(
*,
since: datetime | None,
until: datetime | None,
user_id: uuid.UUID | None,
q: str,
kind: str,
ok: bool | None,
ip: str,
before: str,
) -> tuple[str, dict[str, Any]]:
terms: list[str] = []
params: dict[str, Any] = {"fail": list(FAIL_ACTIONS), "prefixes": list(LOG_PREFIXES)}
if since is not None:
terms.append("x.at >= %(since)s")
params["since"] = since
if until is not None:
terms.append("x.at < %(until)s")
params["until"] = until
if user_id is not None:
terms.append("x.user_id = %(user_id)s")
params["user_id"] = user_id
q = q.strip()
if q:
pat = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
terms.append(
"(x.account ILIKE %(q)s OR u.sam_account ILIKE %(q)s OR u.display_name ILIKE %(q)s"
" OR u.emp_no = %(q_exact)s)"
)
params["q"], params["q_exact"] = pat, q
kind = kind.strip()
if kind:
terms.append("x.kind = %(kind)s")
params["kind"] = kind
if ok is not None:
terms.append("x.ok = %(ok)s")
params["ok"] = ok
ip = ip.strip()
if ip:
try:
net = ipaddress.ip_network(ip, strict=False)
except ValueError:
raise ApiError(422, "invalid_request", "ip must be an address or a network like 10.1.2.0/24.") from None
terms.append("x.ip <<= %(ip)s::inet")
params["ip"] = str(net)
before = before.strip()
if before:
m = RX_CURSOR.match(before)
if m is None:
raise ApiError(422, "invalid_request", "before is the next_before value of a previous page.")
try:
at = datetime.fromisoformat(m["at"].replace("Z", "+00:00"))
except ValueError:
raise ApiError(422, "invalid_request", "before is the next_before value of a previous page.") from None
terms.append("(x.at, x.src, x.id) < (%(b_at)s, %(b_src)s, %(b_id)s)")
params.update(b_at=at, b_src=int(m["src"]), b_id=int(m["id"]))
return "".join(f" AND {t}" for t in terms), params
def _access_rows(conn: Any, where: str, params: dict[str, Any], limit: int) -> list[dict[str, Any]]:
with conn.cursor(row_factory=dict_row) as cur:
rows: list[dict[str, Any]] = cur.execute(ACCESS_SQL.format(where=where), {**params, "limit": limit}).fetchall()
return rows
def _access_out(r: dict[str, Any]) -> dict[str, Any]:
return {
"id": f"{r['source']}:{r['id']}",
"at": r["at"],
"source": r["source"],
"kind": r["kind"],
"ok": bool(r["ok"]),
"account": r["account"],
"user_id": str(r["user_id"]) if r["user_id"] else None,
"display_name": r["display_name"],
"ip": r["ip"],
"detail": r["detail"] or {},
}
class AccessFilters(BaseModel):
since: datetime | None = None
until: datetime | None = None
user_id: uuid.UUID | None = None
q: str = Field("", max_length=200)
kind: str = Field("", max_length=60)
ok: bool | None = None
ip: str = Field("", max_length=60)
def access_filters(
since: datetime | None = Query(None), # noqa: B008
until: datetime | None = Query(None), # noqa: B008
user_id: uuid.UUID | None = Query(None), # noqa: B008
q: str = Query("", max_length=200),
kind: str = Query("", max_length=60),
ok: bool | None = Query(None),
ip: str = Query("", max_length=60),
) -> AccessFilters:
return AccessFilters(since=since, until=until, user_id=user_id, q=q, kind=kind, ok=ok, ip=ip)
@router.get("/access-log")
def access_log(
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
f: AccessFilters = Depends(access_filters), # noqa: B008
limit: int = Query(100, ge=1, le=ACCESS_LIMIT_MAX),
before: str = Query("", max_length=80),
) -> dict[str, Any]:
where, params = _access_where(**f.model_dump(), before=before)
rows = _access_rows(d.conn, where, params, limit)
nxt = None
if len(rows) == limit:
last = rows[-1]
nxt = f"{last['at'].astimezone(UTC).isoformat()},{last['src']},{last['id']}"
return {"rows": [_access_out(r) for r in rows], "next_before": nxt}
CSV_HEADER = ("at", "source", "kind", "ok", "account", "display_name", "user_id", "ip", "detail")
@router.get("/access-log.csv")
def access_log_csv(
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
f: AccessFilters = Depends(access_filters), # noqa: B008
) -> Response:
conn = d.conn
where, params = _access_where(**f.model_dump(), before="")
rows = _access_rows(conn, where, params, CSV_ROWS_MAX)
lines: list[list[Any]] = [list(CSV_HEADER)]
for r in rows:
o = _access_out(r)
lines.append(
[
o["at"].astimezone(UTC).isoformat(),
o["source"],
o["kind"],
"yes" if o["ok"] else "no",
o["account"],
o["display_name"],
o["user_id"],
o["ip"],
json.dumps(o["detail"], sort_keys=True, default=str, separators=(",", ":")),
]
)
text = tracecsv.to_csv(lines) + "\r\n"
filters = {k: (str(v) if v is not None else None) for k, v in f.model_dump().items() if v not in (None, "")}
with conn.transaction():
conn.execute(
"INSERT INTO print_log (by, by_name, session_id, kind, what, pages, ip)"
" VALUES (%s, %s, %s, 'access-log-csv', %s, NULL, %s)",
(p.user_id, p.user.get("display_name"), p.session_id, f"Access log CSV ({len(rows)} rows)", p.ip),
)
audit.append(
conn,
action="admin.access_log_exported",
user_id=p.user_id,
session_id=p.session_id,
role_snapshot=p.role_snapshot,
subject_type="access_log",
subject_id="csv",
detail={"rows": len(rows), "filters": filters, "capped": len(rows) >= CSV_ROWS_MAX},
ip=p.ip,
)
day = datetime.now(UTC).strftime("%Y%m%d")
return Response(
content=text.encode("utf-8"),
media_type=CSV,
headers={
"Content-Disposition": f'attachment; filename="access-log-{day}.csv"',
"X-Content-Type-Options": "nosniff",
"Cache-Control": "no-store",
},
)
# ---------------------------------------------------------------- who is in now
@router.get("/presence")
def presence(
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
conn = d.conn
s = secsettings.overlay(conn, settings_of(request))
widest = max(s.session_idle_minutes, s.session_desk_minutes)
with conn.cursor(row_factory=dict_row) as cur:
rows = cur.execute(
"SELECT s.id, s.user_id, u.display_name, s.kind::text AS kind, dv.name AS device_name, host(s.ip) AS ip,"
" s.created_at, s.last_seen_at, s.locked_since, s.roles_snapshot, now() AS db_now,"
" now() - s.last_seen_at AS idle"
" FROM sessions s JOIN users u ON u.id = s.user_id LEFT JOIN devices dv ON dv.id = s.device_id"
" WHERE s.revoked_at IS NULL AND s.expires_at > now()"
" AND s.last_seen_at > now() - make_interval(mins => %s::int)"
" ORDER BY lower(u.display_name), s.created_at",
(widest,),
).fetchall()
streams: dict[str, dict[str, Any]] = {}
hub = getattr(request.app.state, "events", None)
if hub is not None:
for screen, users in hub.presence_by_screen().items():
for u in users:
streams.setdefault(str(u["user_id"]), {"screen": None if screen == "none" else screen, **u})
at = rows[0]["db_now"] if rows else datetime.now(UTC)
out = []
for r in rows:
roles = list((r["roles_snapshot"] or {}).get("roles", []))
idle_min, _ = idle_minutes(s, roles)
if r["idle"].total_seconds() > idle_min * 60:
continue
stream = streams.get(str(r["user_id"]))
out.append(
{
"session_id": str(r["id"]),
"user_id": str(r["user_id"]),
"display_name": r["display_name"],
"kind": r["kind"],
"device_name": r["device_name"],
"ip": r["ip"],
"created_at": r["created_at"],
"last_seen_at": r["last_seen_at"],
"locked": r["locked_since"] is not None,
"screen": stream["screen"] if stream else None,
"packet_id": stream.get("packet_id") if stream else None,
}
)
return {"at": at, "sessions": out}
# ---------------------------------------------------------------- stamp check
RX_SIG_IN_TEXT = re.compile(r"stamp\s+([0-9a-fA-F]{16})\b")
RX_SIG_ALONE = re.compile(r"^\s*([0-9a-fA-F]{16})\s*$")
RX_UTC = re.compile(r"(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}) UTC")
TEXT_MAX = 4000
PDF_STAMPS_MAX = 50
class StampText(BaseModel):
model_config = ConfigDict(extra="forbid")
text: str = Field(max_length=TEXT_MAX)
def _squash(text: str) -> str:
return "".join(text.split())
def _sign_candidates(conn: Any, r: dict[str, Any]) -> list[tuple[Any, str, str | None]]:
"""(packet_id, signed kind, seal hash) the print could have been signed with, from its print_log kind."""
kind, pid = str(r["kind"]), r["packet_id"]
itp = f"itp-{r['itp_id']}" if r.get("itp_id") is not None else None
if kind == "report-pdf":
return [(None, "report", None)]
if kind == "itp-pdf":
return [(itp, "itp-pdf", None)]
if kind == "itp":
return [(itp, "print:itp", None)]
if kind == "databook-export":
seals: list[str | None] = [
bytes(x[0]).hex()
for x in conn.execute(
"SELECT hash FROM packet_seals WHERE packet_id = %s ORDER BY id DESC", (pid,)
).fetchall()
]
return [(pid, "databook", s) for s in [*seals, None]]
if kind == "traveler":
return [(pid, "traveler", None), (pid, "print:traveler", None)]
return [(pid, f"print:{kind}", None)]
def _minutes(r: dict[str, Any]) -> list[str]:
out: list[str] = []
m = RX_UTC.search(str(r.get("stamp") or ""))
if m:
out.append(f"{m[1]}T{m[2]}Z")
at = r["at"].astimezone(UTC)
out.append(stamps.minute_of(at))
return list(dict.fromkeys(out))
def _print_out(conn: Any, r: dict[str, Any]) -> dict[str, Any]:
label = None
if r["packet_id"] is not None:
row = conn.execute(
"SELECT pr.number, u.lot, p.drawing FROM packets p JOIN units u ON u.id = p.unit_id"
" JOIN projects pr ON pr.id = u.project_id WHERE p.id = %s",
(r["packet_id"],),
).fetchone()
if row:
label = " ".join(x for x in (row[0], f"Lot {row[1]}" if row[1] else "", row[2] or "") if x)
return {
"id": r["id"],
"at": r["at"],
"by_name": r["by_name"],
"kind": r["kind"],
"what": r["what"],
"pages": r["pages"],
"packet_id": r["packet_id"],
"packet_label": label,
}
def check_stamp(conn: Any, key: bytes | None, sig: str | None, shown: str | None) -> dict[str, Any]:
"""One stamp: the signature, and the text shown up to and including it (None when only the hex was given)."""
if not sig:
return {"found": False, "valid": False, "reason": "No stamp signature in the text.", "print": None}
sig = sig.lower()
with conn.cursor(row_factory=dict_row) as cur:
rows = cur.execute("SELECT * FROM print_log WHERE stamp_sig = %s ORDER BY id", (sig,)).fetchall()
if not rows:
return {
"found": False,
"valid": False,
"reason": f"No print has stamp {sig}. The copy is not from Packet Station, or the stamp was changed.",
"print": None,
}
r = rows[0]
out = {"found": True, "valid": False, "reason": "", "print": _print_out(conn, r)}
if key is None:
out["reason"] = "The seal key is not available, so the signature cannot be checked."
return out
signed = any(
stamps.verify_stamp(key, pid, kind, seal, r["by"], minute, sig)
for pid, kind, seal in _sign_candidates(conn, r)
for minute in _minutes(r)
)
if not signed:
out["reason"] = "The signature does not match the print log. Treat the copy as not controlled."
return out
if shown is not None and r.get("stamp"):
logged, seen = _squash(str(r["stamp"])), _squash(shown)
if not (logged.endswith(seen) or seen.endswith(logged)):
out["reason"] = "The stamp text differs from the print log. The copy was changed."
return out
out["valid"] = True
out["reason"] = "The stamp matches the print log."
return out
def _seal_key(request: Request) -> bytes | None:
try:
return settings_of(request).seal_key()
except Exception:
return None
def _audit_verify(conn: Any, p: Principal, results: list[dict[str, Any]], source: str) -> None:
audit.append(
conn,
action="admin.stamp_verified",
user_id=p.user_id,
session_id=p.session_id,
role_snapshot=p.role_snapshot,
subject_type="print",
subject_id=",".join(str((x["print"] or {}).get("id", "")) for x in results)[:300] or None,
detail={
"source": source,
"results": [
{"found": x["found"], "valid": x["valid"], "print_id": (x["print"] or {}).get("id")} for x in results
],
},
ip=p.ip,
)
@router.post("/stamp-verify", dependencies=WRITE)
def stamp_verify(
body: StampText,
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
text = body.text
sig: str | None = None
shown: str | None = None
m = RX_SIG_IN_TEXT.search(text)
if m is not None:
sig, shown = m[1], text[: m.end()]
else:
alone = RX_SIG_ALONE.match(text)
if alone:
sig = alone[1]
res = check_stamp(d.conn, _seal_key(request), sig, shown)
_audit_verify(d.conn, p, [res], "text")
return res
def pdf_stamps(data: bytes) -> list[tuple[str, str]]:
"""(signature, the page text up to and including it) for every stamp in a PDF's text, first page first."""
from pypdf import PdfReader
from pypdf.errors import PdfReadError
try:
reader = PdfReader(io.BytesIO(data))
found: dict[str, str] = {}
for page in reader.pages:
text = page.extract_text() or ""
for m in RX_SIG_IN_TEXT.finditer(text):
sig = m[1].lower()
if sig not in found:
found[sig] = text[: m.end()]
if len(found) >= PDF_STAMPS_MAX:
break
except (PdfReadError, ValueError, KeyError, TypeError) as e:
raise ApiError(422, "bad_pdf", f"The PDF cannot be read: {type(e).__name__}.") from None
return list(found.items())
@router.post("/stamp-verify/pdf")
async def stamp_verify_pdf(
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
length = request.headers.get("content-length")
if length is not None and length.isdigit() and int(length) > PDF_MAX_BYTES + FORM_SLACK:
raise ApiError(413, "too_large", "The PDF is larger than 25 MB.")
form = await request.form(max_files=1, max_fields=2)
try:
file = form.get("file")
if not isinstance(file, UploadFile):
raise ApiError(422, "invalid_request", "Send the PDF in the 'file' field.")
data = await file.read(PDF_MAX_BYTES + 1)
finally:
await form.close()
if len(data) > PDF_MAX_BYTES:
raise ApiError(413, "too_large", "The PDF is larger than 25 MB.")
if not data.startswith(b"%PDF-"):
raise ApiError(422, "not_pdf", "The file is not a PDF.")
found = await run_in_threadpool(pdf_stamps, data)
key = _seal_key(request)
def check_all() -> list[dict[str, Any]]:
results = [check_stamp(d.conn, key, sig, shown) for sig, shown in found]
_audit_verify(d.conn, p, results, "pdf")
return results
return {"stamps": await run_in_threadpool(check_all)}
# ---------------------------------------------------------------- directory flags
@router.get("/directory-flags")
def directory_flags(p: Principal = Depends(admin_principal), d: Db = Depends(get_db)) -> dict[str, Any]: # noqa: B008
conn = d.conn
with conn.cursor(row_factory=dict_row) as cur:
run = cur.execute(
"SELECT finished_at FROM job_runs WHERE job = 'ad-reconcile' AND finished_at IS NOT NULL"
" ORDER BY id DESC LIMIT 1"
).fetchone()
rows = cur.execute(
"SELECT id, sam_account, display_name, emp_no, directory_flags FROM users WHERE active"
" AND (coalesce((directory_flags->>'emp_missing')::bool, false)"
" OR coalesce((directory_flags->>'emp_duplicate')::bool, false))"
" ORDER BY emp_no NULLS FIRST, lower(display_name)"
).fetchall()
synced = cur.execute("SELECT max(ad_synced_at) AS at FROM users").fetchone()
missing: list[dict[str, Any]] = []
dupes: dict[str, list[dict[str, Any]]] = {}
for r in rows:
who = {"user_id": str(r["id"]), "sam_account": r["sam_account"], "display_name": r["display_name"]}
flags = r["directory_flags"] or {}
if flags.get("emp_missing"):
missing.append(who)
if flags.get("emp_duplicate") and r["emp_no"]:
dupes.setdefault(str(r["emp_no"]), []).append(who)
return {
"checked_at": (run or {}).get("finished_at") or (synced or {}).get("at"),
"missing": missing,
"duplicate": [{"emp_no": k, "users": v} for k, v in sorted(dupes.items())],
}
+15 -3
View File
@@ -15,7 +15,7 @@ from fastapi import Depends, Request, Response
from psycopg.types.json import Jsonb
from .. import audit, db, events
from ..auth import devices, sessions
from ..auth import devices, secsettings, sessions
from ..auth import directory as userdir
from ..auth import groups as groupmap
from ..auth.ldap import Directory, DirectoryProtocol, LdapUnavailable
@@ -48,6 +48,15 @@ def settings_of(request: Request) -> Settings:
return s
def effective_settings(request: Request, conn: Any) -> Settings:
"""The settings with the Admin's security values applied, read once per request."""
s: Settings | None = getattr(request.state, "sec_settings", None)
if s is None:
s = secsettings.overlay(conn, settings_of(request))
request.state.sec_settings = s
return s
def directory_of(request: Request) -> DirectoryProtocol:
d: DirectoryProtocol | None = request.app.state.directory
if d is None:
@@ -257,9 +266,9 @@ def resolve(request: Request, d: Db, *, allow_locked: bool, token: str | None =
token = token or request.cookies.get(sessions.SESSION_COOKIE)
if not token:
raise ApiError(401, "no_session", "Not signed in.")
s = settings_of(request)
key = session_key(request)
conn = d.conn
s = effective_settings(request, conn)
ip = ip_of(request)
row = sessions.load(conn, key, token)
if row is None or row["revoked_at"] is not None or row["expires_at"] <= row["db_now"]:
@@ -312,7 +321,7 @@ def resolve(request: Request, d: Db, *, allow_locked: bool, token: str | None =
if now - row["roles_checked_at"] > timedelta(minutes=s.roles_refresh_minutes):
roles, groups = _refresh_roles(conn, directory_of(request), row, ip)
device = devices.get(conn, row["device_id"]) if row.get("device_id") else None
return Principal(
p = Principal(
session=row,
user=user,
roles=roles,
@@ -324,6 +333,9 @@ def resolve(request: Request, d: Db, *, allow_locked: bool, token: str | None =
ip=ip,
lock_minutes=idle_minutes(s, roles)[1],
)
# The error handler audits a 403 against the principal it was refused to (policy.denied)
request.state.principal = p
return p
def current_principal(request: Request, d: Db = Depends(get_db)) -> Principal: # noqa: B008
+16 -1
View File
@@ -49,7 +49,22 @@ def error_response(err: ApiError) -> JSONResponse:
def install(app: FastAPI) -> None:
@app.exception_handler(ApiError)
async def _api_error(_: Request, exc: ApiError) -> JSONResponse:
async def _api_error(request: Request, exc: ApiError) -> JSONResponse:
principal = getattr(request.state, "principal", None)
if exc.status == 403 and principal is not None:
# A refusal to a signed-in person is audited as policy.denied: method, path and code, never the body
from starlette.concurrency import run_in_threadpool
from ..domain.security_alerts import record_denied
await run_in_threadpool(
record_denied,
request.app.state.settings,
principal,
method=request.method,
path=request.url.path,
code=exc.code,
)
return error_response(exc)
@app.exception_handler(RequestValidationError)
+1
View File
@@ -493,6 +493,7 @@ def reject_line(
)
head = f"NCR {ncr}" if ncr else "NCR # pending"
w.log(f"ITP line {key} rejected", f"{head} · {note}" if note else head)
dom.notify_reject(w, key, ncr, note)
return _answer(d, p, itp_id)
+511
View File
@@ -0,0 +1,511 @@
"""Notifications: the Admin's routing rules, test send and sent log, and each person's own view on Account.
Who gets what lives in the database (notification_rules, _recipients, _exclusions), never in the environment.
"""
from __future__ import annotations
import logging
import re
import uuid
from datetime import time
from typing import Any, Literal, cast
from fastapi import APIRouter, Depends, Query, Request
from psycopg.rows import dict_row
from pydantic import BaseModel, ConfigDict, Field
from .. import audit
from ..auth.ldap import LdapUnavailable
from ..notify import seed as seed_rules
from ..notify import send as notify_send
from ..notify import smtp
from ..notify.catalog import EVENT_TYPES, SECURITY_TYPES
from ..notify.routing import (
DIRECTORY_DOWN,
NotifyDirectory,
Rule,
address_ok,
cn_of,
dn_under,
load_rules,
supervisor_depts,
)
from ..settings import Settings
from ..text import clean_text
from .deps import Db, Principal, admin_principal, current_principal, directory_of, get_db, password_principal
from .errors import ApiError
from .packets import WRITE
log = logging.getLogger(__name__)
router = APIRouter(tags=["notifications"])
ADMIN = "/api/admin/notifications"
ACCOUNT = "/api/account/notifications"
RECIPIENTS_MAX = 50
EXCLUSIONS_MAX = 200
TIME_RE = re.compile(r"^([01][0-9]|2[0-3]):([0-5][0-9])$")
NAME_MAX = 256
DN_MAX = 1024
def _directory(request: Request) -> NotifyDirectory:
return cast(NotifyDirectory, directory_of(request))
def _settings(request: Request) -> Settings:
s: Settings = request.app.state.settings
return s
def _known(event_type: str) -> None:
if event_type not in EVENT_TYPES:
raise ApiError(404, "not_found", "No such notification.")
# ---------------------------------------------------------------- views
def rule_view(conn: Any, rule: Rule) -> dict[str, Any]:
et = EVENT_TYPES[rule.event_type]
last = conn.execute(
"SELECT at, result FROM notifications_sent WHERE event_type = %s ORDER BY id DESC LIMIT 1",
(rule.event_type,),
).fetchone()
return {
"event_type": rule.event_type,
"label": et.label,
"description": et.description,
"security": et.security,
"dept_scoped": et.dept_scoped,
"param": {"name": et.param, "value": rule.param_value, "default": et.param_default} if et.param else None,
"enabled": rule.enabled,
"delivery": rule.delivery,
"digest_time": rule.digest_time.strftime("%H:%M"),
"recipients": [
{
"id": r["id"],
"kind": r["kind"],
"group_dn": r["group_dn"],
"group_name": r["group_name"],
"user_id": str(r["user_id"]) if r["user_id"] else None,
"display_name": r.get("display_name"),
"address": r["address"],
}
for r in rule.recipients
],
"exclusions": [
{"user_id": str(x["user_id"]), "display_name": x.get("display_name"), "self": bool(x["self"])}
for x in rule.exclusions
],
"version": rule.version,
"last_sent": {"at": last[0], "result": last[1]} if last else None,
}
def _rules(conn: Any, settings: Settings) -> dict[str, Rule]:
seed_rules(conn, settings)
return load_rules(conn)
@router.get(ADMIN)
def admin_rules(
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
s = _settings(request)
rules = _rules(d.conn, s)
return {
"dry_run": bool(s.notify_dry_run),
"smtp": {"host": s.smtp_host, "port": s.smtp_port, "from": s.smtp_from, "starttls": s.smtp_starttls},
"subject_prefix": smtp.subject_prefix(s),
"rules": [rule_view(d.conn, rules[k]) for k in EVENT_TYPES if k in rules],
}
# ---------------------------------------------------------------- update a rule
class RecipientIn(BaseModel):
model_config = ConfigDict(extra="forbid")
kind: Literal["group", "user", "mailbox", "dept_supervisor"]
group_dn: str | None = Field(default=None, max_length=DN_MAX)
group_name: str | None = Field(default=None, max_length=NAME_MAX)
user_id: uuid.UUID | None = None
address: str | None = Field(default=None, max_length=254)
class RuleIn(BaseModel):
model_config = ConfigDict(extra="forbid")
enabled: bool
delivery: Literal["immediate", "digest"]
digest_time: str = Field(max_length=5)
param: int | None = Field(default=None, ge=1, le=10000)
recipients: list[RecipientIn] = Field(max_length=RECIPIENTS_MAX)
exclusions: list[uuid.UUID] = Field(default_factory=list, max_length=EXCLUSIONS_MAX)
version: int
def _invalid(text: str) -> ApiError:
return ApiError(422, "invalid_request", text)
def _clean_recipients(conn: Any, s: Settings, event_type: str, items: list[RecipientIn]) -> list[dict[str, Any]]:
et = EVENT_TYPES[event_type]
base = s.ldap_base_dn.strip() or s.ldap_group_base_dn.strip()
out: list[dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
for r in items:
row: dict[str, Any] = {"kind": r.kind, "group_dn": None, "group_name": None, "user_id": None, "address": None}
if r.kind == "group":
dn = clean_text(r.group_dn or "", DN_MAX)
if not dn or not dn.lower().startswith("cn="):
raise _invalid("A group needs its DN.")
if base and not dn_under(dn, base):
raise _invalid("The group must be in the domain (under LDAP_BASE_DN).")
row["group_dn"] = dn
row["group_name"] = clean_text(r.group_name or "", NAME_MAX) or cn_of(dn)
key = ("group", dn.lower())
elif r.kind == "user":
if r.user_id is None:
raise _invalid("A user recipient needs a user.")
if conn.execute("SELECT 1 FROM users WHERE id = %s", (r.user_id,)).fetchone() is None:
raise _invalid("No such user.")
row["user_id"] = r.user_id
key = ("user", str(r.user_id))
elif r.kind == "mailbox":
addr = (r.address or "").strip()
if not address_ok(addr):
raise _invalid(f"Not an email address: {addr[:80]}")
row["address"] = addr
key = ("mailbox", addr.lower())
else:
if not et.dept_scoped:
raise _invalid("This notification has no department, so it has no Supervisor group.")
key = ("dept_supervisor", "")
if key in seen:
continue
seen.add(key)
out.append(row)
return out
@router.put(f"{ADMIN}/{{event_type}}", dependencies=WRITE)
def update_rule(
event_type: str,
body: RuleIn,
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
_known(event_type)
s = _settings(request)
conn = d.conn
et = EVENT_TYPES[event_type]
m = TIME_RE.match(body.digest_time)
if m is None:
raise _invalid("The digest time is HH:MM.")
digest_at = time(int(m.group(1)), int(m.group(2)))
if body.param is not None and et.param is None:
raise _invalid("This notification takes no setting.")
excl = list(dict.fromkeys(body.exclusions))
if excl and event_type in SECURITY_TYPES:
raise _invalid("Nobody can be left out of a security notification.")
recipients = _clean_recipients(conn, s, event_type, body.recipients)
if excl:
found = {r[0] for r in conn.execute("SELECT id FROM users WHERE id = ANY(%s)", (excl,)).fetchall()}
if len(found) != len(excl):
raise _invalid("An excluded person is not a known user.")
_rules(conn, s)
with conn.transaction():
before_rule = load_rules(conn, event_type).get(event_type)
assert before_rule is not None
before = rule_view(conn, before_rule)
done = conn.execute(
"UPDATE notification_rules SET enabled = %s, delivery = %s, digest_time = %s, param = %s,"
" version = version + 1, updated_at = now(), updated_by = %s WHERE event_type = %s AND version = %s"
" RETURNING version",
(
body.enabled,
body.delivery,
digest_at,
(body.param or et.param_default) if et.param else None,
p.user_id,
event_type,
body.version,
),
).fetchone()
if done is None:
raise ApiError(
409,
"version_conflict",
"Someone else changed this notification. Here is the current one.",
extra={"current": before},
)
conn.execute("DELETE FROM notification_recipients WHERE event_type = %s", (event_type,))
for r in recipients:
conn.execute(
"INSERT INTO notification_recipients (event_type, kind, group_dn, group_name, user_id, address)"
" VALUES (%s, %s, %s, %s, %s, %s)",
(event_type, r["kind"], r["group_dn"], r["group_name"], r["user_id"], r["address"]),
)
conn.execute(
"DELETE FROM notification_exclusions WHERE event_type = %s AND NOT self AND NOT (user_id = ANY(%s))",
(event_type, excl),
)
for uid in excl:
conn.execute(
"INSERT INTO notification_exclusions (event_type, user_id, self, created_by) VALUES (%s, %s, false, %s)"
" ON CONFLICT (event_type, user_id) DO NOTHING",
(event_type, uid, p.user_id),
)
after_rule = load_rules(conn, event_type)[event_type]
after = rule_view(conn, after_rule)
audit.append(
conn,
action="admin.notification_rule_updated",
user_id=p.user_id,
session_id=p.session_id,
role_snapshot=p.role_snapshot,
subject_type="notification_rule",
subject_id=event_type,
detail={"event_type": event_type, "before": _audit_view(before), "after": _audit_view(after)},
ip=p.ip,
)
return after
def _audit_view(v: dict[str, Any]) -> dict[str, Any]:
keep = ("enabled", "delivery", "digest_time", "param", "version")
out = {k: v[k] for k in keep}
out["recipients"] = [
{k: r[k] for k in ("kind", "group_dn", "group_name", "user_id", "address") if r.get(k)} for r in v["recipients"]
]
out["exclusions"] = [{"user_id": x["user_id"], "self": x["self"]} for x in v["exclusions"]]
return out
# ---------------------------------------------------------------- group search, test send, sent log
@router.get(f"{ADMIN}/groups")
def search_groups(
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
q: str = Query(..., min_length=2, max_length=100),
) -> list[dict[str, Any]]:
try:
found = _directory(request).search_groups(q.strip(), 25)
except LdapUnavailable as e:
log.warning("group search: AD unavailable: %s", e)
raise ApiError(503, "ldap_unavailable", "The directory is not available. Try again in a minute.") from None
return [{"dn": g.get("dn"), "name": g.get("name"), "description": g.get("description")} for g in found]
class TestIn(BaseModel):
model_config = ConfigDict(extra="forbid")
to: str | None = Field(default=None, max_length=254)
@router.post(f"{ADMIN}/{{event_type}}/test", dependencies=WRITE)
def test_send(
event_type: str,
body: TestIn,
request: Request,
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
_known(event_type)
s = _settings(request)
to = (body.to or "").strip() or None
if to is not None and not address_ok(to):
raise _invalid("Not an email address.")
conn = d.conn
_rules(conn, s)
out = notify_send.send_test(conn, s, _directory(request), event_type, to=to, requested_by=p.user_id)
audit.append(
conn,
action="admin.notification_test",
user_id=p.user_id,
session_id=p.session_id,
role_snapshot=p.role_snapshot,
subject_type="notification_rule",
subject_id=event_type,
detail={
"event_type": event_type,
"to": to,
"result": out["result"],
"recipients": len(out["recipients"]),
"sent_id": out["sent_id"],
},
ip=p.ip,
)
return out
@router.get(f"{ADMIN}/sent")
def sent_log(
p: Principal = Depends(admin_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
event_type: str | None = Query(None, max_length=64),
limit: int = Query(50, ge=1, le=500),
before: int | None = Query(None, ge=1),
) -> dict[str, Any]:
sql = (
"SELECT id, at, event_type, digest, test, subject, recipients, result, error FROM notifications_sent"
" WHERE (%s::text IS NULL OR event_type = %s) AND (%s::bigint IS NULL OR id < %s) ORDER BY id DESC LIMIT %s"
)
args = (event_type or None, event_type or None, before, before)
with d.conn.cursor(row_factory=dict_row) as cur:
rows = [dict(r) for r in cur.execute(sql, (*args, limit)).fetchall()]
return {"rows": rows, "next_before": rows[-1]["id"] if len(rows) == limit else None}
# ---------------------------------------------------------------- account
def _my_groups(directory: NotifyDirectory, user_id: uuid.UUID) -> tuple[Any, set[str]]:
"""(the AdUser or None, lower-case names of the user's groups in the PacketStation OU, nested)."""
u = directory.get_user_by_guid(user_id)
if u is None:
return None, set()
return u, {g.lower() for g in directory.groups_of(u.dn)}
def account_view(conn: Any, settings: Settings, directory: NotifyDirectory, p: Principal) -> list[dict[str, Any]]:
rules = _rules(conn, settings)
email = str(p.user.get("email") or "").strip().lower()
me = str(p.user_id)
sup = supervisor_depts(p.roles)
groups: tuple[Any, set[str]] | None = None
down = False
member_cache: dict[str, bool] = {}
out: list[dict[str, Any]] = []
for key, et in EVENT_TYPES.items():
rule = rules.get(key)
if rule is None or not rule.enabled:
continue
why: list[str] = []
for r in rule.recipients:
kind = r["kind"]
if kind == "user" and str(r["user_id"]) == me:
why.append("user")
elif kind == "mailbox" and email and str(r["address"]).strip().lower() == email:
why.append("mailbox")
elif kind == "dept_supervisor" and et.dept_scoped and sup:
why.extend(f"Supervisor group of {code}" for code in sup)
elif kind == "group" and not down:
dn = str(r["group_dn"])
name = str(r["group_name"] or cn_of(dn))
try:
if dn_under(dn, settings.ldap_group_base_dn):
if groups is None:
groups = _my_groups(directory, p.user_id)
hit = name.lower() in groups[1]
else:
if dn.lower() not in member_cache:
member_cache[dn.lower()] = any(
u.guid == p.user_id for u in directory.members_of_dn(dn) if not u.disabled
)
hit = member_cache[dn.lower()]
except Exception as e:
log.warning("account notifications: directory: %s", type(e).__name__)
down = True
continue
if hit:
why.append(f"group {name}")
excl = next((x for x in rule.exclusions if str(x["user_id"]) == me), None)
opted_out = bool(excl and excl["self"])
if excl and not excl["self"]:
why.append("left out by an Admin")
if down and any(r["kind"] == "group" for r in rule.recipients):
why.append(DIRECTORY_DOWN)
reaches = excl is None and any(w not in (DIRECTORY_DOWN, "left out by an Admin") for w in why)
out.append(
{
"event_type": key,
"label": et.label,
"security": et.security,
"delivery": rule.delivery,
"reaches": reaches,
"why": list(dict.fromkeys(why)),
"opted_out": opted_out,
"can_opt_out": not et.security,
}
)
return out
@router.get(ACCOUNT)
def my_notifications(
request: Request,
p: Principal = Depends(current_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> list[dict[str, Any]]:
return account_view(d.conn, _settings(request), _directory(request), p)
class OptOutIn(BaseModel):
model_config = ConfigDict(extra="forbid")
opt_out: bool
@router.put(f"{ACCOUNT}/{{event_type}}", dependencies=WRITE)
def opt_out(
event_type: str,
body: OptOutIn,
request: Request,
p: Principal = Depends(password_principal), # noqa: B008
d: Db = Depends(get_db), # noqa: B008
) -> dict[str, Any]:
_known(event_type)
if event_type in SECURITY_TYPES:
raise ApiError(403, "forbidden", "Security notifications cannot be turned off.")
conn = d.conn
s = _settings(request)
_rules(conn, s)
with conn.transaction():
if body.opt_out:
changed = conn.execute(
"INSERT INTO notification_exclusions (event_type, user_id, self, created_by) VALUES (%s, %s, true, %s)"
" ON CONFLICT (event_type, user_id) DO NOTHING",
(event_type, p.user_id, p.user_id),
).rowcount
else:
changed = conn.execute(
"DELETE FROM notification_exclusions WHERE event_type = %s AND user_id = %s AND self",
(event_type, p.user_id),
).rowcount
if changed:
audit.append(
conn,
action="account.notification_opt_out",
user_id=p.user_id,
session_id=p.session_id,
role_snapshot=p.role_snapshot,
subject_type="notification_rule",
subject_id=event_type,
detail={"event_type": event_type, "opt_out": body.opt_out},
ip=p.ip,
)
item = next((x for x in account_view(conn, s, _directory(request), p) if x["event_type"] == event_type), None)
if item is None:
# The rule is off: nothing reaches anyone, but the choice is kept
et = EVENT_TYPES[event_type]
item = {
"event_type": event_type,
"label": et.label,
"security": False,
"delivery": None,
"reaches": False,
"why": [],
"opted_out": body.opt_out,
"can_opt_out": True,
}
return item
+183 -91
View File
@@ -83,13 +83,43 @@ def get_user(conn: Any, user_id: Any) -> dict[str, Any] | None:
return row
def mark_inactive(conn: Any, user_id: Any, *, reason: str, gone: bool) -> None:
def end_sessions(conn: Any, user_id: Any, reason: str, *, by: str | None = None) -> list[uuid.UUID]:
"""End every live session of a user, one `auth.session_ended` audit row each. Returns the session ids."""
rows = conn.execute(
"SELECT id, roles_snapshot FROM sessions WHERE user_id = %s AND revoked_at IS NULL", (user_id,)
).fetchall()
ended: list[uuid.UUID] = []
for sid, snap in rows:
if not sessions.revoke(conn, sid, reason):
continue
ended.append(sid)
detail: dict[str, Any] = {"reason": reason}
if by:
detail["by"] = by
audit.append(
conn,
action="auth.session_ended",
user_id=user_id,
session_id=sid,
role_snapshot=",".join((snap or {}).get("roles", [])),
detail=detail,
)
return ended
def mark_inactive(conn: Any, user_id: Any, *, reason: str, gone: bool, audit_by: str | None = None) -> list[uuid.UUID]:
"""Mark a user inactive (disabled or gone in AD), drop the roles and end the sessions.
With `audit_by` every ended session gets an `auth.session_ended` row naming who ended it. Returns the ids.
"""
conn.execute(
"UPDATE users SET active = false, directory_flags = directory_flags || %s, ad_synced_at = now() WHERE id = %s",
(Jsonb({"disabled": not gone, "gone": gone}), user_id),
)
conn.execute("DELETE FROM user_roles WHERE user_id = %s", (user_id,))
sessions.revoke_user(conn, user_id, reason)
if audit_by:
return end_sessions(conn, user_id, reason, by=audit_by)
return sessions.revoke_user(conn, user_id, reason)
def public_user(row: dict[str, Any]) -> dict[str, Any]:
@@ -115,133 +145,195 @@ def ad_user_public(u: AdUser) -> dict[str, Any]:
}
# ---------------------------------------------------------------- AD refresh
# ---------------------------------------------------------------- AD reconcile
RECONCILE_BY = "ad-reconcile"
LIST_CAP = 200 # names kept in a report list; the count is always whole
class ReconcileRefused(Exception):
"""AD answered, but with something that would deactivate people by mistake. Nothing was changed."""
@dataclass
class RefreshResult:
groups_checked: int = 0
groups_missing: list[str] = field(default_factory=list)
groups_extra: list[str] = field(default_factory=list)
users_seen: int = 0
created: int = 0
updated: int = 0
deactivated: int = 0
sessions_ended: int = 0
emp_missing: list[str] = field(default_factory=list)
emp_duplicate: list[str] = field(default_factory=list)
created_names: list[str] = field(default_factory=list)
deactivated_names: list[str] = field(default_factory=list)
def as_dict(self) -> dict[str, Any]:
"""The report: lists of names (missing and extra groups, accounts without an employee ID, duplicated
employee IDs) and counts. The same shape is the ad-reconcile run's counts."""
return {
"groups_checked": self.groups_checked,
"groups_missing": self.groups_missing,
"groups_missing": self.groups_missing[:LIST_CAP],
"groups_extra": self.groups_extra[:LIST_CAP],
"users_seen": self.users_seen,
"created": self.created,
"updated": self.updated,
"deactivated": self.deactivated,
"emp_missing": self.emp_missing,
"emp_duplicate": self.emp_duplicate,
"sessions_ended": self.sessions_ended,
"emp_missing": self.emp_missing[:LIST_CAP],
"emp_duplicate": self.emp_duplicate[:LIST_CAP],
}
def summary(self) -> dict[str, int]:
"""Counts only, for the audit row and the log."""
return {
"groups_checked": self.groups_checked,
"groups_missing": len(self.groups_missing),
"groups_extra": len(self.groups_extra),
"users_seen": self.users_seen,
"created": self.created,
"updated": self.updated,
"deactivated": self.deactivated,
"sessions_ended": self.sessions_ended,
"emp_missing": len(self.emp_missing),
"emp_duplicate": len(self.emp_duplicate),
}
def refresh_from_ad(
conn: Any, directory: DirectoryProtocol, *, actor: sessions.Actor | None = None, ip: str | None = None
) -> RefreshResult:
"""Enumerate every mapped group, upsert its members and rebuild their roles; deactivate gone accounts.
@dataclass
class _AdRead:
"""Everything the reconcile reads from AD, before any database write."""
All AD reads happen first, then the database writes in one transaction, so no lock is held while AD
answers. Raises LdapUnavailable when AD cannot be read.
"""
res = RefreshResult()
present: list[tuple[str, str]] # mapped groups found in the OU: (name as mapped, dn)
missing: list[str]
extra: list[str]
seen: dict[uuid.UUID, AdUser]
member_groups: dict[uuid.UUID, set[str]]
unseen: dict[uuid.UUID, AdUser | None]
def _read_ad(conn: Any, directory: DirectoryProtocol) -> _AdRead:
names = groupmap.group_names()
in_ad = {g.lower() for g in directory.list_groups()}
res.groups_missing = [n for n in names if n.lower() not in in_ad]
present = [n for n in names if n.lower() in in_ad]
in_ou = {cn.lower(): (cn, dn) for cn, dn in directory.list_groups_detail()}
mapped = {n.lower() for n in names}
present = [(n, in_ou[n.lower()][1]) for n in names if n.lower() in in_ou]
missing = [n for n in names if n.lower() not in in_ou]
extra = sorted((cn for key, (cn, _) in in_ou.items() if key not in mapped), key=str.lower)
if not present:
raise ReconcileRefused("no mapped group was found in the OU; check LDAP_GROUP_BASE_DN")
seen: dict[uuid.UUID, AdUser] = {}
member_groups: dict[uuid.UUID, set[str]] = {}
for name in present:
for u in directory.members_of(name):
for name, dn in present:
for u in directory.members_of_dn(dn):
seen.setdefault(u.guid, u)
member_groups.setdefault(u.guid, set()).add(name)
res.groups_checked = len(present)
res.users_seen = len(seen)
known = [r[0] for r in conn.execute("SELECT id FROM users WHERE active ORDER BY id").fetchall()]
unseen_ids = [uid for uid in known if uid not in seen]
if not seen and unseen_ids:
raise ReconcileRefused("AD listed no member in any mapped group; nobody was changed")
unseen = directory.users_by_guid(unseen_ids) if unseen_ids else {}
return _AdRead(present, missing, extra, seen, member_groups, unseen)
known = conn.execute("SELECT id FROM users WHERE active").fetchall()
unseen = [r[0] for r in known if r[0] not in seen]
# Accounts not in any group: still in AD (keep, no roles), disabled, or gone
unseen_state: dict[uuid.UUID, AdUser | None] = {uid: directory.get_user_by_guid(uid) for uid in unseen}
enabled = [u for u in seen.values() if not u.disabled]
def refresh_from_ad(
conn: Any,
directory: DirectoryProtocol,
*,
actor: sessions.Actor | None = None,
ip: str | None = None,
run_id: int | None = None,
) -> RefreshResult:
"""The AD reconcile. List the OU (missing and extra groups), enumerate the members of every mapped group
(IN_CHAIN, paged), upsert them on objectGUID and rebuild their roles. An active user AD no longer lists in
any group is looked up by GUID: gone or disabled means inactive with the sessions ended (`ad_gone`,
`ad_disabled`); still there means no roles and the sessions ended (`roles_lost`). directory_flags gets
emp_missing, emp_duplicate, gone and disabled.
Every AD read happens first, then the writes in one transaction, so no lock is held while AD answers.
Raises LdapUnavailable when AD cannot be read and ReconcileRefused when what it read looks wrong.
"""
res = RefreshResult()
ad = _read_ad(conn, directory)
res.groups_missing = ad.missing
res.groups_extra = ad.extra
res.groups_checked = len(ad.present)
res.users_seen = len(ad.seen)
enabled = [u for u in ad.seen.values() if not u.disabled]
enabled += [u for u in ad.unseen.values() if u is not None and not u.disabled]
res.emp_missing = sorted(u.sam_account for u in enabled if not u.emp_no)
counts = Counter(u.emp_no for u in enabled if u.emp_no)
res.emp_duplicate = sorted(e for e, n in counts.items() if n > 1)
dup = set(res.emp_duplicate)
who: dict[str, Any] = {
"user_id": actor.user_id if actor else None,
"session_id": actor.session_id if actor else None,
"role_snapshot": actor.role_snapshot if actor else None,
}
with conn.transaction():
for guid, u in seen.items():
grp = groupmap.mapped_groups(member_groups[guid])
was_active_row = conn.execute("SELECT active FROM users WHERE id = %s", (guid,)).fetchone()
created = upsert_user(conn, u, groups=grp)
conn.execute(
"UPDATE users SET directory_flags = directory_flags || %s WHERE id = %s",
(Jsonb({"emp_duplicate": bool(u.emp_no and u.emp_no in dup), "gone": False}), guid),
)
if created:
res.created += 1
else:
res.updated += 1
if u.disabled:
set_roles(conn, guid, [])
sessions.revoke_user(conn, guid, "ad_disabled")
if was_active_row is not None and was_active_row[0]:
res.deactivated += 1
audit.append(
conn,
action="ad.user_deactivated",
user_id=actor.user_id if actor else None,
session_id=actor.session_id if actor else None,
role_snapshot=actor.role_snapshot if actor else None,
subject_type="user",
subject_id=guid,
detail={"reason": "disabled", "sam_account": u.sam_account},
ip=ip,
)
else:
set_roles(conn, guid, groupmap.roles_from_groups(grp))
for uid, found in unseen_state.items():
if found is None or found.disabled:
mark_inactive(conn, uid, reason="ad_disabled" if found else "ad_gone", gone=found is None)
res.deactivated += 1
audit.append(
conn,
action="ad.user_deactivated",
user_id=actor.user_id if actor else None,
session_id=actor.session_id if actor else None,
role_snapshot=actor.role_snapshot if actor else None,
subject_type="user",
subject_id=uid,
detail={"reason": "gone" if found is None else "disabled"},
ip=ip,
)
else:
upsert_user(conn, found, groups=[])
set_roles(conn, uid, [])
sessions.revoke_user(conn, uid, "roles_lost")
def deactivated(uid: uuid.UUID, reason: str, sam: str) -> None:
res.deactivated += 1
res.deactivated_names.append(sam)
audit.append(
conn,
action="ad.refresh",
user_id=actor.user_id if actor else None,
session_id=actor.session_id if actor else None,
role_snapshot=actor.role_snapshot if actor else None,
detail={
"groups_checked": res.groups_checked,
"groups_missing": len(res.groups_missing),
"users_seen": res.users_seen,
"created": res.created,
"updated": res.updated,
"deactivated": res.deactivated,
"emp_missing": len(res.emp_missing),
"emp_duplicate": len(res.emp_duplicate),
},
action="ad.user_deactivated",
subject_type="user",
subject_id=uid,
detail={"reason": reason, "sam_account": sam},
ip=ip,
**who,
)
log.info("AD refresh: %s", {k: v for k, v in res.as_dict().items() if isinstance(v, int)})
def flags(uid: uuid.UUID, u: AdUser | None) -> None:
conn.execute(
"UPDATE users SET directory_flags = directory_flags || %s WHERE id = %s",
(Jsonb({"emp_duplicate": bool(u and u.emp_no and u.emp_no in dup), "gone": u is None}), uid),
)
with conn.transaction():
for guid, u in ad.seen.items():
grp = groupmap.mapped_groups(ad.member_groups[guid])
was = conn.execute("SELECT active FROM users WHERE id = %s", (guid,)).fetchone()
if upsert_user(conn, u, groups=grp):
res.created += 1
res.created_names.append(u.sam_account)
else:
res.updated += 1
flags(guid, u)
roles = [] if u.disabled else groupmap.roles_from_groups(grp)
set_roles(conn, guid, roles)
if u.disabled:
res.sessions_ended += len(end_sessions(conn, guid, "ad_disabled", by=RECONCILE_BY))
if was is not None and was[0]:
deactivated(guid, "disabled", u.sam_account)
elif not roles:
res.sessions_ended += len(end_sessions(conn, guid, "roles_lost", by=RECONCILE_BY))
for uid, found in ad.unseen.items():
if found is None or found.disabled:
row = conn.execute("SELECT sam_account FROM users WHERE id = %s", (uid,)).fetchone()
ended = mark_inactive(
conn,
uid,
reason="ad_gone" if found is None else "ad_disabled",
gone=found is None,
audit_by=RECONCILE_BY,
)
res.sessions_ended += len(ended)
flags(uid, found)
deactivated(uid, "gone" if found is None else "disabled", row[0] if row else str(uid))
else:
upsert_user(conn, found, groups=[])
flags(uid, found)
set_roles(conn, uid, [])
res.sessions_ended += len(end_sessions(conn, uid, "roles_lost", by=RECONCILE_BY))
detail: dict[str, Any] = dict(res.summary())
if run_id is not None:
detail["run_id"] = run_id
audit.append(conn, action="ad.refresh", detail=detail, ip=ip, **who)
log.info("AD reconcile: %s", res.summary())
return res
reconcile = refresh_from_ad
+77 -2
View File
@@ -32,6 +32,8 @@ USER_ATTRS = (
ACCOUNTDISABLE = 2
INVALID_CREDENTIALS = 49
PAGE_SIZE = 500
GUID_BATCH = 50 # objectGUID terms in one OR filter
SEARCH_GROUPS_MAX = 100
class LdapUnavailable(Exception):
@@ -60,6 +62,10 @@ class DirectoryProtocol(Protocol):
def groups_of(self, dn: str) -> list[str]: ...
def list_groups(self) -> list[str]: ...
def members_of(self, group_cn: str) -> list[AdUser]: ...
def list_groups_detail(self) -> list[tuple[str, str]]: ...
def members_of_dn(self, group_dn: str) -> list[AdUser]: ...
def search_groups(self, q: str, limit: int = 25) -> list[dict[str, str]]: ...
def users_by_guid(self, guids: list[uuid.UUID]) -> dict[uuid.UUID, AdUser | None]: ...
# ---------------------------------------------------------------- attribute helpers
@@ -287,6 +293,7 @@ class Directory:
return sorted({cn for e in found if (cn := _text(e.get("attributes") or {}, "cn"))})
def members_of(self, group_cn: str) -> list[AdUser]:
"""Every account in the group under LDAP_GROUP_BASE_DN, nested included (IN_CHAIN, paged)."""
from ldap3.utils.conv import escape_filter_chars
with self._service() as conn:
@@ -299,6 +306,74 @@ class Directory:
if not groups:
return []
gdn = _text(groups[0].get("attributes") or {}, "distinguishedName") or str(groups[0].get("dn") or "")
flt = f"(&(objectClass=user)(objectCategory=person)(memberOf:{IN_CHAIN}:={escape_filter_chars(gdn)}))"
found = self._search(conn, self.settings.ldap_base_dn, flt, USER_ATTRS)
return self._members(conn, gdn)
def _members(self, conn: Any, group_dn: str) -> list[AdUser]:
from ldap3.utils.conv import escape_filter_chars
flt = f"(&(objectClass=user)(objectCategory=person)(memberOf:{IN_CHAIN}:={escape_filter_chars(group_dn)}))"
found = self._search(conn, self.settings.ldap_base_dn, flt, USER_ATTRS)
return [user_from_entry(e) for e in found]
def list_groups_detail(self) -> list[tuple[str, str]]:
"""(cn, dn) of every group in LDAP_GROUP_BASE_DN, sorted by cn."""
with self._service() as conn:
found = self._search(
conn, self.settings.ldap_group_base_dn, "(objectClass=group)", ["cn", "distinguishedName"]
)
out: dict[str, tuple[str, str]] = {}
for e in found:
attrs = e.get("attributes") or {}
cn = _text(attrs, "cn")
if cn:
out[cn.lower()] = (cn, _text(attrs, "distinguishedName") or str(e.get("dn") or ""))
return sorted(out.values(), key=lambda t: t[0].lower())
def members_of_dn(self, group_dn: str) -> list[AdUser]:
"""Every account in the group by DN, nested included, disabled accounts too. Paged at 500."""
with self._service() as conn:
return self._members(conn, group_dn)
def search_groups(self, q: str, limit: int = 25) -> list[dict[str, str]]:
"""Groups anywhere under LDAP_BASE_DN whose name or description contains q: [{dn, name, description}]."""
from ldap3.utils.conv import escape_filter_chars
q = q.strip()
if not q:
return []
limit = max(1, min(int(limit), SEARCH_GROUPS_MAX))
term = escape_filter_chars(q)
flt = f"(&(objectClass=group)(|(cn=*{term}*)(description=*{term}*)))"
with self._service() as conn:
found = self._search(conn, self.settings.ldap_base_dn, flt, ["cn", "distinguishedName", "description"])
out = []
for e in found:
attrs = e.get("attributes") or {}
name = _text(attrs, "cn")
if not name:
continue
out.append(
{
"dn": _text(attrs, "distinguishedName") or str(e.get("dn") or ""),
"name": name,
"description": _text(attrs, "description") or "",
}
)
out.sort(key=lambda g: (not g["name"].lower().startswith(q.lower()), g["name"].lower()))
return out[:limit]
def users_by_guid(self, guids: list[uuid.UUID]) -> dict[uuid.UUID, AdUser | None]:
"""The accounts for these objectGUIDs, read in batches. None for a GUID AD no longer has."""
wanted = list(dict.fromkeys(guids))
out: dict[uuid.UUID, AdUser | None] = dict.fromkeys(wanted)
if not wanted:
return out
with self._service() as conn:
for i in range(0, len(wanted), GUID_BATCH):
part = wanted[i : i + GUID_BATCH]
flt = "(|" + "".join(guid_filter(g) for g in part) + ")"
for e in self._search(conn, self.settings.ldap_base_dn, flt, USER_ATTRS):
u = user_from_entry(e)
if u.guid in out:
out[u.guid] = u
return out
+3
View File
@@ -101,6 +101,9 @@ def register_failure(
detail={"failures": int(row[0]), "where": where},
ip=ip,
)
from ..domain import security_alerts
security_alerts.pin_disabled(conn, user_id, int(row[0]), where)
return True
+5 -1
View File
@@ -14,6 +14,7 @@ from datetime import datetime, timedelta
from typing import Any
from ..settings import Settings
from . import secsettings
RATE_LIMITED = "rate_limited"
KINDS = ("password", "pin", "unlock", "signature", "pin_set")
@@ -62,7 +63,10 @@ def check(
ip: str | None,
now: datetime | None = None,
) -> int | None:
"""Seconds until another attempt is allowed, or None when allowed. Failures of all `accounts` count together."""
"""Seconds until another attempt is allowed, or None when allowed. Failures of all `accounts` count together.
The limits are the effective security settings (the Admin's values over the environment defaults)."""
settings = secsettings.overlay(conn, settings)
now = now or db_now(conn)
window = timedelta(minutes=settings.signin_account_window_minutes)
cooldown = timedelta(minutes=settings.signin_account_cooldown_minutes)
+252
View File
@@ -0,0 +1,252 @@
"""Security settings: the `security` settings row laid over the environment defaults.
The Admin Security tab edits the row. Every reader of these values (sessions, idle lock, roles refresh, sign-in
rate limits) goes through `effective()` or `overlay()`. The stored row is cached in the process for at most
CACHE_SECONDS; a save clears the cache at once and NOTIFYs `ps_settings` so the worker picks it up.
A stored value out of its bounds (an older bound, a hand edit) is ignored and the default applies.
"""
from __future__ import annotations
import json
import logging
import threading
import time
from collections import OrderedDict
from dataclasses import dataclass, field
from typing import Any
from psycopg.types.json import Jsonb
from .. import audit
from ..settings import Settings
log = logging.getLogger(__name__)
KEY = "security"
SETTINGS_CHANNEL = "ps_settings"
CACHE_SECONDS = 30.0
# key: (min, max). The names are the Settings field names where one exists.
BOUNDS: dict[str, tuple[int, int]] = {
"session_idle_minutes": (1, 60),
"session_lock_minutes": (1, 30),
"session_desk_minutes": (5, 240),
"session_password_hours": (1, 24),
"session_pin_hours": (1, 24),
"roles_refresh_minutes": (5, 60),
"signin_account_limit": (1, 15), # always under AD's lockout at 20
"signin_account_window_minutes": (1, 60),
"signin_account_cooldown_minutes": (0, 120),
"signin_ip_limit": (5, 200),
"signin_ip_window_minutes": (1, 60),
"signin_burst_threshold": (5, 200),
}
KEYS = tuple(BOUNDS)
# Keys that are not a Settings field: their default lives here.
EXTRA_DEFAULTS: dict[str, int] = {"signin_burst_threshold": 20}
BURST_WINDOW_MINUTES = 5
class SecSettingsError(ValueError):
"""Values the bounds refuse. `errors` holds one short line per problem."""
def __init__(self, errors: list[str]):
super().__init__("; ".join(errors))
self.errors = errors
class VersionConflict(Exception):
def __init__(self, version: int):
super().__init__(f"version is now {version}")
self.version = version
@dataclass(frozen=True)
class SecSettings:
"""The effective values. Every key is an attribute; `settings` is the Settings copy with them applied."""
session_idle_minutes: int
session_lock_minutes: int
session_desk_minutes: int
session_password_hours: int
session_pin_hours: int
roles_refresh_minutes: int
signin_account_limit: int
signin_account_window_minutes: int
signin_account_cooldown_minutes: int
signin_ip_limit: int
signin_ip_window_minutes: int
signin_burst_threshold: int
version: int = 0
settings: Settings | None = field(default=None, compare=False, repr=False)
def values(self) -> dict[str, int]:
return {k: int(getattr(self, k)) for k in KEYS}
def defaults(settings: Settings) -> dict[str, int]:
"""The environment defaults (Settings), plus the defaults of the keys Settings does not have."""
out: dict[str, int] = {}
for k in KEYS:
out[k] = int(getattr(settings, k)) if k in Settings.model_fields else EXTRA_DEFAULTS[k]
return out
def _in_bounds(key: str, value: Any) -> bool:
if isinstance(value, bool) or not isinstance(value, int):
return False
lo, hi = BOUNDS[key]
return lo <= value <= hi
def stored_values(value: Any) -> dict[str, int]:
"""The good keys of a stored row value; anything unknown, not an integer, or out of bounds is dropped."""
if not isinstance(value, dict):
return {}
return {k: int(v) for k, v in value.items() if k in BOUNDS and _in_bounds(k, v)}
# ---------------------------------------------------------------- the row and its cache
_lock = threading.Lock()
# (host, port, database) -> (loaded monotonic time, stored values, version)
_rows: dict[tuple[str, int, str], tuple[float, dict[str, int], int]] = {}
# (id(base settings), version, values) -> (base, overlaid Settings); keeps base alive so its id is not reused
_overlays: OrderedDict[tuple[int, int, tuple[tuple[str, int], ...]], tuple[Settings, Settings]] = OrderedDict()
OVERLAYS_MAX = 32
def _db_key(settings: Settings) -> tuple[str, int, str]:
return (settings.pg_host, int(settings.pg_port), settings.pg_database)
def clear_cache() -> None:
with _lock:
_rows.clear()
_overlays.clear()
def load_versioned(conn: Any) -> tuple[dict[str, int], int, Any, Any]:
"""(stored values, version, updated_at, updated_by) read now; version 0 when the row is missing."""
row = conn.execute("SELECT value, version, updated_at, updated_by FROM settings WHERE key = %s", (KEY,)).fetchone()
if row is None:
return {}, 0, None, None
return stored_values(row[0]), int(row[1]), row[2], row[3]
def _stored(conn: Any, settings: Settings) -> tuple[dict[str, int], int]:
key = _db_key(settings)
now = time.monotonic()
with _lock:
hit = _rows.get(key)
if hit is not None and now - hit[0] < CACHE_SECONDS:
return hit[1], hit[2]
values, version, _, _ = load_versioned(conn)
with _lock:
_rows[key] = (now, values, version)
return values, version
def _is_overlay(settings: Settings) -> bool:
with _lock:
return any(v[1] is settings for v in _overlays.values())
def _overlay_settings(base: Settings, values: dict[str, int], version: int) -> Settings:
upd = {k: v for k, v in values.items() if k in Settings.model_fields}
if not upd:
return base
key = (id(base), version, tuple(sorted(upd.items())))
with _lock:
hit = _overlays.get(key)
if hit is not None and hit[0] is base:
_overlays.move_to_end(key)
return hit[1]
out = base.model_copy(update=upd)
with _lock:
_overlays[key] = (base, out)
while len(_overlays) > OVERLAYS_MAX:
_overlays.popitem(last=False)
return out
def effective(conn: Any, settings: Settings) -> SecSettings:
"""The effective security settings: the stored row over the environment defaults."""
values, version = _stored(conn, settings)
merged = {**defaults(settings), **values}
over = settings if _is_overlay(settings) else _overlay_settings(settings, values, version)
return SecSettings(**merged, version=version, settings=over)
def overlay(conn: Any, settings: Settings) -> Settings:
"""`settings` with the stored security values applied. Passing an overlaid copy returns it as it is."""
if _is_overlay(settings):
return settings
values, version = _stored(conn, settings)
return _overlay_settings(settings, values, version)
def burst_threshold(conn: Any, settings: Settings) -> int:
return effective(conn, settings).signin_burst_threshold
# ---------------------------------------------------------------- saving
def validate(values: Any) -> dict[str, int]:
"""A partial set of values, checked against the bounds. Raises SecSettingsError."""
if not isinstance(values, dict):
raise SecSettingsError(["values must be an object"])
errors: list[str] = []
out: dict[str, int] = {}
for k, v in values.items():
if k not in BOUNDS:
errors.append(f"{k}: not a security setting")
continue
lo, hi = BOUNDS[k]
if not _in_bounds(k, v):
errors.append(f"{k}: a whole number from {lo} to {hi}")
continue
out[k] = int(v)
if errors:
raise SecSettingsError(errors)
return out
def save(conn: Any, settings: Settings, *, who: Any, values: Any, version: int) -> tuple[dict[str, int], int]:
"""Compare-and-set a partial change. Returns (stored values, new version). Raises SecSettingsError or
VersionConflict. Audited `admin.security_settings_updated` with before and after (effective values)."""
patch = validate(values)
with conn.transaction():
row = conn.execute("SELECT value, version FROM settings WHERE key = %s FOR UPDATE", (KEY,)).fetchone()
current = stored_values(row[0]) if row else {}
cur_version = int(row[1]) if row else 0
if int(version) != cur_version:
raise VersionConflict(cur_version)
base = defaults(settings)
before = {**base, **current}
new = {**current, **patch}
after = {**base, **new}
changed = {k: {"from": before[k], "to": after[k]} for k in KEYS if before[k] != after[k]}
new_version = conn.execute(
"INSERT INTO settings (key, value, version, updated_at, updated_by) VALUES (%s, %s, 1, now(), %s)"
" ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, version = settings.version + 1,"
" updated_at = now(), updated_by = EXCLUDED.updated_by RETURNING version",
(KEY, Jsonb(new), who.user_id),
).fetchone()[0]
audit.append(
conn,
action="admin.security_settings_updated",
user_id=who.user_id,
session_id=getattr(who, "session_id", None),
role_snapshot=getattr(who, "role_snapshot", None),
subject_type="settings",
subject_id=KEY,
detail={"before": before, "after": after, "changed": changed},
ip=getattr(who, "ip", None),
)
payload = json.dumps({"key": KEY, "version": int(new_version)}, separators=(",", ":"))
conn.execute("SELECT ps_notify(%s, %s)", (SETTINGS_CHANNEL, payload))
clear_cache()
return new, int(new_version)
+2
View File
@@ -14,6 +14,7 @@ from psycopg.types.json import Jsonb
from .. import events
from ..settings import Settings
from . import secsettings
SESSION_COOKIE = "ps_session"
DEVICE_COOKIE = "ps_device"
@@ -58,6 +59,7 @@ def create(
) -> tuple[str, dict[str, Any]]:
"""Insert a session row; returns (cookie token, row). The token itself is never stored."""
token = new_token()
settings = secsettings.overlay(conn, settings)
hours = settings.session_pin_hours if kind == "pin" else settings.session_password_hours
with conn.cursor(row_factory=dict_row) as cur:
row = cur.execute(
+12
View File
@@ -14,6 +14,7 @@ from psycopg.rows import dict_row
from .. import audit
from ..api.errors import ApiError
from ..domain import security_alerts
from ..settings import Settings
from . import devices, ratelimit, sessions
from . import directory as userdir
@@ -91,6 +92,7 @@ def rate_gate(
def _fail(
conn: Any,
*,
settings: Settings,
account: str,
ip: str | None,
kind: str,
@@ -111,6 +113,7 @@ def _fail(
detail={"reason": reason, "account": account, "kind": kind},
ip=ip,
)
security_alerts.after_failure(conn, settings, account=account, ip=ip, kind=kind, user_id=user_id)
raise ApiError(status, code, detail)
@@ -187,6 +190,7 @@ def password_signin(
# Refused before LDAP: an empty password would be an anonymous bind
_fail(
conn,
settings=settings,
account=account or "(empty)",
ip=ip,
kind="password",
@@ -203,6 +207,7 @@ def password_signin(
if u is None:
_fail(
conn,
settings=settings,
account=account,
ip=ip,
kind="password",
@@ -230,6 +235,7 @@ def password_signin(
if not ok:
_fail(
conn,
settings=settings,
account=account,
ip=ip,
kind="password",
@@ -242,6 +248,7 @@ def password_signin(
if u.disabled:
_fail(
conn,
settings=settings,
account=account,
ip=ip,
kind="password",
@@ -259,6 +266,7 @@ def password_signin(
if not roles:
_fail(
conn,
settings=settings,
account=account,
ip=ip,
kind="password",
@@ -302,6 +310,7 @@ def pin_signin(
def fail(reason: str, user_id: Any = None, *, audited: bool = False) -> NoReturn:
_fail(
conn,
settings=settings,
account=account,
ip=ip,
kind="pin",
@@ -338,6 +347,7 @@ def pin_signin(
with conn.transaction():
ratelimit.record(conn, account=account, ip=ip, kind="pin", ok=False, reason="bad_pin", user_id=uid)
pins.register_failure(conn, settings, uid, ip=ip, where="signin")
security_alerts.after_failure(conn, settings, account=account, ip=ip, kind="pin", user_id=uid)
raise ApiError(401, "signin_failed", PIN_FAILED)
try:
u = directory.get_user_by_guid(uid)
@@ -354,6 +364,7 @@ def pin_signin(
if not roles:
_fail(
conn,
settings=settings,
account=account,
ip=ip,
kind="pin",
@@ -423,6 +434,7 @@ def set_pin(
user_id=actor.user_id,
session_id=actor.session_id,
)
security_alerts.after_failure(conn, settings, account=account, ip=ip, kind="pin_set", user_id=actor.user_id)
raise ApiError(403, "bad_password", "The password is wrong.")
with conn.transaction():
pins.set_pin(conn, actor.user_id, new_pin)
+3
View File
@@ -11,6 +11,7 @@ from typing import Any
from .. import audit
from ..api.errors import ApiError
from ..domain import security_alerts
from ..settings import Settings
from . import pin as pins
from . import ratelimit, sessions
@@ -121,8 +122,10 @@ def verify(
(actor.session_id,),
).fetchone()
failures = int(row[0]) if row else settings.stepup_max_failures
security_alerts.after_failure(conn, settings, account=account, ip=ip, kind=kind, user_id=actor.user_id)
if failures >= settings.stepup_max_failures:
end_session(conn, actor, "stepup_failures", ip)
security_alerts.stepup_session_ended(conn, actor.user_id, failures, kind, ip)
raise ApiError(
401,
"session_ended",
+26 -14
View File
@@ -593,33 +593,45 @@ def open_quota_alert(conn: Any) -> dict[str, Any] | None:
def check_quota(conn: Any, settings: Settings) -> dict[str, Any]:
"""Open or resolve the storage alert. Returns the usage with `alert`: "opened", "resolved" or None.
Over the threshold with no open `assets_quota` row: insert one (severity warning, subject assets, detail the
usage) and audit `storage.quota_warning`. Under it with one open: set its resolved_at.
Over the threshold with no open `assets_quota` event: raise one through notify.raise_event (a standing
condition, subject assets, detail the usage) and audit `storage.quota_warning`. Under it with one open:
notify.resolve_event closes it and queues the recovery notice.
"""
from ..notify import raise_event, resolve_event
usage = usage_for(conn, settings)
change: str | None = None
warn_pct = int(settings.assets_quota_warn_pct)
with conn.transaction():
open_row = open_quota_alert(conn)
if usage["warn"] and open_row is None:
r = conn.execute(
"INSERT INTO notification_events (kind, severity, subject, detail) VALUES (%s, 'warning', %s, %s)"
" ON CONFLICT (kind, coalesce(subject, '')) WHERE resolved_at IS NULL DO NOTHING RETURNING id",
(QUOTA_KIND, QUOTA_SUBJECT, Jsonb(usage)),
).fetchone()
if r is not None:
new_id = raise_event(
conn,
QUOTA_KIND,
subject=QUOTA_SUBJECT,
title=f"Assets storage is {usage['pct']}% full",
body=f"The assets storage is {usage['pct']}% full. The warning is at {warn_pct}%."
f" Used {usage['used']} of {usage['total']} bytes.",
detail=usage,
condition=True,
)
if new_id is not None:
change = "opened"
audit.append(
conn,
action="storage.quota_warning",
subject_type="notification",
subject_id=str(r[0]),
detail={**usage, "warn_pct": int(settings.assets_quota_warn_pct)},
)
log.warning(
"assets storage is %.1f%% full (warning at %s%%)", usage["pct"], settings.assets_quota_warn_pct
subject_id=str(new_id),
detail={**usage, "warn_pct": warn_pct},
)
log.warning("assets storage is %.1f%% full (warning at %s%%)", usage["pct"], warn_pct)
elif not usage["warn"] and open_row is not None:
conn.execute("UPDATE notification_events SET resolved_at = now() WHERE id = %s", (open_row["id"],))
resolve_event(
conn,
QUOTA_KIND,
subject=QUOTA_SUBJECT,
body=f"The assets storage is back under the warning: {usage['pct']}% full.",
)
change = "resolved"
log.info("assets storage is back under the warning: %.1f%%", usage["pct"])
return {**usage, "alert": change}
+266
View File
@@ -0,0 +1,266 @@
"""Departments and their stations, as the Admin edits them.
Code and slug never change (the group names derive from them; a trigger refuses it too). Stations are never deleted,
because station limits and packets point at them: `active: false` hides one. A new department is a shop department
with the three shop groups IT must create in the PacketStation OU. The support departments map to fixed roles, so a
new one would have no group and no role; it is refused.
"""
from __future__ import annotations
import re
from typing import Any
from psycopg.rows import dict_row
from .. import audit
from ..api.errors import ApiError
from ..auth import groups as groupmap
RX_CODE = re.compile(r"^[0-9A-Z]{2,4}$")
RX_SLUG = re.compile(r"^[A-Za-z][A-Za-z0-9]{1,30}$")
NAME_MAX = 80
STATION_NAME_MAX = 60
STATIONS_MAX = 60
def clean_name(value: Any, what: str, limit: int) -> str:
text = " ".join(str(value or "").split())
if not text:
raise ApiError(422, "invalid_request", f"Give the {what} a name.")
if len(text) > limit:
raise ApiError(422, "invalid_request", f"The {what} name is longer than {limit} characters.")
return text
def group_names_for(code: str, slug: str, kind: str, supervisor_group: bool) -> list[tuple[str, str]]:
"""(group name, role) for one department, as the group map builds them."""
if kind == "shop":
return [(f"PS-{code}-{slug}-{lv.capitalize()}", f"shop:{code}:{lv}") for lv in groupmap.SHOP_LEVELS]
kinds = groupmap.OFFICE_KINDS.get(code)
if kinds is None:
return []
member, sup = kinds
out = [(f"PS-{slug}", member)]
if supervisor_group and sup:
out.append((f"PS-{slug}-Supervisor", sup))
return out
def _stations(conn: Any, code: str | None = None) -> dict[str, list[dict[str, Any]]]:
sql = "SELECT dept_code, id, name, sort, active FROM stations"
params: tuple[Any, ...] = ()
if code is not None:
sql += " WHERE dept_code = %s"
params = (code,)
out: dict[str, list[dict[str, Any]]] = {}
for dept, sid, name, sort, active in conn.execute(sql + " ORDER BY dept_code, sort, name, id", params):
out.setdefault(dept, []).append({"id": int(sid), "name": name, "sort": int(sort), "active": bool(active)})
return out
def _out(r: dict[str, Any], stations: list[dict[str, Any]], in_ou: set[str] | None) -> dict[str, Any]:
groups = [
{"name": name, "role": role, "in_ou": None if in_ou is None else name.lower() in in_ou}
for name, role in group_names_for(r["code"], r["slug"], r["kind"], bool(r["supervisor_group"]))
]
return {
"code": r["code"],
"slug": r["slug"],
"name": r["name"],
"kind": r["kind"],
"sort": int(r["sort"]),
"active": bool(r["active"]),
"weld": bool(r["weld"]),
"supervisor_group": bool(r["supervisor_group"]),
"created_at": r["created_at"],
"groups": groups,
"stations": stations,
}
SELECT = (
"SELECT code, slug, name, kind::text AS kind, sort, active, weld, supervisor_group, created_at FROM departments"
)
def list_all(conn: Any, in_ou: list[str] | None) -> list[dict[str, Any]]:
"""Every department, active or not, in sort order. `in_ou` is the group names in the OU (None: unknown)."""
lower = {n.lower() for n in in_ou} if in_ou is not None else None
with conn.cursor(row_factory=dict_row) as cur:
rows = cur.execute(SELECT + " ORDER BY sort, code").fetchall()
st = _stations(conn)
return [_out(r, st.get(r["code"], []), lower) for r in rows]
def get(conn: Any, code: str, in_ou: list[str] | None = None) -> dict[str, Any] | None:
lower = {n.lower() for n in in_ou} if in_ou is not None else None
with conn.cursor(row_factory=dict_row) as cur:
r = cur.execute(SELECT + " WHERE code = %s", (code,)).fetchone()
if r is None:
return None
return _out(r, _stations(conn, code).get(code, []), lower)
def _snapshot(d: dict[str, Any]) -> dict[str, Any]:
return {k: d[k] for k in ("name", "active", "stations")}
def update(
conn: Any,
who: Any,
code: str,
*,
name: str | None,
active: bool | None,
stations: list[dict[str, Any]] | None,
) -> dict[str, Any]:
"""Change the name, active and stations of one department. Audited `admin.department_updated`."""
with conn.transaction():
locked = conn.execute("SELECT code FROM departments WHERE code = %s FOR UPDATE", (code,)).fetchone()
if locked is None:
raise ApiError(404, "not_found", "No such department.")
before = get(conn, code)
assert before is not None
if name is not None:
conn.execute(
"UPDATE departments SET name = %s WHERE code = %s", (clean_name(name, "department", NAME_MAX), code)
)
if active is not None:
conn.execute("UPDATE departments SET active = %s WHERE code = %s", (bool(active), code))
if stations is not None:
_apply_stations(conn, code, before["stations"], stations)
after = get(conn, code)
assert after is not None
if _snapshot(before) != _snapshot(after):
audit.append(
conn,
action="admin.department_updated",
user_id=who.user_id,
session_id=who.session_id,
role_snapshot=who.role_snapshot,
subject_type="department",
subject_id=code,
detail={"before": _snapshot(before), "after": _snapshot(after)},
ip=who.ip,
)
return after
def _apply_stations(conn: Any, code: str, current: list[dict[str, Any]], wanted: list[dict[str, Any]]) -> None:
by_id = {s["id"]: s for s in current}
final: dict[int, dict[str, Any]] = {s["id"]: dict(s) for s in current}
new: list[dict[str, Any]] = []
seen_ids: set[int] = set()
for w in wanted:
sid = w.get("id")
entry = {
"name": clean_name(w.get("name"), "station", STATION_NAME_MAX),
"sort": int(w.get("sort") or 0),
"active": bool(w.get("active", True)),
}
if sid is None:
new.append(entry)
continue
if sid not in by_id:
raise ApiError(422, "invalid_request", f"Station {sid} is not in department {code}.")
if sid in seen_ids:
raise ApiError(422, "invalid_request", f"Station {sid} is listed twice.")
seen_ids.add(sid)
final[sid].update(entry)
names = [s["name"].lower() for s in final.values()] + [s["name"].lower() for s in new]
dupes = sorted({n for n in names if names.count(n) > 1})
if dupes:
raise ApiError(422, "duplicate_station", f"Two stations in {code} have the same name: {', '.join(dupes)}.")
if len(final) + len(new) > STATIONS_MAX:
raise ApiError(422, "invalid_request", f"A department has at most {STATIONS_MAX} stations.")
renamed = [sid for sid, s in final.items() if s["name"] != by_id[sid]["name"]]
# Names are unique per department: park the renamed ones first so two stations can swap names.
for sid in renamed:
conn.execute("UPDATE stations SET name = %s WHERE id = %s", (f"~{sid}~renaming", sid))
for sid, s in final.items():
if s != by_id[sid]:
conn.execute(
"UPDATE stations SET name = %s, sort = %s, active = %s WHERE id = %s",
(s["name"], s["sort"], s["active"], sid),
)
for s in new:
conn.execute(
"INSERT INTO stations (dept_code, name, sort, active) VALUES (%s, %s, %s, %s)",
(code, s["name"], s["sort"], s["active"]),
)
def add(
conn: Any,
who: Any,
*,
code: str,
slug: str,
name: str,
kind: str,
weld: bool,
supervisor_group: bool,
stations: list[str],
) -> tuple[dict[str, Any], list[str]]:
"""Add a shop department. Returns (department, group names IT must create). Audited `admin.department_added`."""
code, slug = code.strip(), slug.strip()
if not RX_CODE.match(code):
raise ApiError(422, "invalid_request", "The code is 2 to 4 capital letters or digits, like 15.")
if not RX_SLUG.match(slug):
raise ApiError(422, "invalid_request", "The short name is a letter, then letters or digits, no spaces.")
if kind != "shop":
raise ApiError(
422,
"support_fixed",
"Only a shop department can be added. The support departments map to fixed roles.",
)
title = clean_name(name, "department", NAME_MAX)
names: list[str] = []
for s in stations:
n = clean_name(s, "station", STATION_NAME_MAX)
if n.lower() in (x.lower() for x in names):
raise ApiError(422, "duplicate_station", f"Two stations have the same name: {n}.")
names.append(n)
if len(names) > STATIONS_MAX:
raise ApiError(422, "invalid_request", f"A department has at most {STATIONS_MAX} stations.")
with conn.transaction():
conn.execute("LOCK TABLE departments IN SHARE ROW EXCLUSIVE MODE")
if conn.execute("SELECT 1 FROM departments WHERE upper(code) = upper(%s)", (code,)).fetchone():
raise ApiError(409, "code_taken", f"Department code {code} is taken.")
if conn.execute("SELECT 1 FROM departments WHERE lower(slug) = lower(%s)", (slug,)).fetchone():
raise ApiError(409, "slug_taken", f"The short name {slug} is taken.")
sort = int(conn.execute("SELECT coalesce(max(sort), 0) + 10 FROM departments").fetchone()[0])
conn.execute(
"INSERT INTO departments (code, slug, name, kind, sort, active, supervisor_group, weld, created_at,"
" created_by) VALUES (%s, %s, %s, %s::dept_kind, %s, true, %s, %s, now(), %s)",
(code, slug, title, kind, sort, bool(supervisor_group), bool(weld), who.user_id),
)
for i, n in enumerate(names, start=1):
conn.execute("INSERT INTO stations (dept_code, name, sort) VALUES (%s, %s, %s)", (code, n, i))
dept = get(conn, code)
assert dept is not None
groups = [g["name"] for g in dept["groups"]]
audit.append(
conn,
action="admin.department_added",
user_id=who.user_id,
session_id=who.session_id,
role_snapshot=who.role_snapshot,
subject_type="department",
subject_id=code,
detail={
"after": {
"code": code,
"slug": slug,
"name": title,
"kind": kind,
"weld": bool(weld),
"supervisor_group": bool(supervisor_group),
"stations": names,
},
"groups_to_create": groups,
},
ip=who.ip,
)
return dept, groups
+34
View File
@@ -30,6 +30,7 @@ from ..api import deps
from ..api.errors import ApiError
from ..auth import signatures
from ..auth.signatures import Signature
from ..notify import raise_event
from ..policy.actions import SCOPED_LABELS
from ..policy.engine import check_scoped
from ..text import LIMITS, clean_opt, reason_ok
@@ -593,5 +594,38 @@ def _finish(w: ItpWrite) -> None:
events.publish(conn, {"event": "changed", "kind": "itp", "unit_id": w.itp["unit_id"], "itp_id": w.id})
def reject_dept(conn: Any, unit_id: Any, page: Any) -> str | None:
"""The department of a rejected line: the one department among the unit's live packets that works to the
line's page, or None when there is none or more than one."""
codes = {
str(r[0])
for r in conn.execute(
"SELECT DISTINCT dept_code FROM packets WHERE unit_id = %s AND removed_at IS NULL", (unit_id,)
).fetchall()
}
hits = sorted(c for c in codes if page and page in DEPT_PAGES.get(c, ()))
return hits[0] if len(hits) == 1 else None
def notify_reject(w: ItpWrite, key: str, ncr: str | None, note: str | None) -> int | None:
"""qc_reject for an ITP line reject, in the write's transaction (notify-send mails it)."""
item = next((i for i in items(w.itp.get("template") or {}) if str(i.get("n")) == key), {})
page = item.get("page")
dept = reject_dept(w.conn, w.itp["unit_id"], page)
who = (w.principal.user or {}).get("display_name") or ""
head = f"NCR {ncr}" if ncr else "NCR # pending"
lines = [str(item.get("t") or ""), head, note or "", f"Rejected by {who}." if who else ""]
where = f" ({PAGE_NAMES.get(str(page), page)})" if page else ""
return raise_event(
w.conn,
"qc_reject",
subject=f"itp:{w.id}:{key}",
title=f"ITP {w.itp.get('no') or w.id} line {key} rejected{where}",
body="\n".join(x for x in lines if x),
detail={"itp_id": w.id, "unit_id": w.itp["unit_id"], "line": key, "page": page, "ncr": ncr},
dept_code=dept,
)
def label(action: policy.Action) -> str:
return SCOPED_LABELS.get(action, str(action))
+370
View File
@@ -0,0 +1,370 @@
"""People's Packet Station profiles: welder stamp, profile active, station limits. Edited one at a time or by a CSV
import keyed on the employee ID.
Roles never come from here; AD groups decide them. An inactive profile drops out of the welder picker and the
worker filter. Employee IDs stay text (leading zeros kept).
"""
from __future__ import annotations
import csv
import io
import re
from collections import Counter
from collections.abc import Mapping
from typing import Any
from .. import audit
from ..api.errors import ApiError
RX_STAMP = re.compile(r"^[A-Za-z0-9-]{1,12}$")
CSV_MAX_BYTES = 1_000_000
CSV_MAX_ROWS = 2000
COLUMNS = ("employee_id", "welder_stamp", "active", "stations")
CLEAR = "-"
TRUE_WORDS = ("yes", "true", "1")
FALSE_WORDS = ("no", "false", "0")
# ---------------------------------------------------------------- reading
def station_limits(conn: Any, user_ids: list[Any]) -> dict[Any, dict[str, list[int]]]:
"""user id -> {dept_code: [station ids]}."""
out: dict[Any, dict[str, list[int]]] = {}
if not user_ids:
return out
rows = conn.execute(
"SELECT l.user_id, s.dept_code, s.id FROM user_station_limits l JOIN stations s ON s.id = l.station_id"
" WHERE l.user_id = ANY(%s) ORDER BY s.dept_code, s.sort, s.id",
(user_ids,),
).fetchall()
for uid, dept, sid in rows:
out.setdefault(uid, {}).setdefault(dept, []).append(int(sid))
return out
def profile_of(conn: Any, user_id: Any) -> dict[str, Any] | None:
row = conn.execute("SELECT welder_stamp, profile_active FROM users WHERE id = %s", (user_id,)).fetchone()
if row is None:
return None
return {
"welder_stamp": row[0],
"profile_active": bool(row[1]),
"station_limits": station_limits(conn, [user_id]).get(user_id, {}),
}
def _stamp_owner(conn: Any, stamp: str, user_id: Any) -> str | None:
row = conn.execute(
"SELECT display_name FROM users WHERE lower(welder_stamp) = lower(%s) AND id <> %s LIMIT 1", (stamp, user_id)
).fetchone()
return str(row[0]) if row else None
# ---------------------------------------------------------------- one person
def _audit(conn: Any, who: Any, user_id: Any, before: dict[str, Any], after: dict[str, Any], source: str) -> None:
audit.append(
conn,
action="admin.profile_updated",
user_id=who.user_id,
session_id=who.session_id,
role_snapshot=who.role_snapshot,
subject_type="user",
subject_id=user_id,
detail={"before": before, "after": after, "source": source},
ip=who.ip,
)
def _write(conn: Any, user_id: Any, stamp: Any, active: Any, limits: Mapping[str, list[int]] | None) -> None:
"""Write the fields given; `stamp` and `active` use ... (Ellipsis) for unchanged. `limits` replaces the named
departments' limits; None leaves them."""
if stamp is not ...:
conn.execute("UPDATE users SET welder_stamp = %s WHERE id = %s", (stamp, user_id))
if active is not ...:
conn.execute("UPDATE users SET profile_active = %s WHERE id = %s", (bool(active), user_id))
for dept, ids in (limits or {}).items():
conn.execute(
"DELETE FROM user_station_limits l USING stations s WHERE s.id = l.station_id AND l.user_id = %s"
" AND s.dept_code = %s",
(user_id, dept),
)
for sid in ids:
conn.execute("INSERT INTO user_station_limits (user_id, station_id) VALUES (%s, %s)", (user_id, sid))
def update(conn: Any, who: Any, user_id: Any, fields: Mapping[str, Any]) -> dict[str, Any]:
"""PATCH a profile: welder_stamp (None clears), profile_active, station_limits {dept: [station id]} (a dept key
replaces that department's limits; an empty list clears them)."""
stamp: Any = ...
if "welder_stamp" in fields:
raw = fields["welder_stamp"]
stamp = None if raw is None or not str(raw).strip() else str(raw).strip()
if stamp is not None and not RX_STAMP.match(stamp):
raise ApiError(422, "bad_stamp", "A stamp is 1 to 12 letters, digits or dashes.")
active: Any = fields["profile_active"] if fields.get("profile_active") is not None else ...
limits: dict[str, list[int]] | None = None
if fields.get("station_limits") is not None:
limits = {}
for dept, ids in dict(fields["station_limits"]).items():
ok = {int(r[0]) for r in conn.execute("SELECT id FROM stations WHERE dept_code = %s", (dept,)).fetchall()}
if not conn.execute("SELECT 1 FROM departments WHERE code = %s", (dept,)).fetchone():
raise ApiError(422, "unknown_department", f"No department {dept}.")
want = sorted({int(x) for x in ids})
bad = [x for x in want if x not in ok]
if bad:
raise ApiError(422, "unknown_station", f"Station {bad[0]} is not in department {dept}.")
limits[str(dept)] = want
with conn.transaction():
if conn.execute("SELECT 1 FROM users WHERE id = %s FOR UPDATE", (user_id,)).fetchone() is None:
raise ApiError(404, "not_found", "No such user.")
if stamp is not ... and stamp is not None:
owner = _stamp_owner(conn, stamp, user_id)
if owner is not None:
raise ApiError(409, "stamp_taken", f"Stamp {stamp} belongs to {owner}.")
before = profile_of(conn, user_id)
assert before is not None
_write(conn, user_id, stamp, active, limits)
after = profile_of(conn, user_id)
assert after is not None
if before != after:
_audit(conn, who, user_id, before, after, "edit")
return after
# ---------------------------------------------------------------- CSV import
def _station_index(conn: Any) -> tuple[set[str], dict[tuple[str, str], int], dict[int, str]]:
depts = {r[0] for r in conn.execute("SELECT code FROM departments").fetchall()}
by_name: dict[tuple[str, str], int] = {}
label: dict[int, str] = {}
for sid, dept, name in conn.execute("SELECT id, dept_code, name FROM stations").fetchall():
by_name[(dept.lower(), " ".join(name.split()).lower())] = int(sid)
label[int(sid)] = f"{dept}:{name}"
return depts, by_name, label
def _bool(text: str) -> bool | None:
t = text.strip().lower()
if t in TRUE_WORDS:
return True
if t in FALSE_WORDS:
return False
return None
def _labels(ids: list[int], label: dict[int, str]) -> list[str]:
return sorted(label.get(i, str(i)) for i in ids)
def parse_csv(text: str) -> tuple[list[str], list[tuple[int, dict[str, str]]]]:
"""(header columns, [(line, {column: cell})]). Raises ApiError for a file that cannot be read at all."""
if len(text.encode("utf-8")) > CSV_MAX_BYTES:
raise ApiError(413, "too_large", "The file is larger than 1 MB.")
reader = csv.reader(io.StringIO(text.lstrip("")))
try:
header = next(reader, None)
if header is None:
raise ApiError(422, "bad_csv", "The file is empty.")
cols = [h.strip().lower() for h in header]
unknown = [c for c in cols if c and c not in COLUMNS]
if "employee_id" not in cols:
raise ApiError(422, "bad_csv", "The header row has no employee_id column.")
if unknown:
raise ApiError(422, "bad_csv", f"Unknown column: {', '.join(unknown)}.")
if len(set(c for c in cols if c)) != len([c for c in cols if c]):
raise ApiError(422, "bad_csv", "A column is named twice.")
rows: list[tuple[int, dict[str, str]]] = []
for cells in reader:
if not any(c.strip() for c in cells):
continue
if len(rows) >= CSV_MAX_ROWS:
raise ApiError(422, "bad_csv", f"The file has more than {CSV_MAX_ROWS} rows.")
row = {c: (cells[i].strip() if i < len(cells) else "") for i, c in enumerate(cols) if c}
rows.append((reader.line_num, row))
except csv.Error as e:
raise ApiError(422, "bad_csv", f"The file is not valid CSV: {e}.") from None
return cols, rows
def import_profiles(conn: Any, who: Any, text: str, *, apply: bool) -> dict[str, Any]:
"""Preview (apply false) or apply a profile CSV. With any error, apply is refused: 422 with the same body."""
_, parsed = parse_csv(text)
depts, by_name, label = _station_index(conn)
emps = [r.get("employee_id", "") for _, r in parsed]
users: dict[str, list[dict[str, Any]]] = {}
want = sorted({e for e in emps if e})
if want:
for uid, emp, name, stamp, active in conn.execute(
"SELECT id, emp_no, display_name, welder_stamp, profile_active FROM users"
" WHERE active AND emp_no = ANY(%s)",
(want,),
).fetchall():
users.setdefault(emp, []).append(
{"id": uid, "display_name": name, "welder_stamp": stamp, "profile_active": bool(active)}
)
limits = station_limits(conn, [u["id"] for us in users.values() for u in us])
seen: Counter[str] = Counter()
first_line: dict[str, int] = {}
out: list[dict[str, Any]] = []
plans: list[tuple[dict[str, Any], dict[str, Any]]] = [] # (row out, plan)
for line, r in parsed:
emp = r.get("employee_id", "")
errors: list[str] = []
row_out: dict[str, Any] = {
"line": line,
"employee_id": emp,
"user_id": None,
"display_name": None,
"changes": {},
"errors": errors,
}
out.append(row_out)
if not emp:
errors.append("Employee ID is missing.")
continue
seen[emp] += 1
if seen[emp] > 1:
errors.append(f"Employee ID {emp} is repeated in the file (first on line {first_line[emp]}).")
continue
first_line[emp] = line
found = users.get(emp, [])
if not found:
errors.append(f"Unknown employee ID {emp}.")
continue
if len(found) > 1:
errors.append(f"Employee ID {emp} is on more than one user.")
continue
u = found[0]
row_out["user_id"] = str(u["id"])
row_out["display_name"] = u["display_name"]
plan: dict[str, Any] = {"user": u}
stamp_cell = r.get("welder_stamp", "")
if stamp_cell:
new_stamp = None if stamp_cell == CLEAR else stamp_cell
if new_stamp is not None and not RX_STAMP.match(new_stamp):
errors.append(f"Bad stamp {stamp_cell}: 1 to 12 letters, digits or dashes.")
elif new_stamp != u["welder_stamp"]:
plan["welder_stamp"] = new_stamp
active_cell = r.get("active", "")
if active_cell:
val = _bool(active_cell)
if val is None:
errors.append(f"Bad active value {active_cell}: use yes or no.")
elif val != u["profile_active"]:
plan["profile_active"] = val
st_cell = r.get("stations", "")
if st_cell:
current = sorted(i for ids in limits.get(u["id"], {}).values() for i in ids)
ids: list[int] = []
if st_cell != CLEAR:
for part in (p.strip() for p in st_cell.split(";")):
if not part:
continue
dept, sep, name = part.partition(":")
dept, name = dept.strip(), " ".join(name.split())
if not sep or not dept or not name:
errors.append(f"Bad station {part}: write <department code>:<station name>.")
elif dept not in depts:
errors.append(f"Unknown department {dept}.")
elif (dept.lower(), name.lower()) not in by_name:
errors.append(f"Unknown station {name} in department {dept}.")
else:
sid = by_name[(dept.lower(), name.lower())]
if sid not in ids:
ids.append(sid)
ids.sort()
if ids != current:
plan["stations"] = (current, ids)
plans.append((row_out, plan))
# Stamps must stay unique after the whole file applies.
final: dict[Any, str | None] = {}
for _, plan in plans:
if "welder_stamp" in plan:
final[plan["user"]["id"]] = plan["welder_stamp"]
claimed = [s for s in final.values() if s]
if claimed:
others = {
str(r[1]).lower(): (r[0], r[2])
for r in conn.execute(
"SELECT id, welder_stamp, display_name FROM users WHERE welder_stamp IS NOT NULL"
" AND lower(welder_stamp) = ANY(%s)",
([s.lower() for s in claimed],),
).fetchall()
}
names_by_id = {plan["user"]["id"]: plan["user"]["display_name"] for _, plan in plans}
taken_in_file: dict[str, Any] = {}
for row_out, plan in plans:
s = plan.get("welder_stamp")
if not s:
continue
uid = plan["user"]["id"]
holder = others.get(s.lower())
# The holder keeps the stamp unless the same file gives them another (or clears it)
freed = holder is not None and holder[0] in final and str(final[holder[0]] or "").lower() != s.lower()
if holder is not None and holder[0] != uid and not freed:
row_out["errors"].append(f"Stamp {s} is taken by {holder[1]}.")
elif s.lower() in taken_in_file:
row_out["errors"].append(f"Stamp {s} is taken by {names_by_id[taken_in_file[s.lower()]]}.")
else:
taken_in_file[s.lower()] = uid
for row_out, plan in plans:
u = plan["user"]
if "welder_stamp" in plan:
row_out["changes"]["welder_stamp"] = {"from": u["welder_stamp"], "to": plan["welder_stamp"]}
if "profile_active" in plan:
row_out["changes"]["profile_active"] = {"from": u["profile_active"], "to": plan["profile_active"]}
if "stations" in plan:
cur, ids = plan["stations"]
row_out["changes"]["station_limits"] = {"from": _labels(cur, label), "to": _labels(ids, label)}
n_err = sum(1 for r in out if r["errors"])
body: dict[str, Any] = {"rows": out, "ok": len(out) - n_err, "errors": n_err, "applied": False}
if not apply:
return body
if n_err:
raise ApiError(
422,
"import_errors",
f"{n_err} row{'s' if n_err != 1 else ''} with errors. Nothing was changed.",
extra=body,
)
changed = 0
with conn.transaction():
for row_out, plan in plans:
if not row_out["changes"]:
continue
uid = plan["user"]["id"]
conn.execute("SELECT 1 FROM users WHERE id = %s FOR UPDATE", (uid,))
before = profile_of(conn, uid)
stamp = plan.get("welder_stamp", ...)
active = plan.get("profile_active", ...)
lim: dict[str, list[int]] | None = None
if "stations" in plan:
conn.execute("DELETE FROM user_station_limits WHERE user_id = %s", (uid,))
for sid in plan["stations"][1]:
conn.execute("INSERT INTO user_station_limits (user_id, station_id) VALUES (%s, %s)", (uid, sid))
_write(conn, uid, stamp, active, lim)
after = profile_of(conn, uid)
assert before is not None and after is not None
_audit(conn, who, uid, before, after, "import")
changed += 1
audit.append(
conn,
action="admin.profile_import",
user_id=who.user_id,
session_id=who.session_id,
role_snapshot=who.role_snapshot,
subject_type="users",
subject_id="import",
detail={"rows": len(out), "changed": changed, "unchanged": len(out) - changed},
ip=who.ip,
)
body["applied"] = True
return body
+19
View File
@@ -10,6 +10,7 @@ from collections.abc import Mapping
from typing import Any
from .. import policy
from ..notify import raise_event
from ..text import LIMITS
from . import itp, packets, qcdocs, search
@@ -65,6 +66,23 @@ def inspector_text(insp: Mapping[str, Any] | None) -> str:
return f"{who} [{stamp}]" if stamp else who
def _notify_reject(w: Any, why: str) -> None:
"""qc_reject for the packet's department, in the write's transaction (notify-send mails it)."""
pkt = w.packet
dept = str(w.from_dept or pkt.get("dept_code") or "") or None
what = str(pkt.get("title") or pkt.get("drawing") or "")
who = (w.principal.user or {}).get("display_name") or ""
raise_event(
w.conn,
"qc_reject",
subject=f"packet:{w.id}",
title=f"Q.C. rejected packet {w.id}" + (f" ({what})" if what else "") + (f", dept {dept}" if dept else ""),
body="\n".join(x for x in (f"Rejected by {who}." if who else "", why) if x),
detail={"packet_id": w.id, "dept_code": dept, "by": str(w.user_id)},
dept_code=dept,
)
def qc_texts(w: Any, a: policy.Action, to_dept: str) -> None:
"""The packet_log label and note of a Q.C. accept or reject. Called before the side effects close the inspection.
@@ -79,6 +97,7 @@ def qc_texts(w: Any, a: policy.Action, to_dept: str) -> None:
if welds:
parts.append("welds " + ", ".join(welds))
w.log("Q.C. rejected → supervisor", " · ".join(x for x in parts if x) or None)
_notify_reject(w, " · ".join(x for x in parts if x))
return
if a is A.QC_ACCEPT_AND_ROUTE:
labels = getattr(w.children, "dept_labels", None) or packets.dept_labels(w.conn)
+2 -1
View File
@@ -369,7 +369,8 @@ JOIN stations st ON st.id = p.station_id WHERE p.removed_at IS NULL{scope} ORDER
"""
FACET_WORKERS_SQL = """
SELECT DISTINCT wu.id, wu.display_name FROM packets p JOIN units u ON u.id = p.unit_id
JOIN users wu ON wu.id = p.welder_id WHERE p.removed_at IS NULL{scope} ORDER BY wu.display_name, wu.id
JOIN users wu ON wu.id = p.welder_id AND wu.profile_active
WHERE p.removed_at IS NULL{scope} ORDER BY wu.display_name, wu.id
"""
@@ -0,0 +1,321 @@
"""Security alerts: raising them on a failed sign-in, a disabled PIN or an ended session, auditing policy denials,
and the Admin Security tab's list of what happened.
Raising goes through `notify.raise_event` (security_alert, signin_burst) inside the caller's database work. A
failure here is logged and never breaks the sign-in or the request it rides on.
"""
from __future__ import annotations
import logging
import threading
import time
from datetime import timedelta
from typing import Any
from psycopg.rows import dict_row
from .. import audit
from ..auth import ratelimit, secsettings
from ..settings import Settings
log = logging.getLogger(__name__)
SECURITY_ALERT = "security_alert"
SIGNIN_BURST = "signin_burst"
ALERT_DEDUPE_MINUTES = 15
BURST_DEDUPE_MINUTES = 30
BURST_KINDS = ("password", "pin")
PIN_FAILURES_MIN = 3
ALERTS_MAX = 1000
DENIED_DEDUPE_SECONDS = 60.0
DENIED_SKIP_CODES = frozenset({"wrong_secret"})
ALERT_KINDS = (
"lockout",
"pin_failures",
"denied",
"stepup_wrong_pin",
"admin_change",
"it_edit",
"integrity",
"signin_burst",
)
def notify(conn: Any, event_type: str, **kw: Any) -> int | None:
"""notify.raise_event in a savepoint. A failure is logged and returns None."""
try:
from ..notify import raise_event
with conn.transaction():
result: int | None = raise_event(conn, event_type, **kw)
return result
except Exception:
log.exception("could not raise %s", event_type)
return None
def _recent_audit(conn: Any, action: str, subject: str, minutes: int) -> bool:
row = conn.execute(
"SELECT 1 FROM audit_events WHERE action = %s AND subject_type = 'security' AND subject_id = %s"
" AND at > now() - make_interval(mins => %s::int) LIMIT 1",
(action, subject, max(1, minutes)),
).fetchone()
return row is not None
def _security_audit(
conn: Any, action: str, subject: str, detail: dict[str, Any], *, user_id: Any = None, ip: str | None = None
) -> None:
audit.append(
conn, action=action, user_id=user_id, subject_type="security", subject_id=subject, detail=detail, ip=ip
)
def after_failure(
conn: Any, settings: Settings, *, account: str, ip: str | None, kind: str, user_id: Any = None
) -> None:
"""Call after a failed attempt was recorded. Raises security_alert when the account's cooldown starts, and
signin_burst when the address limit trips or the failures across accounts reach the burst threshold."""
try:
with conn.transaction():
_after_failure(conn, settings, account=account, ip=ip, kind=kind, user_id=user_id)
except Exception:
log.exception("security alert check failed")
def _after_failure(conn: Any, settings: Settings, *, account: str, ip: str | None, kind: str, user_id: Any) -> None:
sec = secsettings.effective(conn, settings)
s = sec.settings or settings
now = ratelimit.db_now(conn)
if account:
wait = ratelimit.check(conn, s, accounts=[account], ip=None, now=now)
subject = f"account:{account}"
if wait is not None and not _recent_audit(conn, "auth.lockout", subject, sec.signin_account_cooldown_minutes):
minutes = max(1, (wait + 59) // 60)
_security_audit(
conn,
"auth.lockout",
subject,
{"account": account, "kind": kind, "minutes": minutes},
user_id=user_id,
ip=ip,
)
notify(
conn,
SECURITY_ALERT,
subject=subject,
title=f"Sign-in cooldown started for {account}",
body=(
f"{sec.signin_account_limit} failed attempts on {account} within "
f"{sec.signin_account_window_minutes} minutes. Further attempts are refused for {minutes} "
f"minutes. Last address: {ip or 'unknown'}."
),
detail={"account": account, "kind": kind, "ip": ip, "minutes": minutes},
dedupe_minutes=ALERT_DEDUPE_MINUTES,
)
if ip:
wait = ratelimit.check(conn, s, accounts=[], ip=ip, now=now)
subject = f"ip:{ip}"
if wait is not None and not _recent_audit(conn, "auth.signin_burst", subject, BURST_DEDUPE_MINUTES):
_security_audit(conn, "auth.signin_burst", subject, {"reason": "ip_limit", "ip": ip}, ip=ip)
notify(
conn,
SIGNIN_BURST,
subject=subject,
title=f"Sign-in limit reached for address {ip}",
body=(
f"{sec.signin_ip_limit} failed sign-ins from {ip} within {sec.signin_ip_window_minutes} minutes. "
"The address is refused until the count drops."
),
detail={"reason": "ip_limit", "ip": ip},
dedupe_minutes=BURST_DEDUPE_MINUTES,
)
if kind in BURST_KINDS:
n = conn.execute(
"SELECT count(*), count(DISTINCT account) FROM signin_attempts WHERE NOT ok"
" AND reason IS DISTINCT FROM %s AND kind = ANY(%s) AND at > %s",
(ratelimit.RATE_LIMITED, list(BURST_KINDS), now - timedelta(minutes=secsettings.BURST_WINDOW_MINUTES)),
).fetchone()
failures, accounts = int(n[0]), int(n[1])
if failures >= sec.signin_burst_threshold and not _recent_audit(
conn, "auth.signin_burst", "burst", BURST_DEDUPE_MINUTES
):
detail = {"reason": "burst", "failures": failures, "accounts": accounts}
_security_audit(conn, "auth.signin_burst", "burst", detail, ip=ip)
notify(
conn,
SIGNIN_BURST,
subject="burst",
title=f"{failures} failed sign-ins in {secsettings.BURST_WINDOW_MINUTES} minutes",
body=(
f"{failures} failed sign-ins across {accounts} accounts in the last "
f"{secsettings.BURST_WINDOW_MINUTES} minutes (the threshold is {sec.signin_burst_threshold})."
),
detail=detail,
dedupe_minutes=BURST_DEDUPE_MINUTES,
)
def _name(conn: Any, user_id: Any) -> str:
row = conn.execute("SELECT display_name, sam_account FROM users WHERE id = %s", (user_id,)).fetchone()
return f"{row[0]} ({row[1]})" if row else str(user_id)
def pin_disabled(conn: Any, user_id: Any, failures: int, where: str) -> None:
"""A PIN was disabled after too many wrong entries."""
name = _name(conn, user_id)
notify(
conn,
SECURITY_ALERT,
subject=f"user:{user_id}",
title=f"PIN disabled after {failures} wrong entries: {name}",
body=f"The PIN of {name} was disabled after {failures} wrong entries ({where}). An Admin can clear it.",
detail={"user_id": str(user_id), "failures": failures, "where": where},
dedupe_minutes=ALERT_DEDUPE_MINUTES,
)
def stepup_session_ended(conn: Any, user_id: Any, failures: int, kind: str, ip: str | None) -> None:
"""A session ended after too many wrong unlock or signature attempts."""
name = _name(conn, user_id)
what = "unlock" if kind == "unlock" else "signature"
notify(
conn,
SECURITY_ALERT,
subject=f"user:{user_id}",
title=f"Session ended after {failures} wrong {what} attempts: {name}",
body=f"{name} entered a wrong PIN or password {failures} times at {what}. The session was ended. "
f"Address: {ip or 'unknown'}.",
detail={"user_id": str(user_id), "failures": failures, "kind": kind, "ip": ip},
dedupe_minutes=ALERT_DEDUPE_MINUTES,
)
# ---------------------------------------------------------------- policy denials
_denied_lock = threading.Lock()
_denied_seen: dict[tuple[str, str, str, str], float] = {}
def _denied_due(key: tuple[str, str, str, str]) -> bool:
now = time.monotonic()
with _denied_lock:
last = _denied_seen.get(key)
if last is not None and now - last < DENIED_DEDUPE_SECONDS:
return False
if len(_denied_seen) > 5000:
_denied_seen.clear()
_denied_seen[key] = now
return True
def record_denied(settings: Settings, principal: Any, *, method: str, path: str, code: str) -> None:
"""Audit `policy.denied` for a 403 refused to a signed-in principal. The method, path and code only (never a
body or a query string). The same user, path and code is audited once a minute at most."""
if code in DENIED_SKIP_CODES:
return
key = (str(principal.user_id), method, path, code)
if not _denied_due(key):
return
from .. import db
try:
with db.get_pool(settings).connection(timeout=5.0) as conn:
conn.autocommit = True
audit.append(
conn,
action="policy.denied",
user_id=principal.user_id,
session_id=principal.session_id,
role_snapshot=principal.role_snapshot,
subject_type="route",
subject_id=f"{method} {path}"[:300],
detail={"method": method, "path": path[:300], "code": code},
ip=principal.ip,
)
except Exception as e:
log.warning("could not audit a denial: %s", type(e).__name__)
# ---------------------------------------------------------------- the Security tab's list
def _row(kind: str, r: dict[str, Any]) -> dict[str, Any]:
return {
"at": r["at"],
"kind": kind,
"user_id": str(r["user_id"]) if r.get("user_id") else None,
"display_name": r.get("display_name"),
"account": r.get("account"),
"ip": r.get("ip"),
"detail": r.get("detail") or {},
}
AUDIT_ALERTS = (
# (kind, WHERE on audit_events e)
("lockout", "e.action = 'auth.lockout'"),
("signin_burst", "e.action = 'auth.signin_burst'"),
("denied", "e.action = 'policy.denied'"),
("admin_change", "e.action LIKE 'admin.%%'"),
("it_edit", "e.it_reason IS NOT NULL"),
)
AUDIT_ALERT_SQL = """
SELECT e.at, e.user_id, u.display_name, coalesce(e.detail->>'account', u.sam_account) AS account,
host(e.ip) AS ip, e.action, e.detail, e.it_reason, e.subject_type, e.subject_id
FROM audit_events e LEFT JOIN users u ON u.id = e.user_id WHERE {where} AND e.at > %s
ORDER BY e.id DESC LIMIT %s
"""
def alerts(conn: Any, hours: int) -> list[dict[str, Any]]:
"""What happened in the last `hours`, newest first, at most ALERTS_MAX rows."""
out: list[dict[str, Any]] = []
since = conn.execute("SELECT now() - make_interval(hours => %s::int)", (hours,)).fetchone()[0]
with conn.cursor(row_factory=dict_row) as cur:
for kind, where in AUDIT_ALERTS:
rows = cur.execute(
AUDIT_ALERT_SQL.format(where=where),
(since, ALERTS_MAX),
).fetchall()
for r in rows:
detail = dict(r["detail"] or {}) if isinstance(r["detail"], dict) else {"value": r["detail"]}
detail.setdefault("action", r["action"])
if r["subject_type"]:
detail.setdefault("subject", f"{r['subject_type']}:{r['subject_id']}")
if kind == "it_edit":
detail["it_reason"] = r["it_reason"]
out.append(_row(kind, {**r, "detail": detail}))
rows = cur.execute(
"SELECT max(a.at) AS at, a.user_id, u.display_name, u.sam_account AS account, count(*) AS n,"
" (array_agg(host(a.ip) ORDER BY a.at DESC))[1] AS ip, array_agg(DISTINCT a.kind) AS kinds"
" FROM signin_attempts a LEFT JOIN users u ON u.id = a.user_id"
" WHERE NOT a.ok AND a.reason = 'bad_pin' AND a.user_id IS NOT NULL AND a.at > %s"
" GROUP BY a.user_id, u.display_name, u.sam_account HAVING count(*) >= %s",
(since, PIN_FAILURES_MIN),
).fetchall()
for r in rows:
out.append(_row("pin_failures", {**r, "detail": {"wrong_pins": int(r["n"]), "where": sorted(r["kinds"])}}))
rows = cur.execute(
"SELECT a.at, a.user_id, u.display_name, u.sam_account AS account, host(a.ip) AS ip, a.kind, a.reason"
" FROM signin_attempts a LEFT JOIN users u ON u.id = a.user_id"
" WHERE NOT a.ok AND a.kind IN ('unlock', 'signature') AND a.reason IN ('bad_pin', 'bad_password')"
" AND a.at > %s ORDER BY a.id DESC LIMIT %s",
(since, ALERTS_MAX),
).fetchall()
for r in rows:
out.append(_row("stepup_wrong_pin", {**r, "detail": {"where": r["kind"], "reason": r["reason"]}}))
rows = cur.execute(
"SELECT f.at, f.kind, f.subject, f.detail, f.run_id FROM integrity_findings f WHERE f.at > %s"
" ORDER BY f.id DESC LIMIT %s",
(since, ALERTS_MAX),
).fetchall()
for r in rows:
detail = {"finding": r["kind"], "subject": r["subject"], "run_id": r["run_id"], **(r["detail"] or {})}
out.append(_row("integrity", {"at": r["at"], "detail": detail}))
out.sort(key=lambda x: x["at"], reverse=True)
return out[:ALERTS_MAX]
+3 -2
View File
@@ -405,14 +405,15 @@ PEOPLE_SQL = """
SELECT u.id, u.display_name, u.welder_stamp, max(CASE r.role::text WHEN 'shop_supervisor' THEN 3
WHEN 'shop_lead' THEN 2 ELSE 1 END) AS level
FROM users u JOIN user_roles r ON r.user_id = u.id
WHERE u.active AND r.dept_code = %s AND r.role::text IN ('shop_worker', 'shop_lead', 'shop_supervisor')
WHERE u.active AND u.profile_active AND r.dept_code = %s
AND r.role::text IN ('shop_worker', 'shop_lead', 'shop_supervisor')
GROUP BY u.id, u.display_name, u.welder_stamp
ORDER BY lower(u.display_name), u.id
"""
def people(conn: Any, dept: str) -> list[dict[str, Any]]:
"""The active users with a shop role in a department, for the welder picker."""
"""The active users with a shop role in a department, for the welder picker. An inactive profile is left out."""
found = packets.rows(conn, PEOPLE_SQL, (dept,))
limits: dict[str, list[int]] = {}
if found:
+5 -1
View File
@@ -97,6 +97,8 @@ def create_app(
from .api import (
account,
admin,
admin_org,
admin_security,
assets,
auth,
departments,
@@ -120,6 +122,8 @@ def create_app(
auth,
account,
admin,
admin_org,
admin_security,
departments,
events_api,
projects,
@@ -138,7 +142,7 @@ def create_app(
):
app.include_router(module.router)
app.include_router(assets.blob_router)
for name in ("feed", "packet_visual"):
for name in ("feed", "packet_visual", "notifications"):
modname = f"{__package__}.api.{name}"
try:
extra = importlib.import_module(modname)
@@ -0,0 +1,162 @@
-- 0010_admin_notify: phase 9. Admin profiles, notification routing and delivery, integrity findings.
-- Runs as ps_migrate; 0001's default privileges give ps_app SELECT, INSERT, UPDATE, DELETE on every table here.
-- Append-only tables revoke below.
-- ---------------------------------------------------------------- profiles and departments
-- The Admin's "active" on a person's profile. users.active belongs to the AD reconcile (disabled or gone in AD);
-- profile_active is the app's own switch. An inactive profile drops out of the welder picker, the worker filter
-- and the station pickers. It does not change what the person may do; AD groups decide that.
ALTER TABLE users ADD COLUMN profile_active bool NOT NULL DEFAULT true;
-- Who added a department in the app. Seeded departments have neither.
ALTER TABLE departments ADD COLUMN created_at timestamptz;
ALTER TABLE departments ADD COLUMN created_by uuid REFERENCES users (id);
-- When the integrity job last rehashed the file behind an asset, and whether it matched.
ALTER TABLE assets ADD COLUMN hash_checked_at timestamptz;
ALTER TABLE assets ADD COLUMN hash_ok bool;
-- ---------------------------------------------------------------- notification routing
CREATE TYPE notify_delivery AS ENUM ('immediate', 'digest');
CREATE TYPE notify_recipient_kind AS ENUM ('group', 'user', 'mailbox', 'dept_supervisor');
-- One row per event type (the catalog in notify/catalog.py). Seeded by the code on first start: enabled,
-- immediate, recipient PS-Admins. param is N hours or N days for the event types that take one.
CREATE TABLE notification_rules (
event_type text PRIMARY KEY,
enabled bool NOT NULL DEFAULT true,
delivery notify_delivery NOT NULL DEFAULT 'immediate',
digest_time time NOT NULL DEFAULT '07:00',
param int CHECK (param IS NULL OR param > 0),
version int NOT NULL DEFAULT 1,
updated_at timestamptz,
updated_by uuid REFERENCES users (id)
);
-- group: an AD group by DN (any group under LDAP_BASE_DN), resolved to its members' mail at send time.
-- user: one person from the directory. mailbox: a plain address. dept_supervisor: the event's department
-- Supervisor group, worked out at send time.
CREATE TABLE notification_recipients (
id bigserial PRIMARY KEY,
event_type text NOT NULL REFERENCES notification_rules (event_type) ON DELETE CASCADE,
kind notify_recipient_kind NOT NULL,
group_dn text,
group_name text,
user_id uuid REFERENCES users (id),
address text,
CHECK (kind <> 'group' OR group_dn IS NOT NULL),
CHECK (kind <> 'user' OR user_id IS NOT NULL),
CHECK (kind <> 'mailbox' OR address IS NOT NULL)
);
CREATE INDEX notification_recipients_event ON notification_recipients (event_type);
-- A person left out of one event type's delivery. self = set by the person on Account settings (never allowed
-- for the security event types); otherwise set by an Admin.
CREATE TABLE notification_exclusions (
event_type text NOT NULL REFERENCES notification_rules (event_type) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users (id),
self bool NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES users (id),
PRIMARY KEY (event_type, user_id)
);
-- ---------------------------------------------------------------- notification events (0005's table, extended)
-- kind stays as the old key and equals event_type for every row raised through notify.raise_event.
-- A standing condition (feed failing, backup stale, quota) stays open (resolved_at null) until it clears; raising
-- it again while open only counts (raised_count, last_raised_at) and sends nothing: that is the dedupe. Clearing
-- it sets resolved_at and inserts a recovery row (recovery_of) that is sent to the same rule. A one-shot event
-- (security alert, reject, version deployed) is inserted already resolved.
ALTER TABLE notification_events ADD COLUMN event_type text;
ALTER TABLE notification_events ADD COLUMN title text;
ALTER TABLE notification_events ADD COLUMN body text;
ALTER TABLE notification_events ADD COLUMN dept_code text REFERENCES departments (code);
ALTER TABLE notification_events ADD COLUMN raised_count int NOT NULL DEFAULT 1;
ALTER TABLE notification_events ADD COLUMN last_raised_at timestamptz NOT NULL DEFAULT now();
ALTER TABLE notification_events ADD COLUMN recovery_of bigint REFERENCES notification_events (id);
-- Set by notify-send when it has handled the row: sent, queued for the digest, or skipped (rule off).
ALTER TABLE notification_events ADD COLUMN dispatched_at timestamptz;
ALTER TABLE notification_events ADD COLUMN dispatch_result text;
-- The phase 4 quota rows become assets_quota events. They were raised before delivery existed, so they are
-- marked handled and never mailed late.
UPDATE notification_events SET event_type = kind, title = 'Assets storage warning', dispatched_at = now(),
dispatch_result = 'before_phase_9'
WHERE event_type IS NULL;
CREATE INDEX notification_events_pending ON notification_events (id)
WHERE dispatched_at IS NULL AND event_type IS NOT NULL;
CREATE INDEX notification_events_type_at ON notification_events (event_type, created_at DESC);
-- Events waiting for their rule's daily digest. Recipients are resolved when the digest goes, not here.
CREATE TABLE notification_outbox (
id bigserial PRIMARY KEY,
event_id bigint NOT NULL REFERENCES notification_events (id),
event_type text NOT NULL,
queued_at timestamptz NOT NULL DEFAULT now(),
sent_at timestamptz,
sent_id bigint
);
CREATE INDEX notification_outbox_pending ON notification_outbox (event_type, id) WHERE sent_at IS NULL;
-- Every message, append-only: the event type ('digest' for a digest), the events in it, the subject, the
-- recipients as resolved [{address, user_id, why}], and the result. A test send is marked test.
CREATE TABLE notifications_sent (
id bigserial PRIMARY KEY,
at timestamptz NOT NULL DEFAULT now(),
event_type text NOT NULL,
event_ids bigint[] NOT NULL DEFAULT '{}',
digest bool NOT NULL DEFAULT false,
test bool NOT NULL DEFAULT false,
subject text NOT NULL,
recipients jsonb NOT NULL DEFAULT '[]',
result text NOT NULL CHECK (result IN ('sent', 'dry_run', 'failed', 'no_recipients')),
error text,
requested_by uuid REFERENCES users (id)
);
CREATE INDEX notifications_sent_type ON notifications_sent (event_type, id DESC);
CREATE TRIGGER notifications_sent_append_only
BEFORE UPDATE OR DELETE ON notifications_sent
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER notifications_sent_no_truncate
BEFORE TRUNCATE ON notifications_sent
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
-- ---------------------------------------------------------------- integrity
-- What the integrity job found wrong, per run, append-only. kind: seal (a filed packet's seal does not verify),
-- asset_hash (the file's sha256 differs), asset_missing (no file for an asset row), blob_orphan (a file no asset
-- row names; reported, never deleted), audit_chain (the hash chain breaks at subject = the audit id).
CREATE TABLE integrity_findings (
id bigserial PRIMARY KEY,
-- no foreign key: housekeeping prunes job_runs at 180 days and these rows are append-only
run_id bigint,
at timestamptz NOT NULL DEFAULT now(),
kind text NOT NULL CHECK (kind IN ('seal', 'asset_hash', 'asset_missing', 'blob_orphan', 'audit_chain')),
subject text NOT NULL,
detail jsonb NOT NULL DEFAULT '{}'
);
CREATE INDEX integrity_findings_run ON integrity_findings (run_id, id);
CREATE TRIGGER integrity_findings_append_only
BEFORE UPDATE OR DELETE ON integrity_findings
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER integrity_findings_no_truncate
BEFORE TRUNCATE ON integrity_findings
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
-- ---------------------------------------------------------------- jobs
-- The phase 9 jobs, so "refresh from AD now" is accepted before the worker has started once.
INSERT INTO job_schedule (job) VALUES
('ad-reconcile'), ('session-refresh'), ('integrity'), ('backup-check'), ('notify-send'), ('notify-digest')
ON CONFLICT (job) DO NOTHING;
-- Explicit, after the default privileges: the app may only read and append these two.
REVOKE UPDATE, DELETE, TRUNCATE ON notifications_sent, integrity_findings FROM ps_app;
GRANT SELECT, INSERT ON notifications_sent, integrity_findings TO ps_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ps_app;
+13
View File
@@ -0,0 +1,13 @@
"""Notifications: the event catalog, raising and resolving events, routing, delivery and the digest.
Everyone else calls `raise_event` and `resolve_event` inside their own transaction. Only the notify-send and
notify-digest jobs (and the Admin's test send) talk to the mail relay.
"""
from __future__ import annotations
from .catalog import EVENT_TYPES, SECURITY_TYPES, EventType
from .events import raise_event, resolve_event
from .seeding import seed
__all__ = ["EVENT_TYPES", "SECURITY_TYPES", "EventType", "raise_event", "resolve_event", "seed"]
+82
View File
@@ -0,0 +1,82 @@
"""The notification event types. One routing rule per type lives in `notification_rules`.
A security type cannot be opted out of on Account settings, and the Admin cannot exclude anyone from it either.
A department-scoped type carries the department of its packet, so it can go to that department's Supervisor group.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class EventType:
key: str
label: str
description: str
security: bool = False
dept_scoped: bool = False
param: str | None = None # "hours" or "days"
param_default: int | None = None
_TYPES = (
EventType(
"security_alert",
"Security alert",
"An account cooldown started, a PIN was disabled after five wrong tries, or a session ended after three"
" wrong unlock or signature PINs.",
security=True,
),
EventType(
"signin_burst",
"Sign-in burst",
"The per-address sign-in limit tripped, or many sign-ins failed across accounts in five minutes.",
security=True,
),
EventType(
"integrity_failure",
"Integrity check failure",
"The nightly integrity check found a broken seal, a changed or missing file, or a break in the audit chain.",
security=True,
),
EventType("feed_failed", "Feed run failed", "A VISUAL feed or drawing lookup run failed."),
EventType("feed_degraded", "Feed run degraded", "A VISUAL feed run finished with errors."),
EventType(
"drawing_failing",
"Drawing lookup failing",
"Drawing lookups in M-Files have failed for more than the set number of hours.",
param="hours",
param_default=24,
),
EventType(
"ad_reconcile_report",
"AD reconcile report",
"The nightly AD reconcile added or deactivated people, found extra groups, or flagged employee IDs.",
),
EventType("group_missing", "Group missing from the OU", "A mapped PS group is not in the PacketStation OU."),
EventType("backup_stale", "Backup failed or stale", "The newest database backup is missing, empty, old or failed."),
EventType(
"co_waiting",
"Change order waiting",
"A change order has waited for review for more than the set number of days.",
dept_scoped=True,
param="days",
param_default=3,
),
EventType(
"qc_reject",
"Hold-point or ITP reject",
"Q.C. rejected a packet, or an ITP line was rejected.",
dept_scoped=True,
),
EventType("version_deployed", "Version deployed", "The worker started on a new build."),
EventType("assets_quota", "Assets storage warning", "The assets storage passed its warning threshold."),
)
EVENT_TYPES: dict[str, EventType] = {t.key: t for t in _TYPES}
SECURITY_TYPES: frozenset[str] = frozenset(t.key for t in _TYPES if t.security)
def is_known(event_type: str) -> bool:
return event_type in EVENT_TYPES
+154
View File
@@ -0,0 +1,154 @@
"""notify-digest: once a rule's digest time has passed today (site time), send what it queued before then.
Recipients are resolved per event, then grouped per address: one message per address listing every event due for
it, across rules. One notifications_sent row per message (event_type 'digest'). An outbox row is marked sent once
every address it goes to has it; a failed send leaves it for the next run, and an address that already has an
event is not sent it again.
"""
from __future__ import annotations
import logging
from datetime import datetime, time, timedelta
from typing import Any
from zoneinfo import ZoneInfo
from psycopg.rows import dict_row
from ..settings import Settings
from . import smtp
from .routing import DIRECTORY_DOWN, DirectoryDown, NotifyDirectory, Recipient, Resolver, load_rules
from .send import deliver, event_lines, footer, record
log = logging.getLogger(__name__)
DEFAULT_TIME = time(7, 0)
BACKOFF = timedelta(minutes=30)
def _due_rows(conn: Any, settings: Settings, rules: dict[str, Any], now: datetime) -> list[dict[str, Any]]:
tz = ZoneInfo(settings.site_tz)
pending = [r[0] for r in conn.execute("SELECT DISTINCT event_type FROM notification_outbox WHERE sent_at IS NULL")]
out: list[dict[str, Any]] = []
for et in pending:
rule = rules.get(et)
at = rule.digest_time if rule is not None else DEFAULT_TIME
cutoff = datetime.combine(now.astimezone(tz).date(), at, tzinfo=tz)
if now < cutoff:
continue
with conn.cursor(row_factory=dict_row) as cur:
out.extend(
dict(r)
for r in cur.execute(
"SELECT o.id AS outbox_id, o.queued_at, e.* FROM notification_outbox o"
" JOIN notification_events e ON e.id = o.event_id"
" WHERE o.sent_at IS NULL AND o.event_type = %s AND o.queued_at < %s ORDER BY o.id",
(et, cutoff),
).fetchall()
)
return out
def _already(conn: Any, event_ids: list[int]) -> set[tuple[int, str]]:
"""(event id, address) pairs a digest already carried, sent or recorded in dry run."""
done: set[tuple[int, str]] = set()
for ids, recips in conn.execute(
"SELECT event_ids, recipients FROM notifications_sent WHERE digest AND result IN ('sent', 'dry_run')"
" AND event_ids && %s::bigint[]",
(event_ids,),
).fetchall():
for rc in recips or []:
addr = str(rc.get("address") or "").lower()
done.update((int(i), addr) for i in ids or [])
return done
def run_digest(
conn: Any, settings: Settings, directory: NotifyDirectory | None, now: datetime | None = None
) -> dict[str, Any]:
"""Send the due digests. Returns counts."""
tz = ZoneInfo(settings.site_tz)
now = now or datetime.now(tz)
last = conn.execute(
"SELECT result, at FROM notifications_sent WHERE digest AND NOT test ORDER BY id DESC LIMIT 1"
).fetchone()
rules = load_rules(conn)
rows = _due_rows(conn, settings, rules, now)
if not rows:
return {"due": 0}
if last is not None and last[0] == "failed" and now - last[1] < BACKOFF:
return {"due": len(rows), "backoff": True}
resolver = Resolver(conn, settings, directory)
per_event: dict[int, list[Recipient]] = {}
try:
for r in rows:
rule = rules.get(str(r["event_type"]))
per_event[int(r["id"])] = resolver.resolve(rule, r.get("dept_code")) if rule and rule.enabled else []
except DirectoryDown:
ids = sorted({int(r["id"]) for r in rows})
subject = smtp.full_subject(settings, "Daily digest")
record(
conn,
event_type="digest",
subject=subject,
recipients=[],
result="failed",
error=DIRECTORY_DOWN,
event_ids=ids,
digest=True,
)
return {"due": len(rows), "failed": 1, "error": DIRECTORY_DOWN}
done = _already(conn, sorted(per_event))
by_addr: dict[str, tuple[Recipient, list[dict[str, Any]]]] = {}
seen_event: dict[str, set[int]] = {}
for r in rows:
eid = int(r["id"])
for rc in per_event[eid]:
key = rc.address.lower()
if (eid, key) in done or eid in seen_event.setdefault(key, set()):
continue
seen_event[key].add(eid)
by_addr.setdefault(key, (rc, []))[1].append(r)
failed_events: set[int] = set()
sent_by_event: dict[int, int] = {}
counts: dict[str, Any] = {"due": len(rows), "messages": 0}
for _key, (rc, events) in sorted(by_addr.items()):
n = len(events)
subject = smtp.full_subject(settings, f"Daily digest: {n} notification{'s' if n != 1 else ''}")
lines: list[str] = [f"{n} Packet Station notification{'s' if n != 1 else ''} since the last digest.", ""]
for i, ev in enumerate(events, 1):
block = event_lines(settings, ev)
lines.append(f"{i}. {block[0]}")
lines.extend(f" {x}" if x else "" for x in block[1:])
lines.append("")
lines.extend(footer(settings))
ids = [int(e["id"]) for e in events]
result, error = deliver(settings, subject, "\n".join(lines), [rc])
sid = record(
conn,
event_type="digest",
subject=subject,
recipients=[rc],
result=result,
error=error,
event_ids=ids,
digest=True,
)
counts["messages"] += 1
counts[result] = counts.get(result, 0) + 1
if result in ("sent", "dry_run"):
for i in ids:
sent_by_event[i] = sid
else:
failed_events.update(ids)
with conn.transaction():
for r in rows:
eid = int(r["id"])
if eid in failed_events:
continue
conn.execute(
"UPDATE notification_outbox SET sent_at = now(), sent_id = %s WHERE id = %s AND sent_at IS NULL",
(sent_by_event.get(eid), r["outbox_id"]),
)
counts["left"] = len({int(r["id"]) for r in rows if int(r["id"]) in failed_events})
return counts
+134
View File
@@ -0,0 +1,134 @@
"""Raise and resolve notification events inside the caller's transaction. No I/O here: notify-send mails them.
A standing condition stays open (resolved_at null) until it clears. Raising it again while open only counts it.
Clearing it closes the row and adds a recovery row that goes to the same rule. A one-shot is inserted resolved.
"""
from __future__ import annotations
import json
import logging
from typing import Any
from psycopg.types.json import Jsonb
from .catalog import EVENT_TYPES
log = logging.getLogger(__name__)
SEVERITIES = ("info", "warning", "critical")
TITLE_MAX = 200
BODY_MAX = 8000
SUBJECT_MAX = 200
def _jsonable(value: Any) -> Any:
return json.loads(json.dumps(value if value is not None else {}, default=str))
def _clip(text: str | None, n: int) -> str:
t = (text or "").strip()
return t if len(t) <= n else t[: n - 1] + "…"
def raise_event(
conn: Any,
event_type: str,
*,
subject: str,
title: str,
body: str = "",
detail: dict[str, Any] | None = None,
dept_code: str | None = None,
severity: str = "warning",
condition: bool = False,
dedupe_minutes: int = 0,
) -> int | None:
"""Record one event. Returns the new row's id, or None when it was deduped (or could not be recorded).
Never raises: a notification must not break a sign-in, a reject or a job. A failure is logged.
"""
if event_type not in EVENT_TYPES:
log.error("raise_event: unknown event type %r; not recorded", event_type)
return None
if severity not in SEVERITIES:
severity = "warning"
subj = _clip(subject, SUBJECT_MAX)
args = (_clip(title, TITLE_MAX), _clip(body, BODY_MAX), Jsonb(_jsonable(detail)), dept_code)
try:
with conn.transaction():
if condition:
row = conn.execute(
"INSERT INTO notification_events (kind, event_type, severity, subject, title, body, detail,"
" dept_code) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)"
" ON CONFLICT (kind, coalesce(subject, '')) WHERE resolved_at IS NULL DO UPDATE SET"
" raised_count = notification_events.raised_count + 1, last_raised_at = now(),"
" detail = EXCLUDED.detail, body = EXCLUDED.body"
" RETURNING id, (xmax = 0) AS inserted",
(event_type, event_type, severity, subj, *args),
).fetchone()
return int(row[0]) if row is not None and row[1] else None
if dedupe_minutes > 0:
recent = conn.execute(
"SELECT 1 FROM notification_events WHERE event_type = %s AND subject = %s"
" AND created_at > now() - make_interval(mins => %s) LIMIT 1",
(event_type, subj, int(dedupe_minutes)),
).fetchone()
if recent is not None:
return None
row = conn.execute(
"INSERT INTO notification_events (kind, event_type, severity, subject, title, body, detail,"
" dept_code, resolved_at) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, now()) RETURNING id",
(event_type, event_type, severity, subj, *args),
).fetchone()
return int(row[0])
except Exception:
log.exception("raise_event %s failed; not recorded", event_type)
return None
def resolve_event(conn: Any, event_type: str, *, subject: str, title: str | None = None, body: str = "") -> int | None:
"""Close the open condition for (event_type, subject) and add its recovery row. Returns the recovery row's id,
or None when nothing was open. Never raises."""
if event_type not in EVENT_TYPES:
log.error("resolve_event: unknown event type %r", event_type)
return None
subj = _clip(subject, SUBJECT_MAX)
try:
with conn.transaction():
row = conn.execute(
"UPDATE notification_events SET resolved_at = now() WHERE kind = %s AND coalesce(subject, '') = %s"
" AND resolved_at IS NULL RETURNING id, title, dept_code",
(event_type, subj),
).fetchone()
if row is None:
return None
open_id, open_title, dept = row
base = title or open_title or EVENT_TYPES[event_type].label
rec = conn.execute(
"INSERT INTO notification_events (kind, event_type, severity, subject, title, body, detail,"
" dept_code, resolved_at, recovery_of) VALUES (%s, %s, 'info', %s, %s, %s, %s, %s, now(), %s)"
" RETURNING id",
(
event_type,
event_type,
subj,
_clip(f"Cleared: {base}", TITLE_MAX),
_clip(body, BODY_MAX),
Jsonb({"recovery_of": open_id}),
dept,
open_id,
),
).fetchone()
return int(rec[0])
except Exception:
log.exception("resolve_event %s failed", event_type)
return None
def open_event(conn: Any, event_type: str, subject: str) -> int | None:
row = conn.execute(
"SELECT id FROM notification_events WHERE kind = %s AND coalesce(subject, '') = %s AND resolved_at IS NULL",
(event_type, subject),
).fetchone()
return int(row[0]) if row else None
+222
View File
@@ -0,0 +1,222 @@
"""Rules and recipients. Recipients are resolved when a message goes, so a group stays current.
group: the members of an AD group by DN (nested, enabled accounts with mail). user: users.email of an active user.
mailbox: the address. dept_supervisor: the Supervisor group of the event's department, none without one.
Exclusions drop a person by user id and by that person's email.
"""
from __future__ import annotations
import logging
import re
import uuid
from dataclasses import dataclass, field
from datetime import time
from typing import Any, Protocol
from psycopg.rows import dict_row
from ..auth import groups as groupmap
from ..auth.ldap import AdUser
from ..settings import Settings
from .catalog import EVENT_TYPES
log = logging.getLogger(__name__)
ADDRESS_RE = re.compile(r"^[^@\s<>,;\"']{1,64}@[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251})\.[A-Za-z]{2,63}$")
DIRECTORY_DOWN = "directory unavailable"
class NotifyDirectory(Protocol):
"""The directory calls notifications use (a subset of auth.ldap.DirectoryProtocol)."""
def get_user_by_guid(self, guid: uuid.UUID) -> AdUser | None: ...
def groups_of(self, dn: str) -> list[str]: ...
def members_of_dn(self, group_dn: str) -> list[AdUser]: ...
def search_groups(self, q: str, limit: int = 25) -> list[dict[str, Any]]: ...
class DirectoryDown(Exception):
"""AD could not be read while resolving a group."""
def address_ok(value: str) -> bool:
v = value.strip()
return len(v) <= 254 and ADDRESS_RE.match(v) is not None
def cn_of(dn: str) -> str:
"""The first CN of a DN ("CN=PS-Admins,OU=..." -> "PS-Admins"); the DN itself when it has none."""
first = dn.split(",", 1)[0].strip()
return first[3:].strip() if first[:3].lower() == "cn=" else dn
def dn_under(dn: str, base: str) -> bool:
b = base.strip().lower().replace(" ", "")
return bool(b) and dn.strip().lower().replace(" ", "").endswith("," + b)
def supervisor_group(dept_code: str | None) -> str | None:
"""The Supervisor group name of a department, from the group map (None when it has none)."""
if not dept_code:
return None
for name, role in groupmap.current().entries:
if role.dept_code == dept_code and role.kind.endswith("supervisor"):
return name
return None
def group_dn(settings: Settings, name: str) -> str:
base = settings.ldap_group_base_dn.strip()
return f"CN={name},{base}" if base else f"CN={name}"
def supervisor_depts(roles: list[str]) -> list[str]:
"""The departments a role set supervises (their Supervisor group), sorted."""
out: set[str] = set()
for text in roles:
try:
r = groupmap.parse_role(text)
except ValueError:
continue
if r.dept_code and r.kind.endswith("supervisor"):
out.add(r.dept_code)
return sorted(out)
# ---------------------------------------------------------------- rules
@dataclass
class Rule:
event_type: str
enabled: bool
delivery: str
digest_time: time
param: int | None
version: int
recipients: list[dict[str, Any]] = field(default_factory=list)
exclusions: list[dict[str, Any]] = field(default_factory=list)
@property
def param_value(self) -> int | None:
et = EVENT_TYPES.get(self.event_type)
if et is None or et.param is None:
return None
return self.param if self.param is not None else et.param_default
def load_rules(conn: Any, event_type: str | None = None) -> dict[str, Rule]:
args = (event_type, event_type)
with conn.cursor(row_factory=dict_row) as cur:
rules = {
r["event_type"]: Rule(
r["event_type"],
bool(r["enabled"]),
str(r["delivery"]),
r["digest_time"],
r["param"],
int(r["version"]),
)
for r in cur.execute(
"SELECT event_type, enabled, delivery::text AS delivery, digest_time, param, version"
" FROM notification_rules WHERE %s::text IS NULL OR event_type = %s",
args,
).fetchall()
}
for r in cur.execute(
"SELECT r.id, r.event_type, r.kind::text AS kind, r.group_dn, r.group_name, r.user_id, r.address,"
" u.display_name FROM notification_recipients r LEFT JOIN users u ON u.id = r.user_id"
" WHERE %s::text IS NULL OR r.event_type = %s ORDER BY r.id",
args,
).fetchall():
if r["event_type"] in rules:
rules[r["event_type"]].recipients.append(dict(r))
for r in cur.execute(
"SELECT x.event_type, x.user_id, x.self, u.display_name, u.email FROM notification_exclusions x"
" LEFT JOIN users u ON u.id = x.user_id WHERE %s::text IS NULL OR x.event_type = %s"
" ORDER BY lower(u.display_name), x.user_id",
args,
).fetchall():
if r["event_type"] in rules:
rules[r["event_type"]].exclusions.append(dict(r))
return rules
def load_rule(conn: Any, event_type: str) -> Rule | None:
return load_rules(conn, event_type).get(event_type)
# ---------------------------------------------------------------- resolving
@dataclass
class Recipient:
address: str
user_id: str | None
why: str
def as_json(self) -> dict[str, Any]:
return {"address": self.address, "user_id": self.user_id, "why": self.why}
class Resolver:
"""Resolves rules to addresses. Caches group members for its lifetime (one run or one request)."""
def __init__(self, conn: Any, settings: Settings, directory: NotifyDirectory | None):
self.conn = conn
self.settings = settings
self.directory = directory
self._members: dict[str, list[AdUser]] = {}
def members(self, dn: str) -> list[AdUser]:
key = dn.strip().lower()
if key not in self._members:
if self.directory is None:
raise DirectoryDown(DIRECTORY_DOWN)
try:
self._members[key] = list(self.directory.members_of_dn(dn))
except Exception as e:
log.warning("notify: can't read group %s: %s", cn_of(dn), type(e).__name__)
raise DirectoryDown(DIRECTORY_DOWN) from None
return self._members[key]
def _group(self, dn: str, why: str) -> list[Recipient]:
return [
Recipient(u.email.strip(), str(u.guid), why)
for u in self.members(dn)
if not u.disabled and u.email and u.email.strip()
]
def resolve(self, rule: Rule, dept_code: str | None) -> list[Recipient]:
"""Every address the rule reaches for an event of this department, after exclusions, one per address.
Raises DirectoryDown when a group cannot be read."""
found: list[Recipient] = []
for r in rule.recipients:
kind = r["kind"]
if kind == "group" and r.get("group_dn"):
found.extend(self._group(r["group_dn"], f"group {r.get('group_name') or cn_of(r['group_dn'])}"))
elif kind == "user" and r.get("user_id"):
row = self.conn.execute(
"SELECT email FROM users WHERE id = %s AND active AND coalesce(email, '') <> ''", (r["user_id"],)
).fetchone()
if row is not None:
found.append(Recipient(str(row[0]).strip(), str(r["user_id"]), "user"))
elif kind == "mailbox" and r.get("address"):
found.append(Recipient(str(r["address"]).strip(), None, "mailbox"))
elif kind == "dept_supervisor":
name = supervisor_group(dept_code)
if name:
found.extend(self._group(group_dn(self.settings, name), f"Supervisor group of {dept_code}"))
excluded_ids = {str(x["user_id"]) for x in rule.exclusions}
excluded_mail = {str(x["email"]).strip().lower() for x in rule.exclusions if x.get("email")}
out: list[Recipient] = []
seen: set[str] = set()
for rc in found:
key = rc.address.lower()
if key in seen or (rc.user_id and rc.user_id in excluded_ids) or key in excluded_mail:
continue
seen.add(key)
out.append(rc)
return out
+38
View File
@@ -0,0 +1,38 @@
"""Seed the routing rules: every missing event type enabled, immediate, digest 07:00, to PS-Admins.
Idempotent. An existing rule is never touched, so the Admin's choices stand.
"""
from __future__ import annotations
from typing import Any
from ..auth.groups import ADMIN_GROUP
from ..settings import Settings
from .catalog import EVENT_TYPES
def admins_dn(settings: Settings) -> str:
base = settings.ldap_group_base_dn.strip()
return f"CN={ADMIN_GROUP},{base}" if base else f"CN={ADMIN_GROUP}"
def seed(conn: Any, settings: Settings) -> list[str]:
"""Insert the missing rules. Returns the event types it inserted."""
added: list[str] = []
with conn.transaction():
for key, et in EVENT_TYPES.items():
row = conn.execute(
"INSERT INTO notification_rules (event_type, enabled, delivery, digest_time, param)"
" VALUES (%s, true, 'immediate', '07:00', %s) ON CONFLICT (event_type) DO NOTHING RETURNING event_type",
(key, et.param_default),
).fetchone()
if row is None:
continue
conn.execute(
"INSERT INTO notification_recipients (event_type, kind, group_dn, group_name)"
" VALUES (%s, 'group', %s, %s)",
(key, admins_dn(settings), ADMIN_GROUP),
)
added.append(key)
return added
+264
View File
@@ -0,0 +1,264 @@
"""notify-send: deliver the events nobody has handled yet, and the Admin's test send.
Per event: rule off -> dispatched `rule_off`; immediate -> one message to the resolved recipients, one
notifications_sent row, dispatched with the result; digest -> an outbox row, dispatched `queued`.
NOTIFY_DRY_RUN records `dry_run` and sends nothing, except a test send, which always goes.
"""
from __future__ import annotations
import logging
from collections.abc import Sequence
from datetime import datetime
from typing import Any
from zoneinfo import ZoneInfo
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from ..settings import Settings
from . import smtp
from .catalog import EVENT_TYPES
from .routing import DIRECTORY_DOWN, DirectoryDown, NotifyDirectory, Recipient, Resolver, load_rules
log = logging.getLogger(__name__)
MAX_DIRECTORY_TRIES = 3
RETRY_PREFIX = "directory_retry:"
BATCH = 200
FOOTER = "Sent by Packet Station. Who gets this is set on the Admin page, Notifications tab."
def record(
conn: Any,
*,
event_type: str,
subject: str,
recipients: Sequence[Recipient],
result: str,
error: str | None = None,
event_ids: Sequence[int] = (),
digest: bool = False,
test: bool = False,
requested_by: Any = None,
) -> int:
row = conn.execute(
"INSERT INTO notifications_sent (event_type, event_ids, digest, test, subject, recipients, result, error,"
" requested_by) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id",
(
event_type,
list(event_ids),
digest,
test,
subject,
Jsonb([r.as_json() for r in recipients]),
result,
(error or None) and str(error)[:1000],
requested_by,
),
).fetchone()
return int(row[0])
def deliver(
settings: Settings, subject: str, body: str, recipients: Sequence[Recipient], *, test: bool = False
) -> tuple[str, str | None]:
"""(result, error) for one message. No recipients -> no_recipients; dry run (not a test) -> dry_run."""
if not recipients:
return "no_recipients", None
if settings.notify_dry_run and not test:
return "dry_run", None
try:
note = smtp.send(settings, subject, body, [r.address for r in recipients])
except smtp.MailError as e:
log.warning("notify: mail not sent: %s", e)
return "failed", str(e)
return "sent", note
def site_time(settings: Settings, at: datetime | None) -> str:
if at is None:
return ""
return at.astimezone(ZoneInfo(settings.site_tz)).strftime("%Y-%m-%d %H:%M")
def label_of(event_type: str) -> str:
et = EVENT_TYPES.get(event_type)
return et.label if et else event_type
def event_title(ev: dict[str, Any]) -> str:
return str(ev.get("title") or label_of(str(ev.get("event_type"))))
def event_lines(settings: Settings, ev: dict[str, Any]) -> list[str]:
"""The plain-text block for one event (a whole immediate message, or one entry in a digest)."""
lines = [event_title(ev)]
if ev.get("body"):
lines += ["", str(ev["body"]).rstrip()]
lines.append("")
lines.append(f"Event: {label_of(str(ev.get('event_type')))}")
if ev.get("subject"):
lines.append(f"About: {ev['subject']}")
if ev.get("dept_code"):
lines.append(f"Department: {ev['dept_code']}")
lines.append(f"When: {site_time(settings, ev.get('created_at'))} ({settings.site_tz})")
if int(ev.get("raised_count") or 1) > 1:
lines.append(f"Seen {ev['raised_count']} times, last {site_time(settings, ev.get('last_raised_at'))}")
return lines
def footer(settings: Settings) -> list[str]:
return ["", "--", f"{FOOTER} Stack {settings.stack}, build {settings.ps_build}."]
def immediate_text(settings: Settings, ev: dict[str, Any]) -> tuple[str, str]:
return smtp.full_subject(settings, event_title(ev)), "\n".join(event_lines(settings, ev) + footer(settings))
def _tries(result: Any) -> int:
text = str(result or "")
if text.startswith(RETRY_PREFIX):
try:
return int(text[len(RETRY_PREFIX) :])
except ValueError:
return 0
return 0
def dispatch_pending(conn: Any, settings: Settings, directory: NotifyDirectory | None) -> dict[str, int]:
"""Handle every undispatched event once. Returns counts by outcome."""
counts: dict[str, int] = {}
ids = [
int(r[0])
for r in conn.execute(
"SELECT id FROM notification_events WHERE dispatched_at IS NULL AND event_type IS NOT NULL"
" ORDER BY id LIMIT %s",
(BATCH,),
).fetchall()
]
if not ids:
return counts
rules = load_rules(conn)
resolver = Resolver(conn, settings, directory)
for eid in ids:
with conn.transaction(), conn.cursor(row_factory=dict_row) as cur:
ev = cur.execute(
"SELECT * FROM notification_events WHERE id = %s AND dispatched_at IS NULL FOR UPDATE SKIP LOCKED",
(eid,),
).fetchone()
if ev is None:
continue
outcome = _dispatch_one(conn, settings, resolver, rules.get(str(ev["event_type"])), dict(ev))
counts[outcome] = counts.get(outcome, 0) + 1
return counts
def _done(conn: Any, eid: int, result: str) -> None:
conn.execute(
"UPDATE notification_events SET dispatched_at = now(), dispatch_result = %s WHERE id = %s", (result, eid)
)
def _dispatch_one(conn: Any, settings: Settings, resolver: Resolver, rule: Any, ev: dict[str, Any]) -> str:
eid = int(ev["id"])
if rule is None:
_done(conn, eid, "no_rule")
return "no_rule"
if not rule.enabled:
_done(conn, eid, "rule_off")
return "rule_off"
if rule.delivery == "digest":
conn.execute("INSERT INTO notification_outbox (event_id, event_type) VALUES (%s, %s)", (eid, ev["event_type"]))
_done(conn, eid, "queued")
return "queued"
subject, body = immediate_text(settings, ev)
try:
recipients = resolver.resolve(rule, ev.get("dept_code"))
except DirectoryDown:
tries = _tries(ev.get("dispatch_result")) + 1
record(
conn,
event_type=ev["event_type"],
subject=subject,
recipients=[],
result="failed",
error=DIRECTORY_DOWN,
event_ids=[eid],
)
if tries >= MAX_DIRECTORY_TRIES:
_done(conn, eid, "failed")
return "failed"
conn.execute(
"UPDATE notification_events SET dispatch_result = %s WHERE id = %s", (f"{RETRY_PREFIX}{tries}", eid)
)
return "retry"
result, error = deliver(settings, subject, body, recipients)
record(
conn,
event_type=ev["event_type"],
subject=subject,
recipients=recipients,
result=result,
error=error,
event_ids=[eid],
)
_done(conn, eid, result)
return result
# ---------------------------------------------------------------- test send
def send_test(
conn: Any,
settings: Settings,
directory: NotifyDirectory | None,
event_type: str,
*,
to: str | None = None,
requested_by: Any = None,
) -> dict[str, Any]:
"""Send a test of one event type now, even in dry run. With `to`, only to that address."""
et = EVENT_TYPES[event_type]
subject = smtp.full_subject(settings, f"Test: {et.label}")
body = "\n".join(
[
f'This is a test of the Packet Station notification "{et.label}".',
"",
et.description,
"",
"Nothing has happened. An Admin sent this from the Notifications tab.",
*footer(settings),
]
)
recipients: list[Recipient] = []
error: str | None = None
result: str | None = None
if to:
recipients = [Recipient(to.strip(), None, "test address")]
else:
rule = load_rules(conn, event_type).get(event_type)
try:
recipients = Resolver(conn, settings, directory).resolve(rule, None) if rule else []
except DirectoryDown:
result, error = "failed", DIRECTORY_DOWN
if result is None:
result, error = deliver(settings, subject, body, recipients, test=True)
sent_id = record(
conn,
event_type=event_type,
subject=subject,
recipients=recipients,
result=result,
error=error,
test=True,
requested_by=requested_by,
)
return {
"result": result,
"subject": subject,
"recipients": [r.as_json() for r in recipients],
"error": error,
"sent_id": sent_id,
}
+86
View File
@@ -0,0 +1,86 @@
"""Send one message through the relay. STARTTLS verified against SMTP_CA_BUNDLE, never a plain-text fallback.
To: is the From address; the real recipients go in the envelope only, so nobody sees the list. No credentials.
"""
from __future__ import annotations
import smtplib
import ssl
from collections.abc import Sequence
from email.message import EmailMessage
from email.utils import formatdate, make_msgid
from ..settings import Settings
TIMEOUT_SECONDS = 15
MAIL_OFF = "Mail is off: SMTP_HOST is empty."
class MailError(Exception):
"""The relay could not take the message. The text is safe to store and show (no secrets)."""
def tls_context(settings: Settings) -> ssl.SSLContext:
"""The same verified context the doctor uses: system trust plus SMTP_CA_BUNDLE when set."""
return ssl.create_default_context(cafile=str(settings.smtp_ca_bundle) if settings.smtp_ca_bundle else None)
def subject_prefix(settings: Settings) -> str:
return "[Packet Station]" if settings.stack == "prod" else "[Packet Station TEST]"
def full_subject(settings: Settings, title: str) -> str:
return f"{subject_prefix(settings)} {title}".strip()[:250]
def build_message(settings: Settings, subject: str, body: str) -> EmailMessage:
msg = EmailMessage()
msg["From"] = settings.smtp_from
msg["To"] = settings.smtp_from
msg["Subject"] = subject
msg["Date"] = formatdate(localtime=False)
domain = settings.smtp_from.rpartition("@")[2] or None
msg["Message-ID"] = make_msgid(domain=domain)
msg["Auto-Submitted"] = "auto-generated"
msg.set_content(body or subject)
return msg
def send(settings: Settings, subject: str, body: str, to: Sequence[str]) -> str | None:
"""Deliver one message to the envelope addresses `to`. Raises MailError when nothing was delivered.
Returns None, or a note naming the addresses the relay refused when it took the message for the rest."""
host = (settings.smtp_host or "").strip()
if not host:
raise MailError(MAIL_OFF)
if not to:
raise MailError("No recipients.")
msg = build_message(settings, subject, body)
try:
with smtplib.SMTP(host, settings.smtp_port, timeout=TIMEOUT_SECONDS) as smtp:
smtp.ehlo()
if settings.smtp_starttls:
# starttls raises SMTPNotSupportedError when the relay does not offer it: no fallback
smtp.starttls(context=tls_context(settings))
smtp.ehlo()
refused = smtp.send_message(msg, from_addr=settings.smtp_from, to_addrs=list(to))
except ssl.SSLError as e:
raise MailError(f"TLS: {e.reason or e}") from None
except ssl.CertificateError as e:
raise MailError(f"TLS: {e}") from None
except smtplib.SMTPNotSupportedError:
raise MailError("The relay does not offer STARTTLS; not sent.") from None
except smtplib.SMTPRecipientsRefused as e:
raise MailError(f"Every recipient was refused ({len(e.recipients)}).") from None
except smtplib.SMTPResponseException as e:
raise MailError(f"SMTP {e.smtp_code}: {_text(e.smtp_error)}") from None
except (OSError, smtplib.SMTPException) as e:
raise MailError(f"{type(e).__name__}: {e}") from None
if refused:
return f"Refused for {len(refused)} of {len(to)} recipients: {', '.join(sorted(refused))[:300]}"
return None
def _text(value: bytes | str) -> str:
return (value.decode("utf-8", "replace") if isinstance(value, bytes) else str(value))[:300]
+165
View File
@@ -0,0 +1,165 @@
"""The standing conditions notify-send checks itself (at most every 15 minutes), and the version check.
drawing_failing: drawing lookups failing for more than the rule's hours (subject `drawings`, the list in the body).
co_waiting: a change order pending review for more than the rule's days (subject `co:<id>`, the packet's department).
version_deployed: the worker runs a build other than the last one noted in the settings row `notify.last_build`.
"""
from __future__ import annotations
import logging
from typing import Any
from psycopg.types.json import Jsonb
from .catalog import EVENT_TYPES
from .events import raise_event, resolve_event
from .routing import load_rules
log = logging.getLogger(__name__)
BUILD_KEY = "notify.last_build"
LIST_MAX = 50
def _param(conn: Any, event_type: str) -> int:
rule = load_rules(conn, event_type).get(event_type)
value = rule.param_value if rule is not None else None
default = EVENT_TYPES[event_type].param_default or 1
return int(value or default)
def check_drawings(conn: Any) -> dict[str, Any]:
hours = _param(conn, "drawing_failing")
rows = conn.execute(
"SELECT dwg, failing_since, fail_count, note FROM drawing_lookups"
" WHERE failing_since IS NOT NULL AND failing_since < now() - make_interval(hours => %s)"
" ORDER BY failing_since, dwg",
(hours,),
).fetchall()
if not rows:
rid = resolve_event(conn, "drawing_failing", subject="drawings", body="Every drawing lookup works again.")
return {"drawings_failing": 0, "drawings_cleared": rid is not None}
n = len(rows)
lines = [f"{r[0]}: failing since {r[1]:%Y-%m-%d %H:%M} UTC, {r[2]} tries. {r[3] or ''}".rstrip() for r in rows]
if n > LIST_MAX:
lines = [*lines[:LIST_MAX], f"and {n - LIST_MAX} more"]
raise_event(
conn,
"drawing_failing",
subject="drawings",
title=f"{n} drawing lookup{'s' if n != 1 else ''} failing for more than {hours} hours",
body="\n".join(lines),
detail={"count": n, "hours": hours, "drawings": [r[0] for r in rows[:LIST_MAX]]},
condition=True,
)
return {"drawings_failing": n}
def check_change_orders(conn: Any) -> dict[str, Any]:
days = _param(conn, "co_waiting")
rows = conn.execute(
"SELECT co.id, co.co_no, co.issued_at, p.id, p.dept_code, p.title, p.drawing, pr.number, u.lot,"
" floor(extract(epoch FROM now() - co.issued_at) / 86400)::int"
" FROM change_orders co JOIN packets p ON p.id = co.packet_id JOIN units u ON u.id = p.unit_id"
" JOIN projects pr ON pr.id = u.project_id"
" WHERE co.review = 'pending' AND p.removed_at IS NULL AND co.issued_at < now() - make_interval(days => %s)"
" ORDER BY co.id",
(days,),
).fetchall()
waiting = {f"co:{r[0]}" for r in rows}
raised = 0
for co_id, co_no, _issued, pid, dept, title, drawing, number, lot, age in rows:
what = " ".join(x for x in (f"{number}-{lot}", dept, title or drawing or "") if x)
new = raise_event(
conn,
"co_waiting",
subject=f"co:{co_id}",
title=f"Change order CO-{co_no} waiting {age} days: {what}",
body=f"CO-{co_no} on packet {pid} ({what}) has waited for review for {age} days.",
detail={"co_id": co_id, "packet_id": pid, "days": age},
dept_code=dept,
condition=True,
)
raised += new is not None
cleared = 0
for (subject,) in conn.execute(
"SELECT subject FROM notification_events WHERE kind = 'co_waiting' AND resolved_at IS NULL"
).fetchall():
if subject not in waiting:
cleared += (
resolve_event(conn, "co_waiting", subject=subject, body="Reviewed or no longer waiting.") is not None
)
return {"co_waiting": len(rows), "co_raised": raised, "co_cleared": cleared}
def note_build(conn: Any, build: str) -> int | None:
"""Raise version_deployed when `build` differs from the last build noted. Returns the event id or None."""
build = (build or "").strip()
if not build:
return None
with conn.transaction():
row = conn.execute("SELECT value FROM settings WHERE key = %s FOR UPDATE", (BUILD_KEY,)).fetchone()
before = (row[0] or {}).get("build") if row is not None else None
if before == build:
return None
if row is None:
conn.execute(
"INSERT INTO settings (key, value, version, updated_at) VALUES (%s, %s, 1, now())"
" ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, version = settings.version + 1,"
" updated_at = now()",
(BUILD_KEY, Jsonb({"build": build})),
)
else:
conn.execute(
"UPDATE settings SET value = %s, version = version + 1, updated_at = now() WHERE key = %s",
(Jsonb({"build": build}), BUILD_KEY),
)
log.info("version deployed: %s (was %s)", build, before or "none")
return raise_event(
conn,
"version_deployed",
subject=f"build:{build}",
title=f"Version {build} deployed",
body=f"The worker started on build {build}. The build before was {before or 'not recorded'}.",
detail={"build": build, "before": before},
severity="info",
)
FEED_JOBS = ("visual-feed", "drawing-lookup")
def feed_run(conn: Any, job: str, status: str, errors: list[str], run_id: int | None = None) -> None:
"""feed_failed and feed_degraded after a visual-feed or drawing-lookup run."""
if job not in FEED_JOBS or status not in ("ok", "degraded", "failed"):
return
first = errors[0] if errors else ""
text = "\n".join(errors[:10])
if status == "failed":
raise_event(
conn,
"feed_failed",
subject=job,
title=f"{job} run failed",
body=f"Run {run_id} failed.\n{text}".strip(),
detail={"job": job, "run_id": run_id, "error": first},
severity="critical",
condition=True,
)
return
resolve_event(conn, "feed_failed", subject=job, body=f"Run {run_id} finished {status}.")
if job != "visual-feed":
return
if status == "degraded":
raise_event(
conn,
"feed_degraded",
subject=job,
title=f"{job} run degraded",
body=f"Run {run_id} finished with errors.\n{text}".strip(),
detail={"job": job, "run_id": run_id, "error": first},
condition=True,
)
else:
resolve_event(conn, "feed_degraded", subject=job, body=f"Run {run_id} finished ok.")
+58 -4
View File
@@ -1,7 +1,7 @@
"""The job registry and the shapes every job uses.
Jobs import `from packetstation.worker.jobs import JobContext, JobSpec, register` and expose `SPEC`. The feed and
drawing jobs are imported lazily by `load_jobs`, so the worker runs before they exist.
Jobs import `from packetstation.worker.jobs import JobContext, JobSpec, register` and expose `SPEC`. Every job but
housekeeping is imported lazily by `load_jobs`, so a job module that cannot import is logged and left out.
"""
from __future__ import annotations
@@ -97,7 +97,16 @@ class JobSpec:
JOBS: dict[str, JobSpec] = {}
_loaded = False
_load_lock = threading.Lock()
LAZY = ("visual_feed", "drawing_lookup")
LAZY = (
"visual_feed",
"drawing_lookup",
"ad_reconcile",
"session_refresh",
"integrity",
"backup_check",
"notify_send",
"notify_digest",
)
def register(spec: JobSpec) -> JobSpec:
@@ -117,12 +126,53 @@ def load_jobs() -> dict[str, JobSpec]:
try:
m = importlib.import_module(f"{__name__}.{mod}")
register(m.SPEC)
except ImportError as e:
except (ImportError, AttributeError) as e:
log.warning("job module %s not loaded: %s", mod, redact(str(e)))
_loaded = True
return JOBS
NOTIFY = __name__.rsplit(".", 2)[0] + ".notify"
def directory(ctx: JobContext) -> Any:
"""The worker's directory (one per process, in the stack cache). Tests put a fake under "directory"."""
from ...auth.ldap import Directory
return ctx.stack.cached("directory", lambda: Directory(ctx.settings))
def reload_departments(conn: Any) -> None:
"""Load the departments table into the group map, so a department the Admin added counts at once."""
from ...auth import groups
deps = groups.load_departments(conn)
if deps:
groups.use_departments(deps)
def raise_event(conn: Any, event_type: str, **kw: Any) -> int | None:
"""notify.raise_event, or a logged no-op when the notify package is not there."""
try:
real = importlib.import_module(NOTIFY).raise_event
except (ImportError, AttributeError) as e:
log.warning("event %s not raised, notify not available: %s", event_type, e)
return None
result: int | None = real(conn, event_type, **kw)
return result
def resolve_event(conn: Any, event_type: str, **kw: Any) -> int | None:
"""notify.resolve_event, or a logged no-op when the notify package is not there."""
try:
real = importlib.import_module(NOTIFY).resolve_event
except (ImportError, AttributeError) as e:
log.warning("event %s not resolved, notify not available: %s", event_type, e)
return None
result: int | None = real(conn, event_type, **kw)
return result
def seconds_until(now_site: datetime, at: time) -> int:
"""Seconds from now_site to the next `at` (site wall clock), at least 1."""
target = datetime.combine(now_site.date(), at, tzinfo=now_site.tzinfo)
@@ -140,7 +190,11 @@ __all__ = [
"JobContext",
"JobSpec",
"Stack",
"directory",
"load_jobs",
"raise_event",
"register",
"reload_departments",
"resolve_event",
"seconds_until",
]
@@ -0,0 +1,133 @@
"""The AD reconcile, nightly at 01:30 site time and on request ("Refresh from AD now" on the Admin screen).
It reloads the departments, then runs `auth.directory.refresh_from_ad`: list the PacketStation OU, enumerate the
members of every mapped group (IN_CHAIN, paged at 500), upsert users on objectGUID, mark disabled or gone users
inactive and end their sessions, end the sessions of users left with no role, and flag missing or duplicate
employee IDs. The run's counts are the report the Admin screen shows.
Then the notifications: one `group_missing` condition per mapped group not in the OU (resolved once the group is
there), and one `ad_reconcile_report` when the run created or deactivated anyone, found extra groups in the OU or
flagged employee IDs. AD unreachable fails the run and changes nothing.
"""
from __future__ import annotations
from datetime import datetime, time
from typing import Any
from ...auth import directory as userdir
from ...auth import groups as groupmap
from ...feed.config import FeedSettings
from .. import jobs
from ..redaction import redact
from . import Counts, JobContext, JobSpec, seconds_until
AT = time(1, 30)
NAME = "ad-reconcile"
FAILED_SUBJECT = "ad-reconcile:failed"
BODY_NAMES = 50 # names listed per section in the report mail
def _names(items: list[str]) -> str:
shown = ", ".join(items[:BODY_NAMES])
more = len(items) - BODY_NAMES
return shown + (f" and {more} more" if more > 0 else "")
def report_text(res: userdir.RefreshResult) -> tuple[str, str] | None:
"""(title, body) of the reconcile report, or None when there is nothing to report."""
parts: list[str] = []
lines: list[str] = []
if res.created:
parts.append(f"{res.created} added")
lines.append(f"Added: {_names(res.created_names)}.")
if res.deactivated:
parts.append(f"{res.deactivated} deactivated")
lines.append(f"Deactivated (disabled or gone in AD): {_names(res.deactivated_names)}.")
if res.groups_extra:
parts.append(f"{len(res.groups_extra)} extra group{'s' if len(res.groups_extra) != 1 else ''}")
lines.append(f"Groups in the OU that Packet Station does not map: {_names(res.groups_extra)}.")
if res.emp_missing:
parts.append(f"{len(res.emp_missing)} without an employee ID")
lines.append(f"Accounts without an employee ID: {_names(res.emp_missing)}.")
if res.emp_duplicate:
parts.append(f"{len(res.emp_duplicate)} duplicate employee ID{'s' if len(res.emp_duplicate) != 1 else ''}")
lines.append(f"Employee IDs on more than one account: {_names(res.emp_duplicate)}.")
if not parts:
return None
if res.sessions_ended:
lines.append(f"Sessions ended: {res.sessions_ended}.")
lines.append(f"Accounts seen in the mapped groups: {res.users_seen}.")
return "AD reconcile: " + ", ".join(parts), "\n".join(lines)
def notify(conn: Any, res: userdir.RefreshResult) -> dict[str, int]:
"""Raise and resolve the reconcile's events in one transaction. Returns what was raised."""
raised = {"group_missing": 0, "report": 0}
missing = {n.lower() for n in res.groups_missing}
with conn.transaction():
for name in res.groups_missing:
jobs.raise_event(
conn,
"group_missing",
subject=name,
title=f"Group missing from the OU: {name}",
body=(f"The AD group {name} is not in the PacketStation OU. Nobody gets its role until IT creates it."),
detail={"group": name},
condition=True,
)
raised["group_missing"] += 1
for name in groupmap.group_names():
if name.lower() not in missing:
jobs.resolve_event(conn, "group_missing", subject=name, title=f"Group missing from the OU: {name}")
text = report_text(res)
if text is not None:
jobs.raise_event(
conn,
"ad_reconcile_report",
subject=NAME,
title=text[0],
body=text[1],
detail=res.summary(),
severity="info",
)
raised["report"] = 1
return raised
def run(ctx: JobContext) -> Counts:
conn = ctx.conn
jobs.reload_departments(conn)
try:
res = userdir.refresh_from_ad(conn, jobs.directory(ctx), run_id=ctx.run_id)
except Exception as e:
# AD down or the run refused: tell the report's recipients once, until a run succeeds again
try:
with conn.transaction():
jobs.raise_event(
conn,
"ad_reconcile_report",
subject=FAILED_SUBJECT,
title="AD reconcile did not run",
body=f"The nightly AD reconcile stopped and changed nothing: {type(e).__name__}: {redact(str(e))}",
condition=True,
)
except Exception:
ctx.log.exception("reconcile failure notice not raised")
raise
counts: Counts = res.as_dict()
try:
with conn.transaction():
jobs.resolve_event(conn, "ad_reconcile_report", subject=FAILED_SUBJECT, title="AD reconcile did not run")
notify(conn, res)
except Exception as e: # a notification problem never undoes the reconcile
ctx.log.exception("reconcile notifications failed")
ctx.error(f"notifications: {type(e).__name__}: {e}", degrade=False)
return counts
def cadence(_: FeedSettings, now_site: datetime) -> int:
return seconds_until(now_site, AT)
SPEC = JobSpec(name=NAME, run=run, cadence=cadence, pausable=False)
@@ -0,0 +1,92 @@
"""The backup check, daily at 07:15 site time.
BACKUP_DIR is this stack's backup folder on the host (/var/backups/packetstation/<stack>/), mounted read-only.
The host's backup script writes `*.dump` files and the `last-success` and `last-failure` markers there. The
check raises the `backup_stale` condition (subject: the stack) when the newest dump is missing, empty or older
than 26 hours, or when `last-failure` is newer than `last-success`. Otherwise it resolves it. It only reads.
"""
from __future__ import annotations
from datetime import UTC, datetime, time
from pathlib import Path
from ...feed.config import FeedSettings
from .. import jobs
from . import Counts, JobContext, JobSpec, seconds_until
AT = time(7, 15)
NAME = "backup-check"
MAX_AGE_HOURS = 26.0
SUCCESS = "last-success"
FAILURE = "last-failure"
def _mtime(p: Path) -> datetime | None:
try:
return datetime.fromtimestamp(p.stat().st_mtime, UTC)
except OSError:
return None
def inspect(folder: Path, now: datetime | None = None) -> tuple[Counts, list[str]]:
"""(counts, problems) for a backup folder. No problems means the backups are fine."""
now = now or datetime.now(UTC)
counts: Counts = {"folder": str(folder), "dumps": 0, "newest": None, "age_hours": None, "size": None}
if not folder.is_dir():
return counts, [f"The backup folder {folder} is not there."]
dumps: list[tuple[datetime, Path, int]] = []
for p in folder.glob("*.dump"):
try:
st = p.stat()
except OSError:
continue
if p.is_file():
dumps.append((datetime.fromtimestamp(st.st_mtime, UTC), p, st.st_size))
counts["dumps"] = len(dumps)
problems: list[str] = []
if not dumps:
problems.append("No backup dump is in the folder.")
else:
at, path, size = max(dumps, key=lambda d: (d[0], d[1].name))
age = (now - at).total_seconds() / 3600
counts.update(newest=path.name, newest_at=at.isoformat(), age_hours=round(age, 1), size=size)
if size == 0:
problems.append(f"The newest dump {path.name} is empty.")
if age > MAX_AGE_HOURS:
problems.append(f"The newest dump {path.name} is {age:.0f} hours old.")
ok_at, fail_at = _mtime(folder / SUCCESS), _mtime(folder / FAILURE)
counts["last_success"] = ok_at.isoformat() if ok_at else None
counts["last_failure"] = fail_at.isoformat() if fail_at else None
if fail_at is not None and (ok_at is None or fail_at > ok_at):
problems.append("The last backup run failed (last-failure is newer than last-success).")
return counts, problems
def run(ctx: JobContext) -> Counts:
stack = str(ctx.settings.stack)
counts, problems = inspect(Path(ctx.settings.backup_dir))
counts["problems"] = problems
title = f"Backup failed or stale ({stack})"
with ctx.conn.transaction():
if problems:
jobs.raise_event(
ctx.conn,
"backup_stale",
subject=stack,
title=title,
body="\n".join(problems),
detail={k: v for k, v in counts.items() if k != "problems"},
severity="critical",
condition=True,
)
else:
jobs.resolve_event(ctx.conn, "backup_stale", subject=stack, title=title)
return counts
def cadence(_: FeedSettings, now_site: datetime) -> int:
return seconds_until(now_site, AT)
SPEC = JobSpec(name=NAME, run=run, cadence=cadence, pausable=False)
@@ -0,0 +1,275 @@
"""The integrity check, nightly at 04:00 site time.
- Seals: every unbroken seal of a filed packet is verified again with the seal key.
- Asset hashes: the sha256 of every file uploaded in the last 7 days, every night, and of every file on Sundays
(site time). Each check sets assets.hash_checked_at and hash_ok.
- Missing files: every asset row whose file is not there.
- Orphan files: files under ASSETS_DIR that no asset row names. Reported only, never deleted. A file younger than
an hour is skipped (an upload in flight).
- The audit hash chain, from the first row.
One integrity_findings row per finding (at most 500 per kind; the counts hold the whole number). Any finding
raises the `integrity_failure` condition; a clean, complete run resolves it.
"""
from __future__ import annotations
import hashlib
import hmac
import os
import re
import time as clock
from datetime import datetime, time
from pathlib import Path
from typing import Any
from psycopg.types.json import Jsonb
from ... import audit
from ...feed.config import FeedSettings
from ...settings import SecretFileError
from .. import jobs
from . import Counts, JobContext, JobSpec, seconds_until
AT = time(4, 0)
NAME = "integrity"
KINDS = ("seal", "asset_hash", "asset_missing", "blob_orphan", "audit_chain")
CAP = 500
RECENT_DAYS = 7
ORPHAN_MIN_AGE_SECONDS = 3600
CHUNK = 1024 * 1024
SKIP_DIRS = {"lost+found"}
SUBJECT = "integrity"
LABELS = {
"seal": "seal does not verify",
"asset_hash": "file changed",
"asset_missing": "file missing",
"blob_orphan": "file with no asset row",
"audit_chain": "audit chain break",
}
class Findings:
"""Collects findings and writes one row each, capped per kind."""
def __init__(self, conn: Any, run_id: int):
self.conn = conn
self.run_id = run_id
self.counts: dict[str, int] = dict.fromkeys(KINDS, 0)
self.samples: dict[str, list[str]] = {k: [] for k in KINDS}
def add(self, kind: str, subject: str, detail: dict[str, Any]) -> None:
self.counts[kind] += 1
if self.counts[kind] > CAP:
return
if len(self.samples[kind]) < 10:
self.samples[kind].append(subject)
self.conn.execute(
"INSERT INTO integrity_findings (run_id, kind, subject, detail) VALUES (%s, %s, %s, %s)",
(self.run_id, kind, subject, Jsonb(detail)),
)
@property
def total(self) -> int:
return sum(self.counts.values())
# ---------------------------------------------------------------- seals
def check_seals(ctx: JobContext, found: Findings) -> int:
"""Verify every unbroken seal of a filed packet. Returns the number checked."""
from ...domain import packets
from ...domain.pipeline import seal_hash, seal_key_id
conn = ctx.conn
rows = conn.execute(
"SELECT s.id, s.packet_id, s.hash, s.key_id FROM packet_seals s JOIN packets p ON p.id = s.packet_id"
" WHERE s.broken_at IS NULL AND p.status = 'filed' ORDER BY s.packet_id"
).fetchall()
if not rows:
return 0
try:
key = ctx.settings.seal_key()
except SecretFileError as e:
ctx.degrade(f"seals not checked: {e}")
return 0
kid = seal_key_id(key)
checked = 0
for seal_id, packet_id, digest, key_id in rows:
detail: dict[str, Any] = {"packet_id": packet_id, "seal_id": seal_id}
if key_id != kid:
found.add("seal", f"packet:{packet_id}", {**detail, "reason": "sealed with another key", "key_id": key_id})
checked += 1
continue
try:
with conn.transaction():
row = packets.load_row(conn, packet_id)
content = packets.seal_content(conn, row) if row is not None else None
except Exception as e: # one packet that cannot be read does not stop the others
ctx.error(f"packet {packet_id}: seal not checked: {type(e).__name__}: {e}")
continue
if content is None:
continue
ok = hmac.compare_digest(seal_hash(key, content), bytes(digest))
if not ok:
found.add("seal", f"packet:{packet_id}", {**detail, "reason": "the packet no longer matches its seal"})
checked += 1
return checked
# ---------------------------------------------------------------- assets
def _path(root: Path, key: str) -> Path | None:
p = (root / key).resolve()
return p if p.is_relative_to(root) else None
def sha256_of(path: Path) -> bytes:
h = hashlib.sha256()
with path.open("rb") as f:
while chunk := f.read(CHUNK):
h.update(chunk)
return h.digest()
def check_assets(ctx: JobContext, found: Findings, *, full: bool) -> dict[str, int]:
"""Missing files for every asset row; the sha256 of the recent ones (all of them when full)."""
conn = ctx.conn
root = Path(ctx.settings.assets_dir).resolve()
rows = conn.execute(
"SELECT id, storage_key, sha256, size, uploaded_at >= now() - make_interval(days => %s) AS recent"
" FROM assets ORDER BY uploaded_at, id",
(RECENT_DAYS,),
).fetchall()
hashed = 0
for aid, key, digest, size, recent in rows:
p = _path(root, str(key))
if p is None or not p.is_file():
found.add("asset_missing", str(aid), {"storage_key": key})
conn.execute("UPDATE assets SET hash_checked_at = now(), hash_ok = false WHERE id = %s", (aid,))
continue
if not (full or recent):
continue
try:
actual = sha256_of(p)
except OSError as e:
found.add("asset_missing", str(aid), {"storage_key": key, "error": e.strerror or type(e).__name__})
conn.execute("UPDATE assets SET hash_checked_at = now(), hash_ok = false WHERE id = %s", (aid,))
continue
ok = hmac.compare_digest(actual, bytes(digest))
hashed += 1
if not ok:
found.add(
"asset_hash",
str(aid),
{"storage_key": key, "expected": bytes(digest).hex(), "actual": actual.hex(), "size": size},
)
conn.execute("UPDATE assets SET hash_checked_at = now(), hash_ok = %s WHERE id = %s", (ok, aid))
return {"assets": len(rows), "hashed": hashed}
def check_orphans(ctx: JobContext, found: Findings) -> int:
"""Files under ASSETS_DIR that no asset row names. Never deletes anything. Returns the files looked at."""
root = Path(ctx.settings.assets_dir)
if not root.is_dir():
ctx.degrade(f"ASSETS_DIR {root} is not a folder")
return 0
keys = {str(r[0]) for r in ctx.conn.execute("SELECT storage_key FROM assets")}
cutoff = clock.time() - ORPHAN_MIN_AGE_SECONDS
seen = 0
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for name in filenames:
p = Path(dirpath) / name
rel = p.relative_to(root).as_posix()
seen += 1
if rel in keys:
continue
try:
st = p.stat()
except OSError:
continue
if st.st_mtime > cutoff:
continue
found.add(
"blob_orphan", rel, {"size": st.st_size, "mtime": datetime.fromtimestamp(st.st_mtime).isoformat()}
)
return seen
# ---------------------------------------------------------------- audit chain
_ROW = re.compile(r"^row (\d+):")
def check_audit(ctx: JobContext, found: Findings) -> int:
ok, n, detail = audit.verify_chain(ctx.conn)
if not ok:
m = _ROW.match(detail)
found.add("audit_chain", m.group(1) if m else "?", {"detail": detail, "rows_checked": n})
return n
# ---------------------------------------------------------------- the run
def _event_text(found: Findings) -> tuple[str, str]:
parts = [f"{n} {LABELS[k]}" for k, n in found.counts.items() if n]
lines = []
for k, n in found.counts.items():
if n:
lines.append(f"{LABELS[k].capitalize()}: {n} ({', '.join(found.samples[k])}{', ...' if n > 10 else ''}).")
lines.append("Admin, Security tab: the full list of findings.")
title = f"Integrity check found {found.total} problem{'s' if found.total != 1 else ''}: " + ", ".join(parts)
return title, "\n".join(lines)
def full_day(now_site: datetime) -> bool:
"""Sunday, site time: every asset is rehashed."""
return now_site.weekday() == 6
def run(ctx: JobContext) -> Counts:
conn = ctx.conn
full = full_day(ctx.now_site())
found = Findings(conn, ctx.run_id)
counts: Counts = {"full": full}
with conn.transaction():
counts["seals"] = check_seals(ctx, found)
with conn.transaction():
counts.update(check_assets(ctx, found, full=full))
with conn.transaction():
counts["files"] = check_orphans(ctx, found)
with conn.transaction():
counts["audit_rows"] = check_audit(ctx, found)
counts["findings"] = dict(found.counts)
counts["total"] = found.total
try:
with conn.transaction():
if found.total:
title, body = _event_text(found)
jobs.raise_event(
conn,
"integrity_failure",
subject=SUBJECT,
title=title,
body=body,
detail={"run_id": ctx.run_id, **found.counts},
severity="critical",
condition=True,
)
elif not ctx.degraded:
jobs.resolve_event(conn, "integrity_failure", subject=SUBJECT, title="Integrity check failure")
except Exception as e:
ctx.log.exception("integrity notification failed")
ctx.error(f"notification: {type(e).__name__}: {e}", degrade=False)
return counts
def cadence(_: FeedSettings, now_site: datetime) -> int:
return seconds_until(now_site, AT)
SPEC = JobSpec(name=NAME, run=run, cadence=cadence, pausable=False)
@@ -0,0 +1,30 @@
"""notify-digest, every 5 minutes: send the daily digests whose time has passed (see notify/digest.py). Not
pausable."""
from __future__ import annotations
from datetime import datetime
from ...feed.config import FeedSettings
from ...notify import digest
from . import Counts, JobContext, JobSpec, directory, reload_departments
EVERY_SECONDS = 5 * 60
def run(ctx: JobContext) -> Counts:
has = ctx.conn.execute("SELECT EXISTS (SELECT 1 FROM notification_outbox WHERE sent_at IS NULL)").fetchone()[0]
if not has:
return {"due": 0}
reload_departments(ctx.conn)
counts = digest.run_digest(ctx.conn, ctx.settings, directory(ctx), ctx.now_site())
if counts.get("failed"):
ctx.degrade(str(counts.get("error") or f"{counts['failed']} digest messages failed"))
return counts
def _cadence(_: FeedSettings, __: datetime) -> int:
return EVERY_SECONDS
SPEC = JobSpec(name="notify-digest", run=run, cadence=_cadence, pausable=False)
@@ -0,0 +1,67 @@
"""notify-send, every 60 seconds: seed the rules, note a new build, run the watch checks (at most every 15 minutes)
and deliver the events nobody has handled yet. Not pausable."""
from __future__ import annotations
import time as clock
from datetime import datetime
from ...feed.config import FeedSettings
from ...notify import seed as seed_rules
from ...notify import send, watch
from . import Counts, JobContext, JobSpec, directory, reload_departments
EVERY_SECONDS = 60
WATCH_SECONDS = 15 * 60
WATCH_KEY = "notify.watch_at"
BUILD_KEY = "notify.build_noted"
def _due(ctx: JobContext, key: str, seconds: float) -> bool:
"""True once per `seconds` per worker process; marks the time when it says so."""
now = clock.monotonic()
with ctx.stack.lock:
last = ctx.stack.cache.get(key)
if last is not None and now - float(last) < seconds:
return False
ctx.stack.cache[key] = now
return True
def run(ctx: JobContext) -> Counts:
conn, s = ctx.conn, ctx.settings
counts: Counts = {}
added = seed_rules(conn, s)
if added:
counts["seeded"] = len(added)
if _due(ctx, BUILD_KEY, float("inf")):
try:
if watch.note_build(conn, s.ps_build) is not None:
counts["version_deployed"] = s.ps_build
except Exception as e:
ctx.stack.drop(BUILD_KEY) # try again on the next run
ctx.error(f"version check: {type(e).__name__}", degrade=False)
if _due(ctx, WATCH_KEY, WATCH_SECONDS):
for name, check in (("drawings", watch.check_drawings), ("change orders", watch.check_change_orders)):
try:
counts.update(check(conn))
except Exception as e:
ctx.log.exception("watch check %s failed", name)
ctx.error(f"watch {name}: {type(e).__name__}")
pending = conn.execute(
"SELECT EXISTS (SELECT 1 FROM notification_events WHERE dispatched_at IS NULL AND event_type IS NOT NULL)"
).fetchone()[0]
if pending:
reload_departments(conn)
out = send.dispatch_pending(conn, s, directory(ctx))
counts.update(out)
if out.get("failed") or out.get("retry"):
ctx.degrade(f"{out.get('failed', 0)} failed, {out.get('retry', 0)} waiting for the directory")
return counts
def _cadence(_: FeedSettings, __: datetime) -> int:
return EVERY_SECONDS
SPEC = JobSpec(name="notify-send", run=run, cadence=_cadence, pausable=False)
@@ -0,0 +1,137 @@
"""Session refresh, every `roles_refresh_minutes` (the effective security setting, 15 by default).
For every user with a live session: read the account from AD by objectGUID (batched), re-resolve its groups
(nested) and userAccountControl, rebuild `user_roles` and each live session's roles snapshot and
roles_checked_at. A user disabled or gone in AD is marked inactive and their sessions end (`ad_disabled`,
`ad_gone`); a user left with no role has their sessions ended (`roles_lost`). Every AD read happens before any
write: AD unreachable makes the run degraded and nothing is ended.
"""
from __future__ import annotations
import threading
import uuid
from datetime import datetime
from typing import Any
from psycopg.types.json import Jsonb
from ... import audit
from ...auth import directory as userdir
from ...auth import groups as groupmap
from ...auth.ldap import AdUser, LdapUnavailable
from ...feed.config import FeedSettings
from ...settings import Settings
from .. import jobs
from . import Counts, JobContext, JobSpec
NAME = "session-refresh"
DEFAULT_MINUTES = 15
_lock = threading.Lock()
_minutes = DEFAULT_MINUTES
def refresh_minutes(conn: Any, settings: Settings) -> int:
"""The effective roles_refresh_minutes: the Admin's security setting when that module is there, else Settings."""
try:
from ...auth import secsettings
except ImportError:
return int(settings.roles_refresh_minutes)
try:
return int(secsettings.effective(conn, settings).roles_refresh_minutes)
except Exception:
return int(settings.roles_refresh_minutes)
def _set_minutes(value: int) -> None:
global _minutes
with _lock:
_minutes = max(1, int(value))
def live_users(conn: Any) -> list[uuid.UUID]:
rows = conn.execute(
"SELECT DISTINCT user_id FROM sessions WHERE revoked_at IS NULL AND expires_at > now() ORDER BY user_id"
).fetchall()
return [r[0] for r in rows]
def _deactivate(conn: Any, uid: uuid.UUID, found: AdUser | None) -> int:
gone = found is None
row = conn.execute("SELECT sam_account, active FROM users WHERE id = %s", (uid,)).fetchone()
ended = userdir.mark_inactive(conn, uid, reason="ad_gone" if gone else "ad_disabled", gone=gone, audit_by=NAME)
if row is not None and row[1]:
audit.append(
conn,
action="ad.user_deactivated",
subject_type="user",
subject_id=uid,
detail={"reason": "gone" if gone else "disabled", "sam_account": row[0], "by": NAME},
)
return len(ended)
def run(ctx: JobContext) -> Counts:
conn = ctx.conn
_set_minutes(refresh_minutes(conn, ctx.settings))
jobs.reload_departments(conn)
uids = live_users(conn)
counts: Counts = {
"users": len(uids),
"refreshed": 0,
"roles_changed": 0,
"deactivated": 0,
"roles_lost": 0,
"sessions_ended": 0,
}
if not uids:
return counts
# Every AD read first: a failure part way changes nothing
d = jobs.directory(ctx)
try:
found = d.users_by_guid(uids)
groups_of: dict[uuid.UUID, list[str]] = {}
for uid, u in found.items():
if u is not None and not u.disabled:
groups_of[uid] = sorted(d.groups_of(u.dn))
except LdapUnavailable as e:
ctx.degrade(f"AD unavailable, nothing changed: {e}")
counts["ad"] = "unavailable"
return counts
with conn.transaction():
for uid in uids:
u = found.get(uid)
if u is None or u.disabled:
counts["sessions_ended"] += _deactivate(conn, uid, u)
counts["deactivated"] += 1
continue
grp = groups_of[uid]
roles = groupmap.roles_from_groups(grp)
before = userdir.roles_of(conn, uid)
userdir.upsert_user(conn, u, groups=groupmap.mapped_groups(grp))
userdir.set_roles(conn, uid, roles)
if sorted(before) != sorted(roles):
counts["roles_changed"] += 1
if not roles:
counts["roles_lost"] += 1
counts["sessions_ended"] += len(userdir.end_sessions(conn, uid, "roles_lost", by=NAME))
continue
conn.execute(
"UPDATE sessions SET roles_snapshot = %s, roles_checked_at = now()"
" WHERE user_id = %s AND revoked_at IS NULL AND expires_at > now()",
(Jsonb({"roles": roles, "groups": grp}), uid),
)
counts["refreshed"] += 1
return counts
def cadence(_: FeedSettings, now_site: datetime) -> int:
"""Every roles_refresh_minutes. The value is read at each run, so a changed setting applies after the next."""
with _lock:
return _minutes * 60
SPEC = JobSpec(name=NAME, run=run, cadence=cadence, pausable=False)
+17
View File
@@ -18,6 +18,7 @@ from psycopg.types.json import Jsonb
from .. import guard
from ..feed import config
from ..notify import watch
from .jobs import JobContext, JobSpec
from .redaction import clip, redact
@@ -65,6 +66,21 @@ def _target(result: Any) -> str:
return text.rsplit(" (", 1)[0] if text.endswith(")") else text
def _notify_feed(sch: Scheduler, conn: Any, name: str, status: str, errors: list[str], run_id: int) -> None:
"""feed_failed and feed_degraded after a feed or drawing run. Never fails the run."""
if name not in watch.FEED_JOBS:
return
try:
conn.execute("SELECT 1") # raise_event never raises, so find a broken connection first
watch.feed_run(conn, name, status, list(errors), run_id)
except Exception:
try:
with sch.connect() as c2:
watch.feed_run(c2, name, status, list(errors), run_id)
except Exception as e:
log.error("job %s: can't raise the feed notification: %s", name, type(e).__name__)
def run_job(sch: Scheduler, name: str, trigger: str) -> dict[str, Any]:
"""Run `name` once and return its job_runs row. Never raises for a job failure; raises when the database
cannot be reached at all (the caller logs it)."""
@@ -129,6 +145,7 @@ def run_job(sch: Scheduler, name: str, trigger: str) -> dict[str, Any]:
# The run's connection is broken: write the result on a fresh one
with sch.connect() as c2:
_finish(c2, run_id, status=status, counts=counts, errors=errors, visual_target=visual_target)
_notify_feed(sch, conn, name, status, errors, run_id)
try:
pending_after = bool(
conn.execute(
+2 -2
View File
@@ -15,7 +15,7 @@ argon2-cffi==25.1.0
apscheduler==3.11.3
httpx==0.28.1
tzdata==2026.5
pypdf==6.21.0
pypdf==6.21.2
python-multipart==0.0.32
pypdfium2==5.14.0
openpyxl==3.1.5
@@ -31,7 +31,7 @@ annotated-doc==0.0.5
annotated-types==0.8.0
anyio==4.15.1
argon2-cffi-bindings==26.1.0
certifi==2026.7.22
certifi==2026.10.19
cffi==2.1.1
charset-normalizer==3.5.2
click==8.5.0
+73
View File
@@ -0,0 +1,73 @@
import { expect, test } from "@playwright/test";
import { api, closeAll } from "./shophelpers";
import type { Json } from "./whhelpers";
import { admin, badSignin, csvFrom, me, openAdminTab, parseCsv, runId } from "./adminhelpers";
// Phase 9 Admin, Access log tab, on the CI stack. The log lists every sign-in attempt and the audit's sign-in and
// session rows for everyone. admin.ann filters it by user (the API's user_id, and the tab's name or account box).
// A failed sign-in typed as "=e2e<run>" puts a formula-looking account in the log; the CSV download of a search for
// it has that cell guarded with a leading apostrophe, so a spreadsheet shows it as text.
const RUN = runId().toLowerCase();
const FORMULA = `=e2e${RUN}`;
test.describe.serial("admin access log", () => {
test.setTimeout(120_000);
test("filter by user: only that user's rows", async ({ browser }) => {
const ann = await admin(browser);
const self = await me(ann);
const out = await api(ann, "get", `/api/admin/access-log?user_id=${encodeURIComponent(String(self.id))}&limit=100`);
const rows = out.rows as Json[];
expect(rows.length).toBeGreaterThan(0);
for (const r of rows) expect(String(r.user_id)).toBe(String(self.id));
// her own password sign-in is there, as an attempt that went through
expect(rows.some((r) => r.source === "attempt" && r.ok === true)).toBe(true);
const page = ann.page;
await openAdminTab(page, "accesslog");
const filters = page.getByRole("search", { name: "Access log filters" });
await filters.getByLabel("Name or account", { exact: true }).fill("admin.ann");
const searched = page.waitForResponse((r) => r.url().includes("/api/admin/access-log?") && r.status() === 200);
await filters.getByRole("button", { name: "Search access log", exact: true }).click();
await searched;
const body = page.getByRole("table", { name: "Access log" }).locator("tbody tr");
await expect(body.first()).toBeVisible();
const texts = await body.allTextContents();
expect(texts.length).toBeGreaterThan(0);
for (const t of texts) expect(t).toMatch(/Ann Admin|admin\.ann/);
await closeAll(ann);
});
test("CSV export: a cell that starts with = is guarded", async ({ browser }) => {
// a failed sign-in with a formula-looking account name, from a browser nobody is signed in on
const ctx = await browser.newContext();
const anon = await ctx.newPage();
expect(await badSignin(anon, FORMULA)).toBe(401);
await ctx.close();
const ann = await admin(browser);
const out = await api(ann, "get", `/api/admin/access-log?q=${encodeURIComponent(FORMULA)}&ok=false`);
expect((out.rows as Json[]).some((r) => r.account === FORMULA && r.ok === false)).toBe(true);
const page = ann.page;
await openAdminTab(page, "accesslog");
const filters = page.getByRole("search", { name: "Access log filters" });
await filters.getByLabel("Name or account", { exact: true }).fill(FORMULA);
await filters.getByLabel("Result", { exact: true }).selectOption("false");
const searched = page.waitForResponse((r) => r.url().includes("/api/admin/access-log?") && r.status() === 200);
await filters.getByRole("button", { name: "Search access log", exact: true }).click();
await searched;
const row = page.getByRole("table", { name: "Access log" }).getByRole("row").filter({ hasText: FORMULA });
await expect(row.first()).toBeVisible();
await expect(row.first()).toContainText("Failed");
const link = filters.getByRole("link", { name: "Download CSV", exact: true });
await expect(link).toHaveAttribute("href", /\/api\/admin\/access-log\.csv\?/);
const text = await csvFrom(page, link);
const cells = parseCsv(text).flat();
expect(cells).toContain(`'${FORMULA}`);
expect(cells.filter((c) => /^[=+\-@]/.test(c) && c !== "-")).toEqual([]);
await closeAll(ann);
});
});
+153
View File
@@ -0,0 +1,153 @@
import { expect, test } from "@playwright/test";
import { api, closeAll } from "./shophelpers";
import type { Json } from "./whhelpers";
import { admin, call, openAdminTab, runId } from "./adminhelpers";
// Phase 9 Admin, Departments tab, on the CI stack. admin.ann adds a shop department with a code and short name
// made for this run and sees the three AD group names IT must create. Then she renames it and a station in the
// edit form; the code and short name have no field there, and the API refuses them in a change (422). A repeated
// code is refused (409). Last, the department is set inactive through the API so it stays out of the other
// scenarios. Existing departments are never changed: other specs read their names and stations.
const RUN = runId();
const CODE = `Z${RUN.slice(-3)}`;
const SLUG = `Ci${RUN}`;
const NAME = `CI Paint ${RUN}`;
const NAME2 = `CI Paint renamed ${RUN}`;
const GROUPS = ["Worker", "Lead", "Supervisor"].map((l) => `PS-${CODE}-${SLUG}-${l}`);
test.describe.serial("admin departments", () => {
test.setTimeout(120_000);
test("the list shows the seeded departments with their groups", async ({ browser }) => {
const ann = await admin(browser);
const page = ann.page;
await openAdminTab(page, "departments");
const table = page.getByRole("table", { name: "Departments" });
await expect(table).toBeVisible();
const weld = table.getByRole("row").filter({ hasText: "Weld Parts" });
await expect(weld).toBeVisible();
await expect(weld).toContainText("03");
await expect(weld).toContainText("PS-03-WeldParts-Worker");
await expect(weld).toContainText("Stall 1");
const list = (await api(ann, "get", "/api/admin/departments")) as unknown as Json[];
const d03 = list.find((d) => d.code === "03");
expect(d03).toBeTruthy();
expect(d03?.slug).toBe("WeldParts");
expect((d03?.groups as Json[]).map((g) => g.name)).toEqual(
expect.arrayContaining(["PS-03-WeldParts-Worker", "PS-03-WeldParts-Lead", "PS-03-WeldParts-Supervisor"]),
);
await closeAll(ann);
});
test("add a department: the three group names IT must create", async ({ browser }) => {
const ann = await admin(browser);
const page = ann.page;
await openAdminTab(page, "departments");
await page.getByRole("button", { name: "New department", exact: true }).click();
const form = page.locator("form").filter({ has: page.getByRole("heading", { name: "Add department" }) });
await expect(form).toBeVisible();
await form.getByLabel("Code", { exact: true }).fill(CODE);
await form.getByLabel("Short name", { exact: true }).fill(SLUG);
await form.getByLabel("Name", { exact: true }).fill(NAME);
await form.getByLabel("Kind", { exact: true }).selectOption("shop");
await form.getByLabel("Stations, one per line").fill("Bay 1\nBay 2");
// the form previews the group names before saving
const preview = form.getByText(/IT will need to create these groups/);
for (const g of GROUPS) await expect(preview).toContainText(g);
const posted = page.waitForResponse(
(r) => r.url().endsWith("/api/admin/departments") && r.request().method() === "POST",
);
await form.getByRole("button", { name: "Add department", exact: true }).click();
const resp = await posted;
expect(resp.status(), await resp.text()).toBe(201);
const out = (await resp.json()) as Json;
expect([...(out.groups_to_create as string[])].sort()).toEqual([...GROUPS].sort());
expect(out.department.code).toBe(CODE);
expect(out.department.slug).toBe(SLUG);
const note = page.getByRole("status", { name: "Groups to create" });
await expect(note).toBeVisible();
for (const g of GROUPS) await expect(note).toContainText(g);
const row = page.getByRole("table", { name: "Departments" }).getByRole("row").filter({ hasText: NAME });
await expect(row).toBeVisible();
await expect(row).toContainText(CODE);
for (const g of GROUPS) await expect(row).toContainText(g);
await expect(row).toContainText("Bay 1, Bay 2");
// the same code again is refused
const again = await call(ann, "post", "/api/admin/departments", {
code: CODE,
slug: `${SLUG}x`,
name: `${NAME} again`,
kind: "shop",
weld: false,
supervisor_group: true,
stations: [],
});
expect(again.status, JSON.stringify(again.body)).toBe(409);
await closeAll(ann);
});
test("edit its name and a station; code and short name are not editable", async ({ browser }) => {
const ann = await admin(browser);
const page = ann.page;
await openAdminTab(page, "departments");
await page.getByRole("button", { name: `Edit department ${CODE}`, exact: true }).click();
const editor = page.locator("form").filter({ has: page.getByRole("heading", { name: `Edit ${CODE} ${NAME}` }) });
await expect(editor).toBeVisible();
// no field for the code or the short name
await expect(editor.getByLabel("Code", { exact: true })).toHaveCount(0);
await expect(editor.getByLabel("Short name", { exact: true })).toHaveCount(0);
await editor.getByLabel("Department name", { exact: true }).fill(NAME2);
await editor.getByLabel("Station 1 name", { exact: true }).fill("Bay 1A");
const patched = page.waitForResponse(
(r) => r.url().endsWith(`/api/admin/departments/${CODE}`) && r.request().method() === "PATCH",
);
await editor.getByRole("button", { name: "Save department", exact: true }).click();
const resp = await patched;
expect(resp.status(), await resp.text()).toBe(200);
await expect(page.getByRole("status").filter({ hasText: "Department saved." })).toBeVisible();
const row = page.getByRole("table", { name: "Departments" }).getByRole("row").filter({ hasText: NAME2 });
await expect(row).toBeVisible();
await expect(row).toContainText("Bay 1A, Bay 2");
const list = (await api(ann, "get", "/api/admin/departments")) as unknown as Json[];
const d = list.find((x) => x.code === CODE);
expect(d?.name).toBe(NAME2);
expect(d?.slug).toBe(SLUG);
expect((d?.stations as Json[]).map((s) => s.name)).toEqual(expect.arrayContaining(["Bay 1A", "Bay 2"]));
expect((d?.stations as Json[]).map((s) => s.name)).not.toContain("Bay 1");
// the API refuses a code or a short name in a change
for (const body of [{ code: "ZZZZ" }, { slug: "Other" }]) {
const r = await call(ann, "patch", `/api/admin/departments/${CODE}`, body);
expect(r.status, `${JSON.stringify(body)}: ${JSON.stringify(r.body)}`).toBe(422);
}
// two stations with one name are refused
const ids = (d?.stations as Json[]).map((s) => Number(s.id));
const dup = await call(ann, "patch", `/api/admin/departments/${CODE}`, {
stations: [
{ id: ids[0], name: "Same", sort: 1, active: true },
{ id: ids[1], name: "Same", sort: 2, active: true },
],
});
expect(dup.status, JSON.stringify(dup.body)).toBe(422);
await closeAll(ann);
});
test("cleanup: the CI department is set inactive", async ({ browser }) => {
const ann = await admin(browser);
const out = await api(ann, "patch", `/api/admin/departments/${CODE}`, { active: false });
expect(out.active).toBe(false);
await openAdminTab(ann.page, "departments");
const row = ann.page.getByRole("table", { name: "Departments" }).getByRole("row").filter({ hasText: NAME2 });
await expect(row).toContainText("Inactive");
await closeAll(ann);
});
});
+254
View File
@@ -0,0 +1,254 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { api, closeAll, type Actor } from "./shophelpers";
import type { Json } from "./whhelpers";
import { admin, call, me, openAdminTab, runId } from "./adminhelpers";
// Phase 9 Admin, Notifications tab, on the CI stack (NOTIFY_DRY_RUN=true, SMTP_HOST empty, no relay). Every event
// type has a rule seeded with the PS-Admins group. admin.ann opens "Change order waiting", switches it to the daily
// digest and back, adds a mailbox on the CI domain and excludes herself, which the API refuses on a security event
// type (422). A test send to an address on the CI domain always goes out, even in dry run; with no relay it fails
// with "Mail is off", and the sent log shows it as a failed test. The rule is put back as it was at the end.
const RUN = runId().toLowerCase();
const EVENT = "co_waiting";
const LABEL = "Change order waiting";
const MAILBOX = `e2e-${RUN}@pstest.local`;
const TEST_TO = "[email protected]";
const EVENT_TYPES = [
"security_alert",
"signin_burst",
"integrity_failure",
"feed_failed",
"feed_degraded",
"drawing_failing",
"ad_reconcile_report",
"group_missing",
"backup_stale",
"co_waiting",
"qc_reject",
"version_deployed",
"assets_quota",
];
let ORIGINAL: Json | null = null;
async function rules(a: Actor): Promise<Json[]> {
return (await api(a, "get", "/api/admin/notifications")).rules as Json[];
}
async function rule(a: Actor, eventType: string): Promise<Json> {
const r = (await rules(a)).find((x) => x.event_type === eventType);
if (!r) throw new Error(`no rule ${eventType}`);
return r;
}
/** The PUT body that writes `r` back as it is, with changes on top. */
function putBody(r: Json, over: Json = {}): Json {
return {
enabled: r.enabled,
delivery: r.delivery,
digest_time: r.digest_time,
param: r.param ? r.param.value : null,
recipients: (r.recipients as Json[]).map((x) => {
if (x.kind === "group") return { kind: "group", group_dn: x.group_dn, group_name: x.group_name };
if (x.kind === "user") return { kind: "user", user_id: x.user_id };
if (x.kind === "mailbox") return { kind: "mailbox", address: x.address };
return { kind: x.kind };
}),
exclusions: (r.exclusions as Json[]).filter((x) => !x.self).map((x) => x.user_id),
version: r.version,
...over,
};
}
function rulesTable(page: Page): Locator {
return page.getByRole("table", { name: "Notification rules" });
}
function ruleRow(page: Page, label: string): Locator {
return rulesTable(page)
.getByRole("row")
.filter({ has: page.getByRole("button", { name: `Edit ${label}`, exact: true }) });
}
/** Open a rule's editor from the table. */
async function editRule(page: Page, label: string): Promise<Locator> {
await page.getByRole("button", { name: `Edit ${label}`, exact: true }).click();
const editor = page.locator("section").filter({ has: page.getByRole("heading", { name: label, exact: true }) });
await expect(editor).toBeVisible();
return editor;
}
async function saveRule(page: Page, editor: Locator, eventType: string): Promise<Json> {
const put = page.waitForResponse(
(r) => r.url().endsWith(`/api/admin/notifications/${eventType}`) && r.request().method() === "PUT",
);
await editor.getByRole("button", { name: "Save rule", exact: true }).click();
const resp = await put;
expect(resp.status(), await resp.text()).toBe(200);
return (await resp.json()) as Json;
}
test.describe.serial("admin notifications", () => {
test.setTimeout(150_000);
test.afterAll(async ({ browser }) => {
if (!ORIGINAL) return;
const ann = await admin(browser);
const now = await rule(ann, EVENT);
await api(ann, "put", `/api/admin/notifications/${EVENT}`, putBody(ORIGINAL, { version: now.version }));
await closeAll(ann);
});
test("every event type has a rule, seeded with PS-Admins", async ({ browser }) => {
const ann = await admin(browser);
const out = await api(ann, "get", "/api/admin/notifications");
expect(out.dry_run).toBe(true);
const list = out.rules as Json[];
expect(list.map((r) => r.event_type)).toEqual(expect.arrayContaining(EVENT_TYPES));
for (const r of list) {
const admins = (r.recipients as Json[]).find((x) => x.kind === "group" && x.group_name === "PS-Admins");
expect(admins, `${r.event_type} has PS-Admins`).toBeTruthy();
expect(String(admins?.group_dn)).toMatch(/^CN=PS-Admins,OU=PacketStation,OU=Groups,/);
}
const security = list.filter((r) => r.security).map((r) => r.event_type);
expect(security.sort()).toEqual(["integrity_failure", "security_alert", "signin_burst"]);
ORIGINAL = await rule(ann, EVENT);
// the group search reads the CI directory
const groups = (await api(ann, "get", "/api/admin/notifications/groups?q=PS-Adm")) as unknown as Json[];
expect(groups.map((g) => g.name)).toContain("PS-Admins");
const page = ann.page;
await openAdminTab(page, "notifications");
await expect(page.getByRole("group", { name: "Mail settings" })).toContainText("Dry run");
await expect(rulesTable(page)).toBeVisible();
for (const label of ["Security alert", "Sign-in burst", LABEL, "Feed run failed"]) {
await expect(ruleRow(page, label)).toContainText("Group PS-Admins");
}
await expect(ruleRow(page, "Security alert")).toContainText("Security");
await closeAll(ann);
});
test("delivery: daily digest and back to at once", async ({ browser }) => {
const ann = await admin(browser);
const page = ann.page;
await openAdminTab(page, "notifications");
let editor = await editRule(page, LABEL);
await editor.getByLabel("Delivery", { exact: true }).selectOption("digest");
await expect(editor.getByLabel("Digest time", { exact: true })).toBeEnabled();
await editor.getByLabel("Digest time", { exact: true }).fill("07:30");
let saved = await saveRule(page, editor, EVENT);
expect(saved.delivery).toBe("digest");
expect(saved.digest_time).toBe("07:30");
await expect(page.getByRole("status").filter({ hasText: `${LABEL}: saved.` })).toBeVisible();
await expect(ruleRow(page, LABEL)).toContainText("Daily digest at 07:30");
expect((await rule(ann, EVENT)).delivery).toBe("digest");
editor = page.locator("section").filter({ has: page.getByRole("heading", { name: LABEL, exact: true }) });
await editor.getByLabel("Delivery", { exact: true }).selectOption("immediate");
saved = await saveRule(page, editor, EVENT);
expect(saved.delivery).toBe("immediate");
await expect(ruleRow(page, LABEL)).toContainText("At once");
expect((await rule(ann, EVENT)).delivery).toBe("immediate");
// a stale version is refused
const old = putBody(saved, { version: Number(saved.version) - 1 });
const stale = await call(ann, "put", `/api/admin/notifications/${EVENT}`, old);
expect(stale.status, JSON.stringify(stale.body)).toBe(409);
await closeAll(ann);
});
test("a mailbox recipient and an exclusion; no exclusion on a security event", async ({ browser }) => {
const ann = await admin(browser);
const self = await me(ann);
const page = ann.page;
await openAdminTab(page, "notifications");
const editor = await editRule(page, LABEL);
await editor.getByLabel("Add a recipient", { exact: true }).selectOption("mailbox");
await editor.getByLabel("Mailbox address", { exact: true }).fill(MAILBOX);
await editor.getByRole("button", { name: "Add mailbox", exact: true }).click();
await expect(editor.getByRole("list", { name: "Recipients" })).toContainText(`Mailbox ${MAILBOX}`);
await editor.getByLabel("Find a user to exclude", { exact: true }).fill("admin.ann");
await editor.getByRole("button", { name: `Pick ${self.display_name}`, exact: true }).click();
await expect(editor.getByRole("list", { name: "Exclusions" })).toContainText(String(self.display_name));
const saved = await saveRule(page, editor, EVENT);
expect((saved.recipients as Json[]).some((r) => r.kind === "mailbox" && r.address === MAILBOX)).toBe(true);
expect((saved.recipients as Json[]).some((r) => r.group_name === "PS-Admins")).toBe(true);
expect((saved.exclusions as Json[]).map((x) => String(x.user_id))).toContain(String(self.id));
await expect(ruleRow(page, LABEL)).toContainText(`Mailbox ${MAILBOX}`);
// a bad address is refused by the API
const cur = await rule(ann, EVENT);
const badAddr = await call(
ann,
"put",
`/api/admin/notifications/${EVENT}`,
putBody(cur, { recipients: [...putBody(cur).recipients, { kind: "mailbox", address: "not an address" }] }),
);
expect(badAddr.status, JSON.stringify(badAddr.body)).toBe(422);
// security event types take no exclusion: the editor says so and the API refuses one
const sec = await rule(ann, "security_alert");
const refused = await call(
ann,
"put",
"/api/admin/notifications/security_alert",
putBody(sec, { exclusions: [String(self.id)] }),
);
expect(refused.status, JSON.stringify(refused.body)).toBe(422);
expect((await rule(ann, "security_alert")).exclusions).toEqual([]);
await page.getByRole("button", { name: "Edit Security alert", exact: true }).click();
const secHead = page.getByRole("heading", { name: "Security alert", exact: true });
const secEditor = page.locator("section").filter({ has: secHead });
await expect(secEditor.getByText("Nobody can be excluded or opt out.", { exact: false })).toBeVisible();
await expect(secEditor.getByLabel("Find a user to exclude", { exact: true })).toHaveCount(0);
await closeAll(ann);
});
test("a test send to a CI address fails with Mail is off and shows in the sent log", async ({ browser }) => {
const ann = await admin(browser);
const page = ann.page;
await openAdminTab(page, "notifications");
const editor = await editRule(page, LABEL);
await editor.getByLabel("Send the test to", { exact: true }).fill(TEST_TO);
const sent = page.waitForResponse(
(r) => r.url().endsWith(`/api/admin/notifications/${EVENT}/test`) && r.request().method() === "POST",
);
await editor.getByRole("button", { name: "Send a test", exact: true }).click();
const resp = await sent;
expect(resp.status(), await resp.text()).toBe(200);
const out = (await resp.json()) as Json;
// a test always goes out, even in dry run; with no relay in CI it fails
expect(out.result).toBe("failed");
expect(String(out.error)).toContain("Mail is off");
const result = editor.getByRole("status", { name: "Test result" });
await expect(result).toContainText("failed");
await expect(result).toContainText("Mail is off");
await expect(result).toContainText(TEST_TO);
// the sent log, through the API and in the tab
const log = await api(ann, "get", `/api/admin/notifications/sent?event_type=${EVENT}&limit=5`);
const row = (log.rows as Json[])[0];
expect(row.test).toBe(true);
expect(row.result).toBe("failed");
expect(String(row.error)).toContain("Mail is off");
expect((row.recipients as Json[]).map((r) => r.address)).toEqual([TEST_TO]);
const table = page.getByRole("table", { name: "Sent log" });
const line = table.getByRole("row").filter({ hasText: LABEL }).filter({ hasText: "Mail is off" }).first();
await expect(line).toBeVisible();
await expect(line).toContainText("Test");
await expect(line).toContainText("failed");
await expect(line).toContainText(TEST_TO);
// a test to an address that is not one is refused by the API
const bad = await call(ann, "post", `/api/admin/notifications/${EVENT}/test`, { to: "nobody" });
expect([400, 422], JSON.stringify(bad.body)).toContain(bad.status);
await closeAll(ann);
});
});
+53
View File
@@ -0,0 +1,53 @@
import { expect, test } from "@playwright/test";
import { dcUser } from "./helpers";
import { actor, api, closeAll } from "./shophelpers";
import type { Json } from "./whhelpers";
import { admin, me, openAdminTab } from "./adminhelpers";
// Phase 9 Admin, Who is in tab, on the CI stack. clerk.clara signs in and opens Clerical; admin.ann sees both live
// sessions in the list, with a password sign-in and Active. When clara signs out through the API her row is gone.
test.describe.serial("admin who is in", () => {
test.setTimeout(90_000);
test("live sessions are listed; a signed-out one drops out", async ({ browser }) => {
const clara = await actor(browser, "clerk.clara");
const claraName = dcUser("clerk.clara").display_name;
const claraSession = String((await api(clara, "get", "/api/auth/me")).session.id);
const ann = await admin(browser);
const annName = (await me(ann)).display_name as string;
const out = await api(ann, "get", "/api/admin/presence");
const sessions = out.sessions as Json[];
const mine = sessions.find((s) => s.display_name === annName);
expect(mine?.kind).toBe("password");
expect(mine?.locked).toBe(false);
expect(sessions.some((s) => String(s.session_id) === claraSession)).toBe(true);
const page = ann.page;
await openAdminTab(page, "presence");
const table = page.getByRole("table", { name: "Who is in" });
const annRow = table.getByRole("row").filter({ hasText: annName }).first();
await expect(annRow).toBeVisible();
await expect(annRow).toContainText("Password");
await expect(annRow).toContainText("Active");
await expect(table.getByRole("row").filter({ hasText: claraName }).first()).toBeVisible();
// clara signs out: her session leaves the list
await api(clara, "post", "/api/auth/signout", undefined, { status: 204 });
await expect
.poll(async () =>
((await api(ann, "get", "/api/admin/presence")).sessions as Json[]).some(
(s) => String(s.session_id) === claraSession,
),
)
.toBe(false);
await page.getByRole("button", { name: "Refresh who is in", exact: true }).click();
await expect
.poll(async () =>
(await table.getByRole("row").allTextContents()).filter((t) => t.includes(claraName)).length,
)
.toBeLessThan(sessions.filter((s) => s.display_name === claraName).length);
await closeAll(ann, clara);
});
});
+154
View File
@@ -0,0 +1,154 @@
import { expect, test } from "@playwright/test";
import { api, closeAll } from "./shophelpers";
import type { Json } from "./whhelpers";
import { admin, badSignin, call, openAdminTab } from "./adminhelpers";
// Phase 9 Admin, Security tab, on the CI stack. admin.ann lowers the sign-in burst threshold to its floor (5) in the
// settings form; a value under the floor is refused by the form and by the API (422). Then a fresh browser makes
// six wrong-password sign-ins for dup.dina, a CI account no other scenario signs in with: the account cooldown
// starts after five, and five failures in five minutes meet the threshold. The alerts list shows the cooldown and
// the burst. The integrity panel renders after a requested integrity run. The threshold is put back at the end.
const KEY = "signin_burst_threshold";
const LABEL = "Failed sign-ins across accounts in 5 minutes that raise an alert";
const TARGET = "dup.dina";
let ORIGINAL: number | null = null;
async function settings(a: Parameters<typeof api>[0]): Promise<Json> {
return api(a, "get", "/api/admin/security/settings");
}
test.describe.serial("admin security", () => {
test.setTimeout(180_000);
test.afterAll(async ({ browser }) => {
if (ORIGINAL === null) return;
const ann = await admin(browser);
const s = await settings(ann);
if (s.values[KEY] !== ORIGINAL) {
await api(ann, "put", "/api/admin/security/settings", { values: { [KEY]: ORIGINAL }, version: s.version });
}
await closeAll(ann);
});
test("a setting changes within its bounds; a value out of bounds is refused", async ({ browser }) => {
const ann = await admin(browser);
const page = ann.page;
let s = await settings(ann);
expect(s.bounds[KEY]).toEqual([5, 200]);
ORIGINAL = Number(s.values[KEY]);
if (ORIGINAL === 5) {
// a rerun after a failed one: start from the default so the form has a change to save
ORIGINAL = Number(s.defaults[KEY] ?? 20);
s = await api(ann, "put", "/api/admin/security/settings", { values: { [KEY]: ORIGINAL }, version: s.version });
}
// the API refuses a value under the floor and one over the ceiling
for (const v of [1, 201]) {
const r = await call(ann, "put", "/api/admin/security/settings", { values: { [KEY]: v }, version: s.version });
expect(r.status, `${v}: ${JSON.stringify(r.body)}`).toBe(422);
}
expect((await settings(ann)).values[KEY]).toBe(ORIGINAL);
await openAdminTab(page, "security");
const form = page.locator("form").filter({ has: page.getByRole("heading", { name: "Settings", exact: true }) });
const input = form.getByLabel(LABEL, { exact: true });
await expect(input).toHaveValue(String(ORIGINAL));
await expect(form.getByText("Allowed 5 to 200.").first()).toBeVisible();
// out of bounds: the form refuses it and nothing is sent
let puts = 0;
page.on("request", (r) => {
if (r.url().endsWith("/api/admin/security/settings") && r.method() === "PUT") puts++;
});
await input.fill("1");
await form.getByRole("button", { name: "Save security settings", exact: true }).click();
await expect(form.getByRole("alert")).toContainText("give a whole number from 5 to 200");
expect(puts).toBe(0);
// within bounds: saved
await input.fill("5");
const saved = page.waitForResponse(
(r) => r.url().endsWith("/api/admin/security/settings") && r.request().method() === "PUT",
);
await form.getByRole("button", { name: "Save security settings", exact: true }).click();
expect((await saved).status()).toBe(200);
await expect(form.getByRole("status")).toContainText("Security settings saved.");
expect((await settings(ann)).values[KEY]).toBe(5);
await closeAll(ann);
});
test("wrong passwords for one account: the alerts list shows the cooldown and the burst", async ({ browser }) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
const statuses: number[] = [];
for (let i = 0; i < 6; i++) statuses.push(await badSignin(page, TARGET));
// five wrong passwords reach the directory; the sixth is refused by the cooldown
expect(statuses.slice(0, 5)).toEqual([401, 401, 401, 401, 401]);
expect(statuses[5]).toBe(429);
await ctx.close();
const ann = await admin(browser);
const hit = (rows: Json[], kind: string, account?: string) =>
rows.some((r) => r.kind === kind && (account === undefined || String(r.account ?? "").toLowerCase() === account));
await expect
.poll(
async () => {
const rows = (await api(ann, "get", "/api/admin/security/alerts?hours=1")) as unknown as Json[];
return { lockout: hit(rows, "lockout", TARGET), burst: hit(rows, "signin_burst") };
},
{ timeout: 20_000 },
)
.toEqual({ lockout: true, burst: true });
const page2 = ann.page;
await openAdminTab(page2, "security");
const table = page2.getByRole("table", { name: "Security alerts" });
await expect(table).toBeVisible();
const cooldown = table.getByRole("row").filter({ hasText: "Account cooldowns" }).filter({ hasText: TARGET });
await expect(cooldown.first()).toBeVisible();
await expect(table.getByRole("row").filter({ hasText: "Sign-in bursts" }).first()).toBeVisible();
// the kind chips filter the list
const chips = page2.getByRole("group", { name: "Alert kinds" });
const burstChip = chips.getByRole("button", { name: /^Sign-in bursts/ });
await expect(burstChip).toHaveAttribute("aria-pressed", "true");
await burstChip.click();
await expect(burstChip).toHaveAttribute("aria-pressed", "false");
await expect(table.getByRole("row").filter({ hasText: "Sign-in bursts" })).toHaveCount(0);
await burstChip.click();
await expect(table.getByRole("row").filter({ hasText: "Sign-in bursts" }).first()).toBeVisible();
await closeAll(ann);
});
test("the integrity panel renders the newest run", async ({ browser }) => {
const ann = await admin(browser);
// ask the worker for a run now; the nightly schedule has not come round in CI
const req = await call(ann, "post", "/api/jobs/integrity/run", { note: "e2e" });
if (req.status === 200) {
await expect
.poll(async () => Boolean((await api(ann, "get", "/api/admin/security/integrity")).run), { timeout: 90_000 })
.toBe(true);
}
const out = await api(ann, "get", "/api/admin/security/integrity");
expect(Array.isArray(out.findings)).toBe(true);
const page = ann.page;
await openAdminTab(page, "security");
const panel = page.locator("section").filter({ has: page.getByRole("heading", { name: "Integrity check" }) });
await expect(panel).toBeVisible();
if (out.run) {
await expect(panel.getByText(/^Finished /)).toBeVisible();
const findings = (out.findings as Json[]).length;
if (findings) {
await expect(panel.getByText(new RegExp(`^${findings} findings?$`))).toBeVisible();
await expect(panel.getByRole("table", { name: "Integrity findings" })).toBeVisible();
} else {
await expect(panel.getByText(/^(Clean|Failed)$/)).toBeVisible();
}
} else {
await expect(panel.getByText("The integrity check has not run yet.", { exact: false })).toBeVisible();
}
await closeAll(ann);
});
});
+102
View File
@@ -0,0 +1,102 @@
import { expect, test } from "@playwright/test";
import { dcUser } from "./helpers";
import { actor, api, closeAll } from "./shophelpers";
import { makeDraft, makeProject, makeUnit, runSuffix, type Made } from "./pmhelpers";
import type { Json } from "./whhelpers";
import { admin, call, openAdminTab, sigOf, stampLines, tampered } from "./adminhelpers";
// Phase 9 Admin, Stamp check tab, on the CI stack. clerk.clara makes a bare draft and exports its job traveler, a
// controlled copy, as the close spec does; the print log records it with the stamp signature. The stamp line is read
// out of the PDF's own text. admin.ann pastes it in the Stamp check tab: Genuine, with who printed what. The same
// line with one character of the code changed does not check out. The PDF itself, uploaded, checks out too.
let RUN = "";
let P: Made;
let PDF: Buffer;
let LINE = "";
test.describe.serial("admin stamp check", () => {
test.setTimeout(120_000);
test("setup: clara exports a traveler; its stamp line is in the PDF", async ({ browser }, info) => {
RUN = runSuffix(info.project.name);
const clara = await actor(browser, "clerk.clara");
const proj = await makeProject(clara, `SV${RUN}`, { name: "Stamp check job" });
const unit = await makeUnit(clara, Number(proj.id), "001");
P = await makeDraft(clara, unit, `Stamp check ${RUN}`);
const r = await clara.page.request.get(`/api/packets/${P.id}/pdf/traveler`);
expect(r.status(), await r.text()).toBe(200);
expect(r.headers()["content-type"]).toContain("application/pdf");
PDF = await r.body();
const lines = stampLines(PDF);
expect(lines.length, "a signed stamp line in the traveler PDF").toBeGreaterThan(0);
LINE = lines[0];
expect(LINE).toContain(dcUser("clerk.clara").display_name);
const prints = ((await api(clara, "get", `/api/packets/${P.id}/prints`)).prints ?? []) as Json[];
expect(prints[0]?.kind).toBe("traveler");
await closeAll(clara);
});
test("the stamp line checks out; a tampered one does not", async ({ browser }) => {
const ann = await admin(browser);
// through the API
const ok = await api(ann, "post", "/api/admin/stamp-verify", { text: LINE });
expect(ok.found).toBe(true);
expect(ok.valid).toBe(true);
expect(ok.print.packet_id).toBe(P.id);
expect(ok.print.kind).toBe("traveler");
expect(ok.print.by_name).toBe(dcUser("clerk.clara").display_name);
const hexOnly = await api(ann, "post", "/api/admin/stamp-verify", { text: sigOf(LINE) });
expect(hexOnly.found).toBe(true);
const bad = await api(ann, "post", "/api/admin/stamp-verify", { text: tampered(LINE) });
expect(bad.valid).toBe(false);
const empty = await call(ann, "post", "/api/admin/stamp-verify", { text: "no stamp here" });
expect([200, 422]).toContain(empty.status);
if (empty.status === 200) expect(empty.body.valid).toBe(false);
// through the tab
const page = ann.page;
await openAdminTab(page, "stampverify");
const form = page.locator("form").filter({ has: page.getByRole("heading", { name: "Check a stamp line" }) });
const box = form.getByLabel("Stamp line or code", { exact: true });
await box.fill(LINE);
await form.getByRole("button", { name: "Check stamp", exact: true }).click();
const result = form.getByRole("status", { name: "Stamp result" });
await expect(result).toContainText("Genuine.");
await expect(result).toContainText("Job traveler");
await expect(result).toContainText(dcUser("clerk.clara").display_name);
await box.fill(tampered(LINE));
await form.getByRole("button", { name: "Check stamp", exact: true }).click();
await expect(result).toContainText(/Not found\.|Does not verify\./);
await expect(result).not.toContainText("Genuine.");
await closeAll(ann);
});
test("the exported PDF uploaded: its stamp checks out", async ({ browser }) => {
const ann = await admin(browser);
const r = await ann.page.request.post("/api/admin/stamp-verify/pdf", {
headers: { Origin: new URL(ann.page.url()).origin, "X-Requested-With": "PacketStation" },
multipart: { file: { name: `traveler-${P.id}.pdf`, mimeType: "application/pdf", buffer: PDF } },
});
expect(r.status(), await r.text()).toBe(200);
const stamps = ((await r.json()) as Json).stamps as Json[];
expect(stamps.length).toBeGreaterThan(0);
expect(stamps.some((s) => s.valid === true && s.print?.packet_id === P.id)).toBe(true);
const page = ann.page;
await openAdminTab(page, "stampverify");
const form = page.locator("form").filter({ has: page.getByRole("heading", { name: "Check a PDF" }) });
await form
.getByLabel("PDF of a printed copy", { exact: true })
.setInputFiles({ name: `traveler-${P.id}.pdf`, mimeType: "application/pdf", buffer: PDF });
await form.getByRole("button", { name: "Check PDF", exact: true }).click();
const results = form.getByRole("status", { name: "PDF stamp results" });
await expect(results).toContainText("Stamp 1: Genuine.");
await expect(results).toContainText("Job traveler");
await closeAll(ann);
});
});
+236
View File
@@ -0,0 +1,236 @@
import { inflateSync, constants as zc } from "node:zlib";
import { expect, type Browser, type Locator, type Page } from "@playwright/test";
import { readFileSync } from "node:fs";
import { actor, type Actor } from "./shophelpers";
import { getJson, writeHeaders, type Json } from "./whhelpers";
// Helpers for the phase 9 Admin scenarios (Departments, Notifications, Security, Access log, Stamp check, Who is
// in): the Admin tabs by their ids, the admin's own user, API calls through the page's session, a CSV caught from a
// link or a download, and the stamp line read out of a PDF's content streams. The tab labels are in A (the phase 9
// design contract). The labels inside each tab are the texts of agent D's components (frontend/src/screens/admin/*)
// and sit in the specs next to their use.
export const A = {
tabs: {
departments: "Departments",
notifications: "Notifications",
security: "Security",
accesslog: "Access log",
stampverify: "Stamp check",
presence: "Who is in",
},
} as const;
/** The run suffix: five base-36 characters, upper case. */
export function runId(): string {
return Date.now().toString(36).toUpperCase().slice(-5);
}
/** admin.ann in a fresh browser context, signed in with her password. */
export function admin(browser: Browser): Promise<Actor> {
return actor(browser, "admin.ann");
}
/** The signed-in user (GET /api/auth/me). */
export async function me(a: Actor): Promise<Json> {
return (await getJson(a.page.request, "/api/auth/me")).user as Json;
}
/** An Admin tab button by its id (admtab-<id>), falling back to the visible label. */
export function adminTab(page: Page, id: keyof typeof A.tabs): Locator {
return page
.locator(`#admtab-${id}`)
.or(page.getByRole("tab", { name: A.tabs[id], exact: true }))
.first();
}
/** Open /admin on the tab and wait for it to be selected. Returns the tab panel. */
export async function openAdminTab(page: Page, id: keyof typeof A.tabs): Promise<Locator> {
await page.goto("/admin");
const t = adminTab(page, id);
await expect(t).toBeVisible();
await t.click();
await expect(t).toHaveAttribute("aria-selected", "true");
const panel = page.getByRole("tabpanel").first();
await expect(panel).toBeVisible();
return panel;
}
/** A raw API call that returns the status and the parsed body, whatever the status. */
export async function call(
a: Actor,
method: "get" | "post" | "patch" | "put" | "delete",
url: string,
data?: Json,
): Promise<{ status: number; body: Json }> {
const r = await a.page.request.fetch(url, {
method: method.toUpperCase(),
headers: writeHeaders(a.page),
...(data !== undefined ? { data } : {}),
});
const text = await r.text();
let body: Json = {};
try {
body = text ? (JSON.parse(text) as Json) : {};
} catch {
body = { text };
}
return { status: r.status(), body };
}
/** A failed password sign-in through the API from a page on /signin. Returns the status. */
export async function badSignin(page: Page, login: string): Promise<number> {
if (!page.url().includes("/signin")) await page.goto("/signin");
const r = await page.request.post("/api/auth/signin/password", {
headers: writeHeaders(page),
data: { login, password: `wrong-${Date.now()}` },
});
return r.status();
}
/**
* The CSV a control hands out: a link is fetched through the page's session; a button must start a download.
* Returns the text without a BOM.
*/
export async function csvFrom(page: Page, target: Locator): Promise<string> {
await expect(target).toBeVisible();
const href = await target.getAttribute("href");
let text: string;
if (href && !href.startsWith("blob:") && !href.startsWith("data:")) {
const r = await page.request.get(new URL(href, page.url()).toString());
expect(r.status(), `GET ${href}`).toBe(200);
expect(r.headers()["content-type"]).toContain("text/csv");
text = await r.text();
} else {
const [dl] = await Promise.all([page.waitForEvent("download"), target.click()]);
const path = await dl.path();
if (!path) throw new Error("download has no path");
text = readFileSync(path, "utf-8");
}
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
}
/** Split CSV text into rows of cells (RFC 4180 quoting). */
export function parseCsv(text: string): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let cell = "";
let quoted = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (quoted) {
if (c === '"' && text[i + 1] === '"') {
cell += '"';
i++;
} else if (c === '"') quoted = false;
else cell += c;
} else if (c === '"') quoted = true;
else if (c === ",") {
row.push(cell);
cell = "";
} else if (c === "\n" || c === "\r") {
if (c === "\r" && text[i + 1] === "\n") i++;
row.push(cell);
rows.push(row);
row = [];
cell = "";
} else cell += c;
}
if (cell !== "" || row.length) {
row.push(cell);
rows.push(row);
}
return rows;
}
// ---------------------------------------------------------------- PDF text
function ascii85(data: string): Buffer {
let s = data.replace(/\s+/g, "");
if (s.startsWith("<~")) s = s.slice(2);
const end = s.indexOf("~>");
if (end >= 0) s = s.slice(0, end);
const out: number[] = [];
let group: number[] = [];
for (const ch of s) {
if (ch === "z" && group.length === 0) {
out.push(0, 0, 0, 0);
continue;
}
group.push(ch.charCodeAt(0) - 33);
if (group.length === 5) {
let v = 0;
for (const g of group) v = v * 85 + g;
out.push((v >>> 24) & 255, (v >>> 16) & 255, (v >>> 8) & 255, v & 255);
group = [];
}
}
if (group.length) {
const n = group.length;
while (group.length < 5) group.push(84);
let v = 0;
for (const g of group) v = v * 85 + g;
const bytes = [(v >>> 24) & 255, (v >>> 16) & 255, (v >>> 8) & 255, v & 255];
out.push(...bytes.slice(0, n - 1));
}
return Buffer.from(out);
}
/** Every content stream of a PDF decoded (ASCII85 and Flate), as latin1 text, plus the raw file. */
export function pdfStreams(pdf: Buffer): string[] {
const raw = pdf.toString("latin1");
const out: string[] = [raw];
const re = /stream\r?\n/g;
let m: RegExpExecArray | null;
while ((m = re.exec(raw)) !== null) {
if (raw.slice(Math.max(0, m.index - 3), m.index) === "end") continue;
const start = m.index + m[0].length;
const stop = raw.indexOf("endstream", start);
if (stop < 0) break;
const dict = raw.slice(Math.max(0, m.index - 400), m.index);
const dictStart = dict.lastIndexOf("obj");
const head = dictStart >= 0 ? dict.slice(dictStart) : dict;
let body: Buffer = Buffer.from(raw.slice(start, stop).replace(/\r?\n$/, ""), "latin1");
try {
if (head.includes("/ASCII85Decode") || head.includes("/A85")) body = ascii85(body.toString("latin1"));
if (head.includes("/FlateDecode") || head.includes("/Fl")) {
body = inflateSync(body, { finishFlush: zc.Z_SYNC_FLUSH });
}
out.push(body.toString("latin1"));
} catch {
// not a stream we can read (an image, a font); skip it
}
re.lastIndex = stop;
}
return out;
}
/** The signed stamp lines in a PDF ("... - stamp <16 hex>"), de-duplicated, PDF string escapes undone. */
export function stampLines(pdf: Buffer): string[] {
const found = new Set<string>();
for (const s of pdfStreams(pdf)) {
// PDF string escapes: octal (\055 is "-", \050 "("), then \( \) \\ and the rest as the character itself
const text = s.replace(/\\([0-7]{1,3}|[\s\S])/g, (_, e: string) =>
/^[0-7]+$/.test(e) ? String.fromCharCode(parseInt(e, 8)) : e,
);
for (const m of text.matchAll(/(?:Controlled copy|PRINTED COPY)[^\r\n]*? - stamp [0-9a-f]{16}/g)) {
found.add(m[0]);
}
}
return [...found];
}
/** The 16 hex of a stamp line. */
export function sigOf(line: string): string {
const m = /stamp ([0-9a-f]{16})\s*$/.exec(line);
if (!m) throw new Error(`no stamp signature in ${line}`);
return m[1];
}
/** The same line with the last hex character of the signature changed. */
export function tampered(line: string): string {
const sig = sigOf(line);
const last = sig.charAt(15);
const swapped = sig.slice(0, 15) + (last === "0" ? "1" : "0");
return line.replace(sig, swapped);
}
+5 -2
View File
@@ -122,9 +122,12 @@ export function runSuffix(projectName: string): string {
return projectName === "chromium" ? base : `${base}${projectName.slice(0, 1).toUpperCase()}`;
}
/** Today plus `days`, as YYYY-MM-DD (UTC calendar; the specs only need a day or more of margin). */
/** Today plus `days`, as YYYY-MM-DD in the site's calendar (America/Denver), as the server counts due dates.
* The UTC calendar is a day ahead every evening after 18:00 Denver time, which made "due yesterday" today. */
export function isoDay(days: number): string {
return new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10);
return new Intl.DateTimeFormat("en-CA", { timeZone: "America/Denver" }).format(
new Date(Date.now() + days * 86_400_000),
);
}
export interface Unit {
+23 -5
View File
@@ -120,11 +120,29 @@ test.describe.serial("device, PIN and admin", () => {
);
await page.getByRole("button", { name: /refresh from ad now/i }).click();
const resp = await refreshed;
expect(resp.ok()).toBe(true);
const counts = (await resp.json()) as { users_seen: number; emp_duplicate: string[] };
expect(counts.users_seen).toBeGreaterThanOrEqual(15);
expect(counts.emp_duplicate).toContain("9001");
await expect(page.getByText(/9001/).first()).toBeVisible();
// Phase 9: the button asks the worker for an ad-reconcile run (202); the report follows.
expect(resp.status()).toBe(202);
const requestedAt = Date.now();
await expect
.poll(
async () => {
const r = await page.request.get("/api/admin/ad/reconcile");
if (!r.ok()) return null;
const body = (await r.json()) as {
run: { finished_at: string | null } | null;
report: { users_seen: number; emp_duplicate: string[] } | null;
};
const done = body.run?.finished_at ? Date.parse(body.run.finished_at) : 0;
return done >= requestedAt - 5000 ? body.report : null;
},
{ timeout: 90_000, intervals: [1000, 2000, 3000] },
)
.toMatchObject({ users_seen: expect.any(Number) });
const rep = (await (await page.request.get("/api/admin/ad/reconcile")).json()) as {
report: { users_seen: number; emp_duplicate: string[] };
};
expect(rep.report.users_seen).toBeGreaterThanOrEqual(15);
expect(rep.report.emp_duplicate).toContain("9001");
await tab(page, /devices/i).click();
await expect(page.getByText("CI tablet").first()).toBeVisible();
+50
View File
@@ -10,6 +10,9 @@ from packetstation.auth.ldap import AdUser, LdapUnavailable
FAKE_PASSWORD = "fake-password-1"
NESTED_GROUP = "Shop-Welders-Nested"
BASE_DN = "DC=pstest,DC=local"
OU_DN = "OU=PacketStation,OU=Groups,OU=PESCO_FARMINGTON," + BASE_DN
OTHER_DN = "OU=Groups,OU=PESCO_FARMINGTON," + BASE_DN
@dataclass
@@ -62,12 +65,19 @@ class FakeDirectory:
users: list[FakeUser] | None = None,
ou_groups: list[str] | None = None,
nested: dict[str, list[str]] | None = None,
other_groups: dict[str, str] | None = None,
):
self.users = users if users is not None else default_users()
if ou_groups is None:
ou_groups = [g for g in group_names() if g != "PS-14-Electrical-Lead"] + ["PS-Extra-Legacy"]
self.ou_groups = ou_groups
self.nested = nested if nested is not None else {NESTED_GROUP: ["PS-10-WeldAssembly-Worker"]}
# Groups outside the OU, by name, with a description (the nested helper group and a mail group)
self.other_groups = (
other_groups
if other_groups is not None
else {NESTED_GROUP: "Helper group for the nested membership test", "IT-Helpdesk": "IT help desk"}
)
self.down = False
self.binds = 0
self.searches = 0
@@ -96,6 +106,18 @@ class FakeDirectory:
todo.extend(self.nested.get(g, []))
return seen
def group_dn(self, name: str) -> str:
"""The DN of a group: in the PacketStation OU, or under Groups for the ones outside it."""
if any(g.lower() == name.lower() for g in self.ou_groups):
return f"CN={name},{OU_DN}"
return f"CN={name},{OTHER_DN}"
def _group_by_dn(self, dn: str) -> str | None:
for name in [*self.ou_groups, *self.other_groups]:
if self.group_dn(name).lower() == dn.strip().lower():
return name
return None
def _in_ou(self, names: set[str]) -> list[str]:
return sorted(g for g in self.ou_groups if g.lower() in names)
@@ -138,3 +160,31 @@ class FakeDirectory:
if group_cn.lower() not in {g.lower() for g in self.ou_groups}:
return []
return [self._ad(u) for u in self.users if group_cn.lower() in self._closure(u)]
def list_groups_detail(self) -> list[tuple[str, str]]:
self._check()
return sorted(((g, self.group_dn(g)) for g in self.ou_groups), key=lambda t: t[0].lower())
def members_of_dn(self, group_dn: str) -> list[AdUser]:
self._check()
name = self._group_by_dn(group_dn)
if name is None:
return []
return [self._ad(u) for u in self.users if name.lower() in self._closure(u)]
def search_groups(self, q: str, limit: int = 25) -> list[dict[str, str]]:
self._check()
key = q.strip().lower()
if not key:
return []
out = []
for name in sorted({*self.ou_groups, *self.other_groups}, key=str.lower):
desc = self.other_groups.get(name, f"Packet Station: {name}")
if key in name.lower() or key in desc.lower():
out.append({"dn": self.group_dn(name), "name": name, "description": desc})
return out[: max(1, limit)]
def users_by_guid(self, guids: list[uuid.UUID]) -> dict[uuid.UUID, AdUser | None]:
self._check()
by = {u.guid: u for u in self.users}
return {g: (self._ad(by[g]) if g in by else None) for g in guids}
+278
View File
@@ -0,0 +1,278 @@
"""An in-process SMTP server with STARTTLS, and a throwaway CA made at test time. Never sends mail anywhere.
`make_ca(dir)` writes a CA and a server certificate for 127.0.0.1 and localhost (with `cryptography` when it is
installed, else the `openssl` command). `FakeSmtp(certs)` listens on 127.0.0.1 and keeps every message it takes.
"""
from __future__ import annotations
import contextlib
import datetime as dt
import shutil
import socket
import ssl
import subprocess
import threading
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
CRLF = b"\r\n"
@dataclass
class Certs:
ca: Path
cert: Path
key: Path
def can_make_ca() -> bool:
try:
import cryptography # noqa: F401
except ImportError:
return shutil.which("openssl") is not None
return True
def make_ca(where: Path, name: str = "ca") -> Certs:
where.mkdir(parents=True, exist_ok=True)
try:
return _with_cryptography(where, name)
except ImportError:
return _with_openssl(where, name)
def _with_cryptography(where: Path, name: str) -> Certs:
import ipaddress
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
now = dt.datetime.now(dt.UTC)
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, f"Packet Station test {name}")])
ca_cert = (
x509.CertificateBuilder()
.subject_name(ca_name)
.issuer_name(ca_name)
.public_key(ca_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - dt.timedelta(minutes=5))
.not_valid_after(now + dt.timedelta(days=1))
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
.add_extension(x509.KeyUsage(True, False, False, False, False, True, True, False, False), critical=True)
.add_extension(x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()), critical=False)
.sign(ca_key, hashes.SHA256())
)
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
cert = (
x509.CertificateBuilder()
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")]))
.issuer_name(ca_name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - dt.timedelta(minutes=5))
.not_valid_after(now + dt.timedelta(days=1))
.add_extension(
x509.SubjectAlternativeName([x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]),
critical=False,
)
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False)
.add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), critical=False)
.sign(ca_key, hashes.SHA256())
)
pem = serialization.Encoding.PEM
certs = Certs(where / f"{name}.pem", where / f"{name}-server.pem", where / f"{name}-server.key")
certs.ca.write_bytes(ca_cert.public_bytes(pem))
certs.cert.write_bytes(cert.public_bytes(pem))
certs.key.write_bytes(
key.private_bytes(pem, serialization.PrivateFormat.TraditionalOpenSSL, serialization.NoEncryption())
)
return certs
def _with_openssl(where: Path, name: str) -> Certs:
exe = shutil.which("openssl")
if exe is None:
raise RuntimeError("neither cryptography nor openssl is available")
certs = Certs(where / f"{name}.pem", where / f"{name}-server.pem", where / f"{name}-server.key")
ca_key = where / f"{name}.key"
csr = where / f"{name}-server.csr"
ext = where / f"{name}-ext.cnf"
ext.write_text(
"basicConstraints=critical,CA:FALSE\nextendedKeyUsage=serverAuth\n"
"subjectAltName=DNS:localhost,IP:127.0.0.1\nauthorityKeyIdentifier=keyid\n"
)
ca_cfg = where / f"{name}-ca.cnf"
ca_cfg.write_text(
"[req]\ndistinguished_name=dn\nx509_extensions=v3_ca\nprompt=no\n[dn]\nCN=Packet Station test "
f"{name}\n[v3_ca]\nbasicConstraints=critical,CA:TRUE\nkeyUsage=critical,keyCertSign,cRLSign\n"
"subjectKeyIdentifier=hash\n"
)
def run(*args: str) -> None:
subprocess.run([exe, *args], check=True, capture_output=True, timeout=60)
run("req", "-x509", "-newkey", "rsa:2048", "-nodes", "-sha256", "-days", "1", "-keyout", str(ca_key),
"-out", str(certs.ca), "-config", str(ca_cfg)) # fmt: skip
run("req", "-newkey", "rsa:2048", "-nodes", "-sha256", "-keyout", str(certs.key), "-out", str(csr),
"-subj", "/CN=localhost") # fmt: skip
run("x509", "-req", "-in", str(csr), "-CA", str(certs.ca), "-CAkey", str(ca_key), "-CAcreateserial",
"-days", "1", "-sha256", "-extfile", str(ext), "-out", str(certs.cert)) # fmt: skip
return certs
@dataclass
class Message:
mail_from: str
rcpts: list[str]
data: bytes
tls: bool
@property
def text(self) -> str:
return self.data.decode("utf-8", "replace")
def header(self, name: str) -> str | None:
import email
msg = email.message_from_bytes(self.data)
value: Any = msg.get(name)
return None if value is None else str(value)
@dataclass
class FakeSmtp:
"""A threaded SMTP server. offer_tls=False leaves STARTTLS out of EHLO. refuse: addresses RCPT refuses."""
certs: Certs | None = None
offer_tls: bool = True
refuse: set[str] = field(default_factory=set)
messages: list[Message] = field(default_factory=list)
connections: int = 0
tls_failures: int = 0
def __post_init__(self) -> None:
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._sock.bind(("127.0.0.1", 0))
self._sock.listen(8)
self._sock.settimeout(0.2)
self.host, self.port = self._sock.getsockname()
self._stop = threading.Event()
self._thread = threading.Thread(target=self._serve, daemon=True)
self._lock = threading.Lock()
def __enter__(self) -> FakeSmtp:
self._thread.start()
return self
def __exit__(self, *exc: object) -> None:
self._stop.set()
self._thread.join(5)
self._sock.close()
def _serve(self) -> None:
while not self._stop.is_set():
try:
conn, _ = self._sock.accept()
except (TimeoutError, OSError):
continue
threading.Thread(target=self._session, args=(conn,), daemon=True).start()
def _session(self, conn: socket.socket) -> None:
with self._lock:
self.connections += 1
conn.settimeout(10)
try:
self._talk(conn)
finally:
with contextlib.suppress(OSError):
conn.close()
def _talk(self, conn: socket.socket) -> None:
sock: Any = conn
buf = b""
tls = False
mail_from, rcpts = "", [] # type: str, list[str]
def send(line: str) -> None:
sock.sendall(line.encode() + CRLF)
def readline() -> bytes | None:
nonlocal buf
while CRLF not in buf:
chunk = sock.recv(65536)
if not chunk:
return None
buf += chunk
line, buf = buf.split(CRLF, 1)
return line
with contextlib.suppress(OSError, ssl.SSLError, ValueError):
send("220 fake.test ESMTP")
while True:
raw = readline()
if raw is None:
return
cmd = raw.decode("utf-8", "replace")
verb = cmd.split(" ", 1)[0].upper()
if verb == "EHLO":
caps = ["250-fake.test", "250-8BITMIME"]
if self.offer_tls and not tls and self.certs is not None:
caps.append("250-STARTTLS")
caps.append("250 SMTPUTF8")
sock.sendall("\r\n".join(caps).encode() + b"\r\n")
elif verb == "HELO":
send("250 fake.test")
elif verb == "STARTTLS":
if not self.offer_tls or self.certs is None or tls:
send("454 TLS not available")
continue
send("220 Ready to start TLS")
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(str(self.certs.cert), str(self.certs.key))
try:
sock = ctx.wrap_socket(conn, server_side=True)
except (ssl.SSLError, OSError):
with self._lock:
self.tls_failures += 1
return
tls, buf, mail_from, rcpts = True, b"", "", []
elif verb == "MAIL":
mail_from = cmd.split(":", 1)[1].split()[0].strip("<>")
rcpts = []
send("250 OK")
elif verb == "RCPT":
addr = cmd.split(":", 1)[1].split()[0].strip("<>")
if addr.lower() in self.refuse:
send("550 No such user")
else:
rcpts.append(addr)
send("250 OK")
elif verb == "DATA":
if not rcpts:
send("554 No valid recipients")
continue
send("354 End data with <CR><LF>.<CR><LF>")
while b"\r\n.\r\n" not in b"\r\n" + buf:
chunk = sock.recv(65536)
if not chunk:
return
buf += chunk
full = b"\r\n" + buf
data, rest = full.split(b"\r\n.\r\n", 1)
buf = rest
with self._lock:
self.messages.append(Message(mail_from, list(rcpts), data[2:], tls))
send("250 OK queued")
elif verb in ("RSET", "NOOP"):
send("250 OK")
elif verb == "QUIT":
send("221 Bye")
return
else:
send("502 Command not implemented")
+133
View File
@@ -0,0 +1,133 @@
"""Helpers for the notify tests: a module-scoped database, a small directory fake with the notify calls, users."""
from __future__ import annotations
import secrets
import uuid
from collections.abc import Iterator
from pathlib import Path
from typing import Any
from fakedir import FakeDirectory
from pshelpers import PG_URL, app_conn, make_settings
from packetstation.auth.ldap import AdUser
from packetstation.notify.routing import cn_of
from packetstation.settings import Settings
BASE_DN = "DC=pstest,DC=local"
GROUP_BASE_DN = f"OU=PacketStation,OU=Groups,OU=PESCO_FARMINGTON,{BASE_DN}"
OTHER_BASE_DN = f"OU=Groups,{BASE_DN}"
OUTSIDE = "Ops-Mail"
def fresh_db(tmp: Path, **over: Any) -> Iterator[Settings]:
"""A fresh migrated database (like conftest.pg_settings), dropped afterwards."""
import psycopg
from psycopg import sql
from psycopg.conninfo import conninfo_to_dict
from packetstation import db, migrate
assert PG_URL
info = conninfo_to_dict(PG_URL)
mig_pw, app_pw = secrets.token_urlsafe(18), secrets.token_urlsafe(18)
(tmp / "mig").write_text(mig_pw + "\n")
(tmp / "app").write_text(app_pw + "\n")
name = f"ps_test_{secrets.token_hex(4)}"
s = make_settings(
tmp,
pg_host=info.get("host", "localhost"),
pg_port=int(info.get("port", 5432)),
pg_database=name,
pg_migrate_password_file=tmp / "mig",
pg_app_password_file=tmp / "app",
pg_superuser_url=PG_URL,
ldap_base_dn=BASE_DN,
ldap_group_base_dn=GROUP_BASE_DN,
smtp_from="[email protected]",
**over,
)
migrate.bootstrap_roles(s)
with psycopg.connect(PG_URL, autocommit=True) as su:
for user, pw in (("ps_migrate", mig_pw), ("ps_app", app_pw)):
su.execute(sql.SQL("ALTER ROLE {} LOGIN PASSWORD {}").format(sql.Identifier(user), sql.Literal(pw)))
with db.connect_as("migrate", s, autocommit=True) as conn:
migrate.run(conn, db.MIGRATIONS_DIR)
try:
yield s
finally:
db.close_pool()
with psycopg.connect(PG_URL, autocommit=True) as su:
su.execute(sql.SQL("DROP DATABASE IF EXISTS {} WITH (FORCE)").format(sql.Identifier(name)))
class NotifyDir(FakeDirectory):
"""FakeDirectory plus members_of_dn and search_groups, by DN under GROUP_BASE_DN or OTHER_BASE_DN.
OUTSIDE is a group outside the OU whose only member is qc.quinn."""
def __init__(self) -> None:
super().__init__()
self.outside: dict[str, list[str]] = {OUTSIDE: ["qc.quinn"]}
self.member_calls: list[str] = []
@staticmethod
def dn(name: str) -> str:
return f"CN={name},{OTHER_BASE_DN if name == OUTSIDE else GROUP_BASE_DN}"
def members_of_dn(self, group_dn: str) -> list[AdUser]:
self._check()
self.member_calls.append(group_dn)
name = cn_of(group_dn)
if name in self.outside:
return [self._ad(self.user(sam)) for sam in self.outside[name]]
if name.lower() not in {g.lower() for g in self.ou_groups}:
return []
return [self._ad(u) for u in self.users if name.lower() in self._closure(u)]
def search_groups(self, q: str, limit: int = 25) -> list[dict[str, Any]]:
self._check()
key = q.strip().lower()
names = sorted({*self.ou_groups, OUTSIDE}, key=str.lower)
return [{"dn": self.dn(n), "name": n, "description": f"group {n}"} for n in names if key in n.lower()][:limit]
def add_users(settings: Settings, directory: FakeDirectory, sams: tuple[str, ...] | None = None) -> dict[str, str]:
"""users rows for the fake people (id = their GUID, email [email protected]). Returns sam -> id."""
out: dict[str, str] = {}
with app_conn(settings) as conn:
for u in directory.users:
if sams is not None and u.sam not in sams:
continue
conn.execute(
"INSERT INTO users (id, sam_account, display_name, emp_no, email, active) VALUES (%s, %s, %s, %s, %s,"
" %s) ON CONFLICT (id) DO NOTHING",
(u.guid, u.sam, u.display, u.emp_no, f"{u.sam}@example.test", not u.disabled),
)
out[u.sam] = str(u.guid)
return out
def clean(settings: Settings) -> int:
"""Empty the notification tables a test touches (notifications_sent is append-only: returns its max id)."""
with app_conn(settings) as conn:
conn.execute("DELETE FROM notification_outbox")
conn.execute("DELETE FROM notification_events")
conn.execute("DELETE FROM notification_exclusions")
conn.execute("DELETE FROM notification_recipients")
conn.execute("DELETE FROM notification_rules")
conn.execute("DELETE FROM settings WHERE key = 'notify.last_build'")
conn.execute("DELETE FROM drawing_lookups")
return int(conn.execute("SELECT coalesce(max(id), 0) FROM notifications_sent").fetchone()[0])
def sent_since(settings: Settings, marker: int) -> list[dict[str, Any]]:
from psycopg.rows import dict_row
with app_conn(settings) as conn, conn.cursor(row_factory=dict_row) as cur:
return [dict(r) for r in cur.execute("SELECT * FROM notifications_sent WHERE id > %s ORDER BY id", (marker,))]
def new_id() -> uuid.UUID:
return uuid.uuid4()
+32 -38
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from fakedir import FAKE_PASSWORD, FakeDirectory
from fastapi.testclient import TestClient
from pshelpers import ORIGIN_HEADERS, PIN, app_conn, needs_pg, pin_signin, register_device, set_pin, sign_in
from psycopg.types.json import Jsonb
from packetstation.main import create_app
from packetstation.settings import Settings
@@ -34,53 +35,46 @@ def test_group_map_flags_missing_and_extra(pg_client: TestClient, fake_dir: Fake
assert weld["role"] == "shop:03:lead" and weld["dept_code"] == "03"
def test_ad_refresh_counts_and_flags(pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory) -> None:
def test_ad_refresh_requests_a_reconcile_run(pg_client: TestClient, pg_settings: Settings) -> None:
"""POST /ad/refresh asks the worker for an ad-reconcile run; the counts are the reconcile's (test_reconcile)."""
sign_in(pg_client, "admin.ann")
res = pg_client.post("/api/admin/ad/refresh").json()
assert res == {
"groups_checked": 49,
"groups_missing": ["PS-14-Electrical-Lead"],
"users_seen": 16,
"created": 15,
"updated": 1,
r = pg_client.post("/api/admin/ad/refresh")
assert r.status_code == 202, r.text
assert r.json() == {"requested": True, "pending": 1, "job": "ad-reconcile"}
assert pg_client.post("/api/admin/ad/refresh").json()["pending"] == 2
empty = pg_client.get("/api/admin/ad/reconcile").json()
assert empty["run"] is None and empty["pending"] == 2
assert empty["report"] == {
"groups_missing": [],
"groups_extra": [],
"users_seen": 0,
"created": 0,
"updated": 0,
"deactivated": 0,
"emp_missing": ["noemp.nora"],
"emp_duplicate": ["9001"],
"sessions_ended": 0,
"emp_missing": [],
"emp_duplicate": [],
}
users = {u["sam_account"]: u for u in pg_client.get("/api/admin/users").json()}
assert "norole.nick" not in users and len(users) == 16
assert users["dup.dave"]["flags"]["emp_duplicate"] and users["dup.dina"]["flags"]["emp_duplicate"]
assert users["noemp.nora"]["flags"]["emp_missing"] and users["noemp.nora"]["emp_no"] is None
assert users["disabled.dan"]["active"] is False and users["disabled.dan"]["flags"]["disabled"]
assert users["nested.ned"]["roles"] == ["shop:10:worker"]
assert users["both.bo"]["roles"] == ["shop:03:supervisor"]
assert users["multi.mia"]["roles"] == ["clerical", "shop:06:supervisor"]
assert [u["sam_account"] for u in pg_client.get("/api/admin/users", params={"q": "dup"}).json()] == [
"dup.dave",
"dup.dina",
]
# Clara signs in elsewhere, then AD disables her; the next refresh deactivates her and ends the session
with TestClient(create_app(pg_settings, directory=fake_dir), headers=ORIGIN_HEADERS) as other:
sign_in(other, "clerk.clara")
fake_dir.user("clerk.clara").disabled = True
fake_dir.users.remove(fake_dir.user("wh.wade")) # gone from AD
fake_dir.user("qc.quinn").groups = [] # still in AD, in no group
res = pg_client.post("/api/admin/ad/refresh").json()
assert res["deactivated"] == 2 and res["users_seen"] == 14
assert other.get("/api/auth/me").json()["code"] == "no_session"
users = {u["sam_account"]: u for u in pg_client.get("/api/admin/users").json()}
assert users["clerk.clara"]["active"] is False and users["wh.wade"]["active"] is False
assert users["qc.quinn"]["active"] is True and users["qc.quinn"]["roles"] == []
with app_conn(pg_settings) as conn:
assert conn.execute("SELECT count(*) FROM job_requests WHERE job = 'ad-reconcile'").fetchone()[0] == 2
acts = conn.execute("SELECT action FROM audit_events WHERE action LIKE 'ad.%' ORDER BY id").fetchall()
assert [a[0] for a in acts] == ["ad.refresh", "ad.user_deactivated", "ad.user_deactivated", "ad.refresh"]
assert [a[0] for a in acts] == ["ad.refresh_requested", "ad.refresh_requested"]
conn.execute(
"INSERT INTO job_runs (job, trigger, finished_at, status, counts) VALUES ('ad-reconcile', 'request',"
" now(), 'ok', %s)",
(Jsonb({"users_seen": 16, "created": 15, "groups_missing": ["PS-14-Electrical-Lead"], "x": 1}),),
)
conn.execute("INSERT INTO job_runs (job, trigger, status) VALUES ('ad-reconcile', 'schedule', 'running')")
got = pg_client.get("/api/admin/ad/reconcile").json()
assert got["run"]["status"] == "ok" and got["run"]["trigger"] == "request"
assert got["report"]["users_seen"] == 16 and got["report"]["created"] == 15
assert got["report"]["groups_missing"] == ["PS-14-Electrical-Lead"] and "x" not in got["report"]
def test_user_detail_and_lookup(pg_client: TestClient, fake_dir: FakeDirectory) -> None:
sign_in(pg_client, "multi.mia") # the user row exists
pg_client.post("/api/auth/signout")
sign_in(pg_client, "admin.ann")
pg_client.post("/api/admin/ad/refresh")
mia = next(u for u in pg_client.get("/api/admin/users", params={"q": "mia"}).json())
detail = pg_client.get(f"/api/admin/users/{mia['id']}").json()
assert detail["groups"] == ["PS-06-CodeBldg2-Supervisor", "PS-Clerical"] and detail["groups_source"] == "ad"
+223
View File
@@ -0,0 +1,223 @@
"""Admin departments, profiles, checklists and directory flags."""
from __future__ import annotations
import copy
from collections.abc import Iterator
import pytest
from fakedir import FakeDirectory
from fastapi.testclient import TestClient
from pshelpers import app_conn, needs_pg, sign_in
from psycopg.types.json import Jsonb
from packetstation.auth import groups
from packetstation.domain.checklists import DEFAULT_TEMPLATES
from packetstation.settings import Settings
pytestmark = needs_pg
@pytest.fixture(autouse=True)
def restore_group_map() -> Iterator[None]:
yield
groups.use_departments(groups.DEPARTMENTS)
def _actions(settings: Settings, like: str) -> list[tuple[str, dict]]:
with app_conn(settings) as conn:
return [
(r[0], r[1])
for r in conn.execute(
"SELECT action, detail FROM audit_events WHERE action LIKE %s ORDER BY id", (like,)
).fetchall()
]
def test_departments(pg_client: TestClient, pg_settings: Settings) -> None:
sign_in(pg_client, "admin.ann")
listed = pg_client.get("/api/admin/departments").json()
assert [d["code"] for d in listed][:5] == ["CL", "WH", "QC", "PM", "01"] and len(listed) == 18
weld = next(d for d in listed if d["code"] == "03")
assert weld["weld"] is True and weld["kind"] == "shop" and len(weld["stations"]) == 6
assert [g["name"] for g in weld["groups"]] == [
"PS-03-WeldParts-Worker",
"PS-03-WeldParts-Lead",
"PS-03-WeldParts-Supervisor",
]
assert all(g["in_ou"] is True for g in weld["groups"])
elec = next(d for d in listed if d["code"] == "14")
assert {g["name"]: g["in_ou"] for g in elec["groups"]}["PS-14-Electrical-Lead"] is False
pm = next(d for d in listed if d["code"] == "PM")
assert [g["name"] for g in pm["groups"]] == ["PS-ProjectMgmt"]
# Code and slug can never change; extra fields are refused
for body in ({"code": "99"}, {"slug": "Other"}):
r = pg_client.patch("/api/admin/departments/03", json=body)
assert r.status_code == 422, body
assert pg_client.patch("/api/admin/departments/ZZ", json={"name": "x"}).status_code == 404
st = weld["stations"]
r = pg_client.patch(
"/api/admin/departments/03",
json={
"name": " Weld Parts Shop ",
"stations": [
{"id": st[0]["id"], "name": st[1]["name"], "sort": 1, "active": True},
{"id": st[1]["id"], "name": st[0]["name"], "sort": 2, "active": False},
{"name": "Stall 7", "sort": 7, "active": True},
],
},
)
assert r.status_code == 200, r.text
got = r.json()
assert got["name"] == "Weld Parts Shop" and len(got["stations"]) == 7 # nothing deleted
by_id = {s["id"]: s for s in got["stations"]}
assert by_id[st[0]["id"]]["name"] == "Stall 2" and by_id[st[1]["id"]]["active"] is False
dup = pg_client.patch(
"/api/admin/departments/03", json={"stations": [{"name": "stall 3", "sort": 9, "active": True}]}
)
assert dup.status_code == 422 and dup.json()["code"] == "duplicate_station"
other = pg_client.patch(
"/api/admin/departments/03",
json={"stations": [{"id": listed[5]["stations"][0]["id"], "name": "X", "sort": 1, "active": True}]},
)
assert other.status_code == 422
# Deactivated stations drop out of the public list
pub = next(d for d in pg_client.get("/api/departments").json() if d["code"] == "03")
assert st[1]["id"] not in {s["id"] for s in pub["stations"]} and pub["name"] == "Weld Parts Shop"
# Add a department: the three group names IT must create
body = {
"code": "15",
"slug": "Machining",
"name": "Machining",
"kind": "shop",
"weld": False,
"supervisor_group": True,
"stations": ["Mill 1", "Lathe 1"],
}
r = pg_client.post("/api/admin/departments", json=body)
assert r.status_code == 201, r.text
out = r.json()
assert out["groups_to_create"] == ["PS-15-Machining-Worker", "PS-15-Machining-Lead", "PS-15-Machining-Supervisor"]
assert out["department"]["sort"] == 190 and out["department"]["created_at"]
assert [s["name"] for s in out["department"]["stations"]] == ["Mill 1", "Lathe 1"]
assert all(g["in_ou"] is False for g in out["department"]["groups"])
# The group map now knows the new groups
assert groups.role_for_group("PS-15-Machining-Lead") is not None
assert pg_client.post("/api/admin/departments", json=body).json()["code"] == "code_taken"
r = pg_client.post("/api/admin/departments", json={**body, "code": "16", "slug": "machining"})
assert r.status_code == 409 and r.json()["code"] == "slug_taken"
for bad in ({"code": "1"}, {"code": "abc"}, {"slug": "9Bad"}, {"slug": "Has Space"}):
assert pg_client.post("/api/admin/departments", json={**body, "code": "17", **bad}).status_code == 422, bad
r = pg_client.post("/api/admin/departments", json={**body, "code": "17", "slug": "Front", "kind": "support"})
assert r.status_code == 422 and r.json()["code"] == "support_fixed"
acts = _actions(pg_settings, "admin.department%")
assert [a for a, _ in acts] == ["admin.department_updated", "admin.department_added"]
assert acts[0][1]["before"]["name"] == "Weld Parts" and acts[0][1]["after"]["name"] == "Weld Parts Shop"
assert acts[1][1]["groups_to_create"] == out["groups_to_create"]
def test_profiles_and_flags(pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory) -> None:
for sam in ("weld.andy", "lead.lou", "dup.dave", "dup.dina", "noemp.nora"):
sign_in(pg_client, sam)
andy, lou = str(fake_dir.user("weld.andy").guid), str(fake_dir.user("lead.lou").guid)
sign_in(pg_client, "admin.ann")
stations = {s["name"]: s["id"] for s in pg_client.get("/api/departments").json()[6]["stations"]}
assert pg_client.get("/api/departments").json()[6]["code"] == "03"
r = pg_client.patch(
f"/api/admin/users/{andy}/profile",
json={"welder_stamp": "AW-3", "station_limits": {"03": [stations["Stall 1"], stations["Stall 2"]]}},
)
assert r.status_code == 200, r.text
assert r.json()["welder_stamp"] == "AW-3" and r.json()["station_limits"] == {
"03": [stations["Stall 1"], stations["Stall 2"]]
}
r = pg_client.patch(f"/api/admin/users/{lou}/profile", json={"welder_stamp": "aw-3"})
assert r.status_code == 409 and r.json()["code"] == "stamp_taken"
for bad in ("x" * 13, "A B", "A_1"):
r = pg_client.patch(f"/api/admin/users/{lou}/profile", json={"welder_stamp": bad})
assert r.status_code == 422, bad
r = pg_client.patch(f"/api/admin/users/{andy}/profile", json={"station_limits": {"03": [999999]}})
assert r.status_code == 422 and r.json()["code"] == "unknown_station"
r = pg_client.patch(f"/api/admin/users/{andy}/profile", json={"station_limits": {"ZZ": []}})
assert r.status_code == 422 and r.json()["code"] == "unknown_department"
assert pg_client.patch(f"/api/admin/users/{andy}/profile", json={"active": False}).status_code == 422
row = next(u for u in pg_client.get("/api/admin/users").json() if u["id"] == andy)
assert row["welder_stamp"] == "AW-3" and row["profile_active"] is True
assert row["station_limits"] == {"03": [stations["Stall 1"], stations["Stall 2"]]}
assert "directory_flags" in row and "groups" not in row["directory_flags"]
detail = pg_client.get(f"/api/admin/users/{andy}").json()
assert detail["welder_stamp"] == "AW-3"
# The welder picker honours profile_active
people = {p["id"] for p in pg_client.get("/api/departments/03/people").json()}
assert andy in people
r = pg_client.patch(
f"/api/admin/users/{andy}/profile", json={"profile_active": False, "station_limits": {"03": []}}
)
assert r.status_code == 200 and r.json()["profile_active"] is False and r.json()["station_limits"] == {}
assert andy not in {p["id"] for p in pg_client.get("/api/departments/03/people").json()}
r = pg_client.patch(f"/api/admin/users/{andy}/profile", json={"welder_stamp": None})
assert r.json()["welder_stamp"] is None
acts = _actions(pg_settings, "admin.profile%")
assert [a for a, _ in acts] == ["admin.profile_updated"] * 3
assert acts[0][1]["before"]["welder_stamp"] is None and acts[0][1]["after"]["welder_stamp"] == "AW-3"
# Directory flags as the reconcile writes them
with app_conn(pg_settings) as conn:
conn.execute(
"UPDATE users SET directory_flags = %s WHERE sam_account IN ('dup.dave', 'dup.dina')",
(Jsonb({"emp_duplicate": True}),),
)
conn.execute(
"UPDATE users SET directory_flags = %s WHERE sam_account = 'noemp.nora'", (Jsonb({"emp_missing": True}),)
)
flags = pg_client.get("/api/admin/directory-flags").json()
assert [m["sam_account"] for m in flags["missing"]] == ["noemp.nora"]
assert flags["duplicate"][0]["emp_no"] == "9001"
assert sorted(u["sam_account"] for u in flags["duplicate"][0]["users"]) == ["dup.dave", "dup.dina"]
assert flags["checked_at"] is not None
def test_checklists_cas(pg_client: TestClient, pg_settings: Settings) -> None:
sign_in(pg_client, "admin.ann")
got = pg_client.get("/api/admin/checklists").json()
assert got["defaults"] == DEFAULT_TEMPLATES and set(got["value"]) == {"weld", "plain"}
version = got["version"]
value = copy.deepcopy(got["value"])
value["plain"].append({"group": "When done", "key": "tag", "text": "Tag on {drawing} rev {rev}"})
r = pg_client.put("/api/admin/checklists", json={"value": value, "version": version})
assert r.status_code == 200, r.text
assert r.json()["version"] == version + 1 and r.json()["value"]["plain"][-1]["key"] == "tag"
stale = pg_client.put("/api/admin/checklists", json={"value": value, "version": version})
assert stale.status_code == 409 and stale.json()["current"]["version"] == version + 1
def bad(change: dict) -> dict:
v = copy.deepcopy(value)
v["weld"][0] = {**v["weld"][0], **change}
r = pg_client.put("/api/admin/checklists", json={"value": v, "version": version + 1})
assert r.status_code == 422, change
return r.json()
assert "unknown placeholder {stamp}" in bad({"text": "Stamp {stamp}"})["detail"]
assert "brace" in bad({"text": "Open { brace"})["detail"]
assert "key" in bad({"key": "Bad-Key"})["detail"]
assert "used twice" in bad({"key": "heat"})["detail"]
assert "group is empty" in bad({"group": " "})["detail"]
assert "text is empty" in bad({"text": ""})["detail"]
too_many = copy.deepcopy(value)
too_many["plain"] = [{"group": "G", "key": f"k{i}", "text": "t"} for i in range(41)]
assert pg_client.put("/api/admin/checklists", json={"value": too_many, "version": version + 1}).status_code == 422
empty = {"weld": value["weld"], "plain": []}
assert pg_client.put("/api/admin/checklists", json={"value": empty, "version": version + 1}).status_code == 422
acts = _actions(pg_settings, "admin.checklists%")
assert len(acts) == 1 and acts[0][1]["after"]["plain"][-1]["key"] == "tag"
assert len(acts[0][1]["before"]["plain"]) == len(value["plain"]) - 1

Some files were not shown because too many files have changed in this diff Show More