Migration 0003: projects, aliases, renumbers, units, packets and every child table, change orders, assets, documents, ITPs, station limits, department weld flag. Policy package: Principal, PacketState, Action rules, custody, preconditions, CO hold, order of checks, can/blocked/holder on reads. docs/POLICY.md with the tightenings table for Seth. Write pipeline: FOR UPDATE, CAS on status and dept, If-Match row versions, server-assigned numbers, field CAS, version bump, packet log, audit, NOTIFY, signatures, IT reasons, filed lock and HMAC seal. API: /api/packets (header, transitions, work orders, pages, docs, move, remove, BOM, shorts, welds, heat tags, heat table, stage, checklist, clock, warehouse stage, Q.C., change orders, log, seal), /api/projects, /api/units, /api/assets, /_blob. Frontend: Clerical home, projects and units, packet page with pdf.js and QR codes, hand draft, heat map, send to warehouse, department or Q.C. Tests: policy matrix, API, concurrency, schema, projects; Playwright Clerical draft scenario. Verified on docker-1 with the CI stack and the Samba DC. Co-Authored-By: Claude Fable 5.1 <[email protected]>
220 lines
18 KiB
Markdown
220 lines
18 KiB
Markdown
# Packet Station policy
|
|
|
|
Who may do what to a packet, and when. This file is written from the code in `server/packetstation/policy/`. When they disagree, the code is the rule and this file is out of date.
|
|
|
|
## Purpose
|
|
|
|
The policy is a Python port of the old `guard.js`. It runs only on the server. Every packet write calls `policy.check()` inside the write transaction, under the packet row lock. Every packet read returns `can`, `blocked` and `holder` from the same rules, so the screens only show what the server will accept.
|
|
|
|
The package is pure functions with no I/O:
|
|
|
|
- `actions.py`: the `Action` enum, the `Who` predicates, the `Rule` record and the `RULES` table.
|
|
- `principal.py`: `Principal`, `PacketState`, `principal_from_roles()`.
|
|
- `custody.py`: `holds()`, `works_on()`, `holder_name()` and the Who predicates.
|
|
- `preconditions.py`: the named preconditions and their messages.
|
|
- `engine.py`: `check()`, `evaluate()`, `can()` and `Decision`.
|
|
|
|
## Roles
|
|
|
|
Every role comes from an AD group (see docs/SECURITY.md). The role strings are `admin`, `clerical`, `clerical_supervisor`, `warehouse`, `warehouse_supervisor`, `qc`, `qc_supervisor`, `pm` and `shop:<code>:<worker|lead|supervisor>`. A person's roles are the union of their groups.
|
|
|
|
`principal_from_roles(user_id, roles, station_limits=(), session_kind="password")` builds the policy's `Principal`:
|
|
|
|
| Field | Meaning |
|
|
|---|---|
|
|
| `admin` | In PS-Admins. Admin is not membership. It is the bypass rule below. |
|
|
| `support` | The codes among CL, WH and Q.C. the person belongs to, at any level. A supervisor role implies membership. |
|
|
| `support_sup` | The codes among CL, WH and Q.C. where the person is Supervisor. |
|
|
| `shop` | Shop department code to level: 1 worker, 2 lead, 3 supervisor. The highest level wins. |
|
|
| `station_limits` | Station ids a worker is limited to. Empty means any station. |
|
|
| `pm` | Project Management. PM can read every packet and change nothing. |
|
|
| `session_kind` | `password` or `pin`. |
|
|
| `system` | `visual-feed` for the worker. Never set from a session. |
|
|
|
|
A role string that does not parse is ignored.
|
|
|
|
## Custody
|
|
|
|
Only whoever holds the packet may change it. Everyone else can open it and follow it.
|
|
|
|
| Status | Holder |
|
|
|---|---|
|
|
| draft | Clerical |
|
|
| warehouse | Warehouse |
|
|
| ready, review | Lead or supervisor of the packet's department |
|
|
| assigned, in_progress | Lead or supervisor of the department, or a worker who works on it |
|
|
| qc | Q.C. |
|
|
| complete | Clerical (data book) |
|
|
| filed | Nobody |
|
|
|
|
`works_on(principal, state)`: a member of the packet's department. Leads and supervisors always pass. A worker passes when they have no station limits, or the limits hold the packet's station. START and SUBMIT also need the assigned welder for a worker (see the tightenings).
|
|
|
|
`holder_name(state, dept_name, station_name)` gives the text for messages: "Clerical", "Warehouse", "Q.C.", "Clerical (data book)", "the data book (filed)", else the department name, plus " · <station>" while the packet is at a station.
|
|
|
|
Statuses in workflow order: draft, warehouse, ready, assigned, in_progress, review, qc, complete, filed. SHOP is ready, assigned, in_progress and review. ACTIVE is assigned and in_progress.
|
|
|
|
## Who
|
|
|
|
| Who | Passes when |
|
|
|---|---|
|
|
| CL, WH, QC | A member of that support department, any level |
|
|
| CL_SUP, WH_SUP, QC_SUP | Supervisor of that support department |
|
|
| CL_OR_QC | A member of Clerical or Q.C. |
|
|
| HOLDER | Holds the packet (custody above) |
|
|
| DEPT_LEAD | Lead or supervisor in the packet's department |
|
|
| DEPT_SUP | Supervisor in the packet's department |
|
|
| WORKS_ON | Lead or above in the department, or a worker in the department who is the assigned welder and whose station limits allow the station |
|
|
| CO_VERIFIER | Supervisor of the department named in the request: a shop Supervisor for a shop code, WH or Q.C. Supervisor for WH or QC. Clerical never verifies. |
|
|
| ADMIN | In PS-Admins |
|
|
| SYSTEM | The worker (`system` is set) |
|
|
|
|
## Rule table
|
|
|
|
`from` is the set of statuses the action starts from. "any" means any status, with the filed lock applied separately. `co hold` means an open change order holds the step.
|
|
|
|
| Action | Who | From | To | Dept | CO hold | Preconditions | Signature | Reason |
|
|
|---|---|---|---|---|---|---|---|---|
|
|
| send_to_warehouse | CL | draft | warehouse | no | | heat_map_answered, drawing_attached | | |
|
|
| release_to_shop | CL | draft | ready | allowed | | heat_map_answered, drawing_attached, target_dept_given | | |
|
|
| send_draft_to_qc | CL | draft | qc | no | | heat_map_answered, drawing_attached | | |
|
|
| wh_send_back | WH | warehouse | draft | no | | reason_given | | yes |
|
|
| return_to_clerical | DEPT_LEAD | ready, only when wh_skipped | draft | no | | reason_given | | yes |
|
|
| wh_release | WH | warehouse | ready | no | yes | bom_all_pulled_or_short | | |
|
|
| send_back_to_warehouse | DEPT_LEAD | ready | warehouse | no | | reason_given | | yes |
|
|
| assign | DEPT_LEAD | ready | assigned | no | | welder_set | | |
|
|
| unassign | DEPT_LEAD | assigned | ready | no | | | | |
|
|
| pull_back | DEPT_LEAD | in_progress | ready | no | | | | |
|
|
| start | WORKS_ON | assigned | in_progress | no | | | | |
|
|
| submit | WORKS_ON | assigned, in_progress | review | no | yes | checklist_complete, welds_recorded, filler_set | Submit work to the supervisor | |
|
|
| return_to_station | DEPT_SUP | review | assigned | no | | reason_given | | yes |
|
|
| send_to_qc | DEPT_SUP | review | qc | no | yes | qc_book_spool_location | Send to Q.C. | |
|
|
| route_from_review | DEPT_SUP | review | ready | required | yes | target_dept_given | Route to another department | |
|
|
| qc_reject | QC | qc | review | no | | reason_given | Q.C. reject | yes |
|
|
| qc_accept | QC | qc | complete | no | yes | qc_missing_empty, route_given | Q.C. accept | |
|
|
| qc_accept_and_route | QC | qc | ready | required | yes | qc_missing_empty, target_dept_given | Q.C. accept and route | |
|
|
| file | CL | complete | filed | no | yes | vclosed_if_databook | File in the data book | |
|
|
| admin_reopen | ADMIN | filed (filed_ok) | complete | no | | reason_given | Reopen a filed packet | yes |
|
|
| edit_header | CL | draft | | | | | | |
|
|
| edit_drawing_ref | CL | any | | | | | | |
|
|
| edit_dept | CL | draft | | | | | | |
|
|
| move_unit | CL | any | | | | | | |
|
|
| edit_wos | CL | draft | | | | | | |
|
|
| edit_bom | HOLDER | draft, warehouse, SHOP | | | | | | |
|
|
| wh_receive_short | WH | any | | | | short_open | | |
|
|
| edit_heats | HOLDER | warehouse, SHOP | | | | | | |
|
|
| edit_heat_table | HOLDER | warehouse, SHOP | | | | | | |
|
|
| edit_welds | HOLDER | SHOP | | | | | | |
|
|
| edit_checklist | HOLDER | SHOP | | | | | | |
|
|
| edit_stage_fields | HOLDER | SHOP | | | | | | |
|
|
| clock | HOLDER | SHOP | | | | | | |
|
|
| edit_qc | QC | qc | | | | | | |
|
|
| set_wh_stage | WH | warehouse | | | | | | |
|
|
| edit_document_set | CL | any | | | | | Attach a drawing by hand (attach only) | |
|
|
| doc_add | CL_OR_QC | any | | | | | | |
|
|
| doc_remove | CL_OR_QC | any | | | | | | |
|
|
| rev_approve | CL_SUP | draft | | | | reason_given | Approve the M-Files revision | yes |
|
|
| set_vclosed | CL | complete, only when routed_to is databook | | | | | | |
|
|
| set_databook_at | CL_OR_QC | any (filed_ok) | | | | | | |
|
|
| remove | CL | draft | | | | reason_given | | yes |
|
|
| co_issue | CL | any | | | | | Issue a change order | |
|
|
| co_approve | CL | any | | | | | Approve a change order | |
|
|
| co_reject | CL | any | | | | reason_given | Reject a change order | yes |
|
|
| co_hold_toggle | CL | any | | | | | | |
|
|
| co_verify | CO_VERIFIER | any | | | | | Verify a change order for the department | |
|
|
| itp_edit_line | QC | any | | | | | | |
|
|
| itp_block_sign | QC | any | | | | | Sign an ITP block | |
|
|
| itp_sign_off | QC | any | | | | | Sign off the ITP | |
|
|
| itp_reopen | QC_SUP (Admin through the bypass) | any | | | | reason_given | | yes |
|
|
| vis_update_draft | SYSTEM | draft | | | | | | |
|
|
| vis_retire_draft | SYSTEM | draft | | | | | | |
|
|
| vis_open_co | SYSTEM | any but draft and filed | | | | | | |
|
|
| vis_auto_release | SYSTEM | draft | warehouse | | | heat_map_answered, drawing_attached | | |
|
|
|
|
The `vis_*` actions are system only. People can never do them, and the worker can do nothing else.
|
|
|
|
## Preconditions
|
|
|
|
Each precondition is a named predicate over the packet state and the request input. They are never bypassed, Admin included. The first one that fails gives the message.
|
|
|
|
| Name | Passes when | Message |
|
|
|---|---|---|
|
|
| heat_map_answered | heat_map is yes or no | Say whether this packet needs a heat map. |
|
|
| drawing_attached | a drawing is attached | Attach the drawing first. |
|
|
| bom_all_pulled_or_short | every line not marked n/a is pulled or has an open short | Pull every material line or mark it short first. |
|
|
| short_open | at least one BOM line has an open short (the old whCanReceive) | No part is short on this packet. |
|
|
| welder_set (input) | the request gives station_id and welder_id | Pick a station and a welder. |
|
|
| checklist_complete | every key of the department checklist is done | Finish the checklist first. |
|
|
| welds_recorded | at least one weld, in a weld department | Record at least one weld first. |
|
|
| filler_set | filler metal entered, in a weld department | Enter the filler metal first. |
|
|
| qc_book_spool_location (input) | book and spool confirmed and a location given | Confirm the book and the spool, and give the location. |
|
|
| qc_missing_empty | the Q.C. record has nothing missing | Q.C. still needs: (the list). |
|
|
| reason_given (input) | a reason of at least 8 characters after cleaning | Give a reason of at least 8 characters. |
|
|
| vclosed_if_databook | routed to Shipping, or closed in VISUAL | Close the job in VISUAL first. |
|
|
| co_staged_if_dwg_change (input) | no drawing change, or the new drawing is staged (phase 3) | Stage the new drawing for the change order first. |
|
|
| route_given (input) | routed_to is databook or shipping | Choose Data Book or Shipping. |
|
|
| target_dept_given (input) | a shop department different from the packet's; from a draft any shop department | Choose a department. / Choose a different department. |
|
|
|
|
"(input)" marks a precondition that reads request input, not packet state. `can()` skips those.
|
|
|
|
## Order of checks
|
|
|
|
`check(principal, state, action, *, extra={}, it_reason=None) -> Decision` runs these in this order and stops at the first failure:
|
|
|
|
1. **Removed.** A removed packet refuses everything, Admin included. Code `removed`.
|
|
2. **Status.** The status must be in the rule's from set. RETURN_TO_CLERICAL also needs wh_skipped. SET_VCLOSED also needs routed_to databook. An unknown status fails here too. Code `wrong_status`.
|
|
3. **Change-order hold.** A held action refuses while an approved change order waits for verification, or a pending one is on hold. Admin included. Code `co_hold`, with the hold text.
|
|
4. **System.** A system-only action refuses every person (`system_only`). The system principal passes only system-only actions (`forbidden`).
|
|
5. **Admin bypass.** An admin who is not allowed by the rule's Who passes who and custody when an IT reason of at least 8 characters is given. Without one the code is `it_reason_required`. SET_DATABOOK_AT needs no reason. The decision is marked `admin_bypass`, and the write marks the log row as an IT edit.
|
|
6. **Filed lock.** A filed packet refuses everything except SET_DATABOOK_AT and ADMIN_REOPEN, Admin included. Code `filed`: "This packet is filed and locked. IT can reopen it if a correction is needed."
|
|
7. **Who and custody.** Code `forbidden`. For a step: "Your access doesn't allow this step." For an edit: "Your access doesn't allow changing (what) on this packet. It's with (holder) right now." PM fails here for everything.
|
|
8. **Preconditions,** in rule order, never bypassed. Code `precondition` with the message, or `reason_required` when the missing piece is the rule's reason, so the screen can ask for one.
|
|
|
|
`Decision` carries `ok`, `code`, `why`, `admin_bypass`, `signature` (the meaning text when the rule needs one) and, in read mode only, `reasons`.
|
|
|
|
The packet API answers 403 with the code for most failures, and 400 for `it_reason_required` and `reason_required`.
|
|
|
|
## Signatures
|
|
|
|
These actions need a signature: submit, send_to_qc, route_from_review, qc_reject, qc_accept, qc_accept_and_route, file, admin_reopen, edit_document_set (attach only), rev_approve, co_issue, co_approve, co_reject, co_verify, itp_block_sign and itp_sign_off. The rule's signature text is the meaning that is signed.
|
|
|
|
The request carries `sign: {factor, secret}`. The write pipeline calls the phase 1 helper `packetstation.auth.signatures.sign()` inside the same transaction, after the policy passes. It checks the PIN or domain password against the session's own user. A PIN works in any session. It logs the attempt and applies the five-strike PIN disable and the three-strike session end. It writes an append-only `signatures` row with the user, session, account, display name, session kind, factor, action, meaning, packet, subject and a sha256 over the canonical JSON of what was signed (the action, the packet id, the expected state and the request fields). The `packet_transitions` and `packet_log` rows point at it.
|
|
|
|
## Concurrency
|
|
|
|
- **Workflow steps** are compare-and-set on `expect: {status, dept}` under the packet row lock (`SELECT ... FOR UPDATE`). When the packet moved on, the answer is 409 `stale_step` "Someone else already took this step." with the current status, department and version. There is no packet version check on a step, so a weld edit never blocks a send. Side effects run in the same transaction.
|
|
- **Keyed child rows** (BOM lines, Q.C. inspections, stage fields, welds, heat tags) take `If-Match: <row_version>`. A mismatch is 409 `row_conflict` with the current row.
|
|
- **Numbers** for welds, heat tags, pages and change orders come from counters on the packet row under the row lock, so concurrent adds never collide.
|
|
- **Header scalars** use field-level compare-and-set: the body sends `set` and `base`. A key whose base differs from the current value is 409 `field_conflict` with the current value. A key with no base is written as is.
|
|
- **Work orders** are a draft-only full replace with If-Match on the packet version.
|
|
- **Shorts and change-order actions** are state compare-and-set. Checklist keys are written one key at a time. The heat table position is an idempotent upsert. A change-order verification is an insert, so a second one is "already verified".
|
|
- **Append-only tables** never conflict: packet_transitions, packet_log, qc_history, short_receipts, project_renumbers, itp_rejects and co_verifications. Triggers refuse UPDATE, DELETE and TRUNCATE, and the app role has no grant for them.
|
|
|
|
## Reads: can, blocked and holder
|
|
|
|
Every packet read returns:
|
|
|
|
- `can`: the actions that pass now. Input-only preconditions are skipped, because they are what the user gives with the step.
|
|
- `blocked`: the actions that pass who and custody but fail a state precondition or the change-order hold, with every reason. The screen shows the control disabled with the reason.
|
|
- `holder`: `holder_name()` for the packet.
|
|
|
|
`can(principal, state, assume_it_reason=True) -> (list[Action], dict[Action, list[str]])`. For an admin the IT reason is assumed, so the list shows what the admin may do once they give one. `evaluate(principal, state, action)` returns the read-mode decision with `admin_bypass` set, so the screen knows to ask for the reason. System-only actions are never listed. CO_VERIFY needs a department in the request, so it is not listed; the change order list says which departments the person can verify.
|
|
|
|
## Tightenings against the old build
|
|
|
|
The old policy was looser than the old screens in places. The rebuild follows the screens where they were stricter, and the README where it states the intent. Each row is a change Seth can veto.
|
|
|
|
| What the old build allowed | What the rebuild does | Why | Can be vetoed |
|
|
|---|---|---|---|
|
|
| A department lead could write welds, BOM, heat tags, the checklist, stage fields and the clock through the policy whenever the packet was in a shop status. | These edits need the holder of the packet (the holder rule, applied as the old screens did). A lead of another department cannot. | The screens only offered these to the holder. The policy now matches. | Yes |
|
|
| Any worker in the department within their station limits could start or submit a packet (`worksOn` ignored who was assigned). | START and SUBMIT need the assigned welder for a worker. Leads and supervisors still can. | The README says only the assigned worker. | Yes |
|
|
| A status change could carry any other field in the same save. | A transition carries only its own fields (the step's inputs). Other changes go through their own edit actions and rules. | One rule per write keeps the check honest. | No |
|
|
| Clerical "lead" actions (approve an M-Files rev mismatch, move back from VISUAL review) were open to any Clerical member with a lead flag. | REV_APPROVE and moving back from VISUAL review need Clerical Supervisor (PS-Clerical-Supervisor). | There is no Clerical lead AD group. | Yes |
|
|
| A packet routed to Shipping had no path to filed. | Complete packets routed to Shipping get the same Clerical close and file path. Only the Data Book route needs the VISUAL close before filing. | Shipping packets were stuck at complete. | Yes |
|
|
| Anyone could set the data book date, even on a filed packet. | SET_DATABOOK_AT is Clerical or Q.C. only (open question 17). It stays allowed on a filed packet. | The data book date is a Clerical and Q.C. record. | Yes |
|
|
| Change-order verifications were stored in the packet document, with no rule against changing them later. | Verifications are insert-only and cannot be withdrawn (open question 18). | A signed verification is a record. | Yes |
|
|
| The old policy did not look at the BOM when the warehouse released a packet. | WH_RELEASE needs every BOM line not marked n/a to be pulled or to have an open short. | A packet should reach the shop with its material, or with the shorts on record. | Yes |
|
|
| A hand draft could leave Clerical with no drawing (the packet PDF upload stood in for it). | A hand draft needs a drawing attached before any send from Clerical. | There is no packet PDF upload in the rebuild. | Yes |
|
|
| Attaching drawing pages by hand needed no signature. | Attaching a drawing by hand needs a signature ("Attach a drawing by hand"). | It replaces what M-Files would supply, so a person stands behind it. | Yes |
|
|
| Admin could write anything on a filed packet with an IT reason. | Admin cannot write a filed packet except through ADMIN_REOPEN (with a reason and a signature), and SET_DATABOOK_AT. | A filed packet is sealed. A change breaks the seal on purpose, on record. | No |
|
|
| Admin passed every check after the change-order hold, so an admin could make a step from any status. | Admin goes through the same status check. The bypass covers who and custody only. Preconditions still apply. | Admin fixes data, not the workflow order. | Yes |
|