Aug 17 - Fix customer inspector out-of-scope issue assign

This commit is contained in:
2026-08-17 15:19:13 -04:00
parent 1de15a925b
commit fb85f7dc28
2 changed files with 134 additions and 10 deletions
+21 -1
View File
@@ -1484,6 +1484,24 @@ A **PDF Summary** button was added to `reports/scorecard.html` alongside the exi
- If `GROQ_API_KEY` is absent, input is disabled and a fallback "Submit to Support" link is shown (the reply is still persisted).
- "Submit to Support" modal POSTs to `POST /support/tickets`; subject pre-filled from last user message in history.
### Flag-issue assignee scoping (Aug 2026)
The "Assign to" dropdown in the flag-issue offcanvas was built from an **org-wide** query (`role in [director, inspector, external_inspector, ...]`), so a Customer Inspector could assign an issue to anyone in the system — including **another client's** Customer Inspector, who was then emailed the facility name and issue description. A cross-customer data leak, and the same leak in reverse whenever one of our own inspectors picked the wrong name.
`_assignable_staff_for(inspection, actor)` in `routes/inspections.py` is now the single source for that list:
| Role group | Scope |
|---|---|
| `inspector`, `external_inspector` | only those holding an `InspectorAssignment` on **this inspection's contract** — the same rows `get_inspector_scope()` reads, so the offered assignee can always open what they were given |
| `director`, `project_manager`, `auditor` | org-wide (they hold no `InspectorAssignment`, so contract-scoping would remove them entirely and break escalation) — but offered **only to our own staff** |
| inactive accounts | never offered |
So a **Customer Inspector sees only the inspectors on their own contracts** — their colleagues plus ours — and never our internal org chart. A facility with no contract yields no contract-scoped candidates: fail-closed, leaving "Unassigned" as the only option.
**Both call sites must use it.** `execute()` renders the dropdown; `flag_issue()` builds `form.assigned_to.choices`, which is what actually **validates the POST** — that is the security boundary, since the dropdown is only a UI hint. They were previously two hand-maintained queries that had already drifted: the offcanvas offered `project_manager` and `auditor` while the choices rejected them, so picking one **silently discarded the issue** (see below). One helper, one list, no drift.
**A failed flag-issue POST now returns 400, not 200.** The offcanvas JS treats `res.ok` as success and reloads the page, so a 200 on a validation failure means the inspector watches the panel close and believes the issue was logged when nothing was saved — rule 60's failure mode, reachable through the PM/auditor drift above and through any rejected assignee. The route returns the re-rendered form with **400** so the JS error branch fires, and flashes a specific message for an out-of-contract assignee rather than "Not a valid choice".
### Inspection Execute Page — UX Patterns
- **Photo upload-on-select**: `uploadPhotoField(input)` fires immediately on `<input type="file">` change. XHR to `POST /<id>/upload-photo`. On success, the server path is written to `<input type="hidden" id="field_<fid>_server_path">` and a `<img id="thumb_<fid>">` is shown.
@@ -1615,9 +1633,11 @@ timeout = 30
| 85 | **`next_due_date` is mutable state, `end_date` is a fixed boundary — never conflate them** | `fulfill()` rewrites `next_due_date` after every completed inspection; `end_date` is set by the manager and never touched by the app. The old single label "Start / Due Date" said both at once, which is what users reported as confusing. The label now follows context — `form.next_due_date.label.text` is set to "Start Date" in `create()` and "Next Due Date" in `edit()`. Do not rename the `next_due_date` column to match a label: it is indexed, it is the API payload key the iPad decodes, and the reminder cron filters on it. |
| 87 | **Never write `role == 'inspector'` — use `user.is_inspector` (`User.INSPECTOR_ROLES`)** | phase49 added `external_inspector`, which must behave as an inspector everywhere. An equality check silently drops it into the *privileged* branch of every `if inspector: scope … else: org-wide` block — i.e. a third-party inspector would see **every contract in the system**. This is a fail-OPEN mistake: nothing errors, the data just leaks. The sweep converted ~44 Python sites and 7 template sites; the only surviving `== 'inspector'` literals are the matrix docstring, the `MATRIX_DEFAULTS` mirror comprehension, and the default-checked box in `admin/broadcast.html`. Query-level checks use `User.role.in_(User.INSPECTOR_ROLES)` (never `filter_by(role='inspector')`). A **new** `app/api/*` blueprint's `_ALLOWED_ROLES` must include `external_inspector`, same as rule 79 requires for `auditor`. |
| 88 | **`app/enrollment/` writes no DB row and has exactly ONE read — keep the vertical slice sealed** | The enrollment form describes accounts that do NOT exist yet (no contract, facility or user to key a row against), so it stores flat JSON in `ENROLLMENT_DIR` and owns its own templates. The single permitted model access is `mailer._admin_recipients()` reading active `admin` users to address the new-enrollment alert — function-local, read-only, and guarded so a DB failure cannot break a submission. Adding a model/migration for enrollment, or letting the public POST **create** Users, would couple an unauthenticated endpoint to the account system — the exact thing the separation buys. If enrollment must ever provision accounts, do it as a separate admin-triggered action that reads a stored submission. Submission ids are filesystem paths: validate against `_ID_RE` before every open (path traversal). See §24. |
| 93 | **The flag-issue assignee list is contract-scoped, and BOTH call sites must use `_assignable_staff_for()`** | `execute()` renders the dropdown, `flag_issue()` builds the choices that validate the POST — the choices are the security boundary. Two separate queries had already drifted (offcanvas offered project_manager/auditor, choices rejected them), which silently discarded issues. Contract scoping applies to the two inspector roles for EVERY actor, not just customer ones: an org-wide list let anyone assign another client's Customer Inspector, who was then emailed that facility's name and issue description. Never widen this back to an unscoped `User.query.filter(role.in_(...))`. |
| 94 | **A failed flag-issue POST must return a non-2xx** | The offcanvas JS branches on `res.ok`, so a 200 re-render of the invalid form reads as success: the panel closes, the page reloads, and no issue exists — with nothing in the logs and no message to the user. `flag_issue()` returns 400 on a failed POST for exactly this reason. Any future AJAX-submitted form needs the same treatment (rule 60 is the same failure seen from the other end). |
| 91 | **A bulk-action form must live OUTSIDE the table; row checkboxes join it via the HTML5 `form=` attribute** | Wrapping the table in the bulk form nests the per-row delete/unfollow forms inside it, and browsers **silently discard** nested forms (rule 9) — the row buttons would post nothing, with no console error and no server log. `<form id="issuesBulkForm">` sits above the table and each checkbox carries `form="issuesBulkForm"`. Same for `inspectionsBulkForm`. Applies to all four list templates (classic + modern). |
| 92 | **Bulk deletes: DB rows first, storage files second** | Collect the keys, `db.session.delete()` every row, `commit()`, and only then `storage.delete()`. Deleting files first means a failed/rolled-back commit leaves surviving rows pointing at missing photos. `_collect_inspection_photos()` is shared by the single and bulk inspection delete paths precisely so the two cannot drift — a key missed there is an invisible permanent storage leak. |
| 89 | **`User.CUSTOMER_ROLES` is for ACCOUNT MANAGEMENT; `role == 'customer'` is for CAPABILITY — never swap them** | The inverse of rule 87, and it fails in both directions. Widening a capability check to `CUSTOMER_ROLES` hands a third-party Customer Inspector the customer portal (fail-OPEN, nothing errors). Narrowing an account-management check back to `'customer'` strands every Customer Inspector in a page that no longer lists or edits them (fail-closed, but invisible until someone looks for a missing account). `CUSTOMER_ROLES` / `is_customer_account` appear ONLY in: the `/customers` list query, its route guards, and the `auth.list_users` exclusion. Everything else — portal gates, `@customer_required`, `get_customer_scope()`, support chat, `notify_customers_for_facility()`, the customer branch of every `app/api/*` scope check — keeps the equality test, because a Customer Inspector is an **inspector** there (rule 87 already routes it correctly). |
| 89 | **`User.CUSTOMER_ROLES` is for ACCOUNT MANAGEMENT; `role == 'customer'` is for CAPABILITY — never swap them** | The inverse of rule 87, and it fails in both directions. Widening a capability check to `CUSTOMER_ROLES` hands a third-party Customer Inspector the customer portal (fail-OPEN, nothing errors). Narrowing an account-management check back to `'customer'` strands every Customer Inspector in a page that no longer lists or edits them (fail-closed, but invisible until someone looks for a missing account). `CUSTOMER_ROLES` / `is_customer_account` appear ONLY in: the `/customers` list query, its route guards, the `auth.list_users` exclusion, and **narrowing** uses that WITHHOLD something from an external account (`_assignable_staff_for()` uses it to hide our internal staff — safe direction, and commented as such). Everything else — portal gates, `@customer_required`, `get_customer_scope()`, support chat, `notify_customers_for_facility()`, the customer branch of every `app/api/*` scope check — keeps the equality test, because a Customer Inspector is an **inspector** there (rule 87 already routes it correctly). |
| 90 | **A per-account notification opt-IN must survive a globally-OFF column** | `notify_by_matrix()` skips a role column early when the matrix says off. For the two customer columns that early `continue` has to also ask whether anyone opted in (`any(overrides.values())`), or the override saves, displays as on, and never sends — a silent failure with no error anywhere. Equally, `notify_customers_for_facility()` re-queries recipients from assignment rows, so `notify_by_matrix()` must hand it `allowed_user_ids` or the facility-scoped path bypasses every override. Both halves are needed; either one alone leaves a hole. See §11. |
| 81 | **Photo timestamp/geo overlay is burned at UPLOAD, never on `PATCH /issues/<id>/photos`** | That PATCH receives only path strings — the bytes are already in storage and the payload carries no capture metadata. Burning there would need a read-modify-write per key plus an overwrite-in-place primitive (`storage.save()` mints a NEW uuid key, and §22 requires key == DB path), and would risk a **double burn** since the endpoint is deliberately idempotent/retry-safe (rule 45). Stamp in `POST /photos/upload`, where the raw bytes + EXIF are in hand and each call writes exactly one already-stamped object. Stamping failures must always fall back to storing the ORIGINAL bytes — never lose a photo to a stamping bug. See §23. |