docs(guide): incident-management per-institution ownership mode (#221)

Document the MSP_MANAGED/CUSTOMER_MANAGED incident ownership mode across the
Claude/Internal/Architect/Testing/Customer tabs: the write matrix, the
/institution-keys/{key}/incident-mode endpoint + MSP toggle, controller-layer
assertWritable enforcement, IncidentDTO.institution, V90 migration, and the
known UI follow-up (customer nav still hidden).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 06:04:57 -04:00
parent be2c162d11
commit 7dcc910389
5 changed files with 76 additions and 6 deletions
@@ -41,6 +41,24 @@ audience: dev
`PARKED` is a first-class status: while any incident for a device is `PARKED`, `IncidentAutoCreationService` suppresses new auto-incidents for that device (#219). `PARKED` is a first-class status: while any incident for a device is `PARKED`, `IncidentAutoCreationService` suppresses new auto-incidents for that device (#219).
## Authorization — per-institution ownership mode (#221)
Incident writes are authorized in two layers:
1. **Coarse role gate** — every mutating endpoint carries `@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN','CUSTOMER')")`.
2. **Per-institution mode** — the controller then calls `IncidentService.assertWritable(incident)` (or `assertCreatable(atmId)` for manual create), which resolves the incident's institution name → `institution_keys.incident_management_mode` and applies:
| Mode | Writers |
|------|---------|
| `MSP_MANAGED` (default) | BCOS_ADMIN, MSP_ADMIN |
| `CUSTOMER_MANAGED` | BCOS_ADMIN (override), that institution's CUSTOMER users |
Design notes:
- The guard is invoked **only from the HTTP controllers** (`IncidentController`, `IncidentManagementController`) — deliberately **not** from `saveIncident`/`createIncident`, which the Kafka/journal auto-creation path (`IncidentAutoCreationService`, `AriaThreatEventConsumer`) also calls with no user role in context.
- The mode column lives in incident's **own** `institution_keys` copy (incident-domain setting → stays inside the incident service; the canonical institution record in hiveops-devices is untouched). The MSP admin UI writes it via `PUT /api/institution-keys/{key}/incident-mode` (upsert, since incident's copy can lag devices').
- `IncidentDTO.institution` (institution name) is surfaced so the SPA can gate action controls and show a Read-only badge; `institutionKeyAPI.getAccessible()` returns `incidentManagementMode` for the client's institution→mode map.
- Migration: `V90__institution_incident_mode.sql` (`ALTER TABLE institution_keys ADD COLUMN incident_management_mode VARCHAR(20) NOT NULL DEFAULT 'MSP_MANAGED'`).
## Kafka — incident lifecycle (producer side) ## Kafka — incident lifecycle (producer side)
`IncidentLifecycleProducer` publishes on create and update (config `IncidentLifecycleKafkaConfig`, 3 partitions each): `IncidentLifecycleProducer` publishes on create and update (config `IncidentLifecycleKafkaConfig`, 3 partitions each):
@@ -28,11 +28,27 @@ audience: dev
| DELETE | `/api/incident-management/{id}/link/{linkedId}` | MSP_ADMIN/BCOS_ADMIN | | DELETE | `/api/incident-management/{id}/link/{linkedId}` | MSP_ADMIN/BCOS_ADMIN |
| GET | `/api/incident-management/{id}/links` · `/audit-trail` · `/stats/summary` | any auth | | GET | `/api/incident-management/{id}/links` · `/audit-trail` · `/stats/summary` | any auth |
| GET | `/api/incident-management/technicians/available` | any auth | | GET | `/api/incident-management/technicians/available` | any auth |
| PUT | `/api/institution-keys/{key}/incident-mode` (set ownership mode, #221) | MSP_ADMIN/BCOS_ADMIN |
† Mutating rows list the `@PreAuthorize` gate. Since #221 that gate also admits `CUSTOMER`, and the real write decision is the per-institution **ownership mode** — see *Roles & per-institution ownership mode* below.
Bulk close body: `{ "incidentIds": ["1","2"], "newStatus": "CLOSED" }` (ids are strings). Bulk close body: `{ "incidentIds": ["1","2"], "newStatus": "CLOSED" }` (ids are strings).
## Roles ## Roles & per-institution ownership mode (#221)
- Every mutation: `@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')")`. Reads: authenticated only. Reads: authenticated only. Mutations are gated in **two layers**:
1. `@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN','CUSTOMER')")` on every mutating endpoint.
2. Service-layer `IncidentService.assertWritable(incident)` / `assertCreatable(atmId)` then enforces the
institution's **incident ownership mode** (`institution_keys.incident_management_mode`):
| Mode | BCOS_ADMIN | MSP_ADMIN | CUSTOMER (own institution) |
|------|-----------|-----------|----------------------------|
| `MSP_MANAGED` (default) | ✅ | ✅ | ❌ read-only |
| `CUSTOMER_MANAGED` | ✅ (override) | ❌ read-only | ✅ |
- Mode is resolved from the incident's institution **name** (`incident.getAtm().getInstitution()`), looked up in incident's own `institution_keys` copy; unknown institution ⇒ `MSP_MANAGED` (unchanged behaviour).
- Set it: `PUT /api/institution-keys/{key}/incident-mode` body `{"incidentManagementMode":"MSP_MANAGED"|"CUSTOMER_MANAGED"}` (MSP_ADMIN/BCOS_ADMIN; **upserts** incident's institution_keys row). Surfaced in the **MSP app** → Institution Keys → "Incident management".
- `IncidentDTO.institution` carries the institution name so the frontend can gate controls (hidden actions + a **Read-only** badge in the detail panel).
- The guard lives at the **controller layer only** — NOT in `saveIncident`/`createIncident`, which Kafka/journal auto-creation also uses, so auto-created incidents are unaffected.
## Tables (`hiveiq_incident`) ## Tables (`hiveiq_incident`)
- `incidents`, `incident_events`, `incident_notes`, `workflow_transitions`, `atms`, `technicians`, `helpdesk_persons`, `device_summary` (Kafka mirror), `incident_embeddings`. - `incidents`, `incident_events`, `incident_notes`, `workflow_transitions`, `atms`, `technicians`, `helpdesk_persons`, `device_summary` (Kafka mirror), `incident_embeddings`.
@@ -50,6 +66,7 @@ Bulk close body: `{ "incidentIds": ["1","2"], "newStatus": "CLOSED" }` (ids are
- Transition requires a valid `workflow_transitions` edge; if `requiresAssignment`, assignee must be set first (else 400). - Transition requires a valid `workflow_transitions` edge; if `requiresAssignment`, assignee must be set first (else 400).
- Closing blocked by `assertNoUnfixedChildren()` if linked children unfixed. - Closing blocked by `assertNoUnfixedChildren()` if linked children unfixed.
- **Possible Fraud note-before-close is FRONTEND-only** (`IncidentList.svelte` fraud-close modal) — NOT enforced by the API. - **Possible Fraud note-before-close is FRONTEND-only** (`IncidentList.svelte` fraud-close modal) — NOT enforced by the API.
- **MSP_ADMIN gets 403 on a `CUSTOMER_MANAGED` institution's incidents — this is intended** (#221), not a bug. Check the institution's `incident_management_mode`; only BCOS_ADMIN overrides it. Conversely a CUSTOMER *can* now write their own incidents when their institution is `CUSTOMER_MANAGED`.
- Agents never call this service; they go via **hiveops-agent-proxy** → `/api/internal/**` (`X-Internal-Secret`). - Agents never call this service; they go via **hiveops-agent-proxy** → `/api/internal/**` (`X-Internal-Secret`).
- `device_summary` is a read-only Kafka mirror from hiveops-devices; no reconciler → stale = fix upstream. - `device_summary` is a read-only Kafka mirror from hiveops-devices; no reconciler → stale = fix upstream.
- `DB_URL` default points at a stale `hiveops_incident` DB — prod overrides to `hiveiq_incident`. - `DB_URL` default points at a stale `hiveops_incident` DB — prod overrides to `hiveiq_incident`.
@@ -57,7 +74,8 @@ Bulk close body: `{ "incidentIds": ["1","2"], "newStatus": "CLOSED" }` (ids are
## Do NOT ## Do NOT
- Do NOT add device/institution writes here — device data is owned by hiveops-devices. - Do NOT add device/institution writes here — device data is owned by hiveops-devices.
- Do NOT assume the fraud-note rule protects direct API closes — it doesn't. - Do NOT assume the fraud-note rule protects direct API closes — it doesn't.
- Do NOT expect UPDATE/transition without an admin JWT — reads succeed, writes 403. - Do NOT assume "MSP_ADMIN can always write" — since #221, a `CUSTOMER_MANAGED` institution makes MSP_ADMIN read-only (only BCOS_ADMIN overrides); and CUSTOMER can write when their institution is customer-managed.
- Do NOT move the write-mode guard into `saveIncident`/`createIncident` — it would 403 Kafka/journal auto-creation (no user role in context). Keep it at the controller layer.
- Do NOT split `DB_URL` into host/port/name. - Do NOT split `DB_URL` into host/port/name.
- Do NOT create incidents for a parked device expecting auto-flow — parking silences it. - Do NOT create incidents for a parked device expecting auto-flow — parking silences it.
- Do NOT invent transition endpoints per-status (start/resolve/close); all go through one `POST /{id}/transition` with `newStatus`. - Do NOT invent transition endpoints per-status (start/resolve/close); all go through one `POST /{id}/transition` with `newStatus`.
@@ -6,11 +6,11 @@ order: 20
audience: internal audience: internal
--- ---
> Ops/support/admin view for the Incident Management screen (`IncidentList.svelte`). Served by **hiveops-incident** (port 8080, NGINX `/api/incident/`). All facts below grounded in source as of 2026-07-01. > Ops/support/admin view for the Incident Management screen (`IncidentList.svelte`). Served by **hiveops-incident** (port 8080, NGINX `/api/incident/`). All facts below grounded in source as of 2026-07-03.
## Who can do what ## Who can do what
Read/list is open to any authenticated user. **Every mutating action requires `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`** (`@PreAuthorize` on `IncidentManagementController`): Read/list is open to any authenticated user. Every mutating action is gated by `@PreAuthorize` **and** by the institution's **incident ownership mode** (#221 — see below):
| Action | Endpoint | Role gate | | Action | Endpoint | Role gate |
|--------|----------|-----------| |--------|----------|-----------|
@@ -21,7 +21,24 @@ Read/list is open to any authenticated user. **Every mutating action requires `h
| Link / unlink / group | `POST /api/incident-management/{id}/link` · `DELETE .../link/{linkedId}` · `POST .../group` | admin | | Link / unlink / group | `POST /api/incident-management/{id}/link` · `DELETE .../link/{linkedId}` · `POST .../group` | admin |
| Create incident (manual) | `POST /api/incident-management` or `POST /api/incidents` | admin | | Create incident (manual) | `POST /api/incident-management` or `POST /api/incidents` | admin |
If an admin reports "buttons do nothing / 403 on save," first confirm their JWT actually carries `MSP_ADMIN` or `BCOS_ADMIN` — a plain customer role can load the list but cannot transition or assign. If an admin reports "buttons do nothing / 403 on save," first confirm their JWT carries `MSP_ADMIN`/`BCOS_ADMIN` — **and** check the institution's ownership mode (below), because a `CUSTOMER_MANAGED` institution deliberately makes MSP admins read-only.
## Per-institution incident ownership mode (#221)
Each institution has an **incident ownership mode** (`institution_keys.incident_management_mode`) deciding who may work its incidents:
| Mode | BCOS_ADMIN | MSP_ADMIN | Customer (own institution) |
|------|-----------|-----------|----------------------------|
| **MSP-managed** (`MSP_MANAGED`, default) | ✅ | ✅ | read-only |
| **Customer-managed** (`CUSTOMER_MANAGED`) | ✅ (override) | **read-only** | ✅ |
Use *Customer-managed* when an institution runs its own fleet/help-desk and just needs HiveIQ as a view (the AOCU case). BCOS_ADMIN always retains write as a platform override.
- **Set it:** in the **MSP app** → *Institution Keys* → edit an institution → **"Incident management"** dropdown (a mode badge also shows in the list). API: `PUT /api/institution-keys/{key}/incident-mode` `{"incidentManagementMode":"…"}`.
- **In the incident UI**, non-writers see a **Read-only** badge in the detail panel and the status/assign/notes/bulk/add controls are hidden; the backend also returns **403** on direct API calls (`assertWritable`).
- Reads are never affected — everyone in scope still sees the incidents.
> "MSP admin says a whole institution went read-only" → that institution is `CUSTOMER_MANAGED`. Intended. Change it back via the MSP toggle, or act as BCOS_ADMIN.
## First things to check when it misbehaves ## First things to check when it misbehaves
@@ -33,6 +33,9 @@ Your working list of everything that needs attention across your fleet. Use this
1. Tick the checkboxes on the incidents you want. 1. Tick the checkboxes on the incidents you want.
2. **Close** them in bulk, or **🔗 Link** two or more together (they must belong to the same device). 2. **Close** them in bulk, or **🔗 Link** two or more together (they must belong to the same device).
## Who manages your incidents
Each institution is set to either **MSP-managed** (your managed-service provider works the incidents on your behalf) or **Customer-managed** (your own team owns them). If your institution is managed by your provider, incidents appear **read-only** to you — you can still view everything, but changes are made by your provider. Your BCOS administrator sets this per institution.
## What you're looking at ## What you're looking at
- **ATM** — the device the incident belongs to. - **ATM** — the device the incident belongs to.
- **Type** — what kind of problem it is (e.g. Card Reader Fail, Cassette Low). - **Type** — what kind of problem it is (e.g. Card Reader Fail, Cassette Low).
@@ -34,4 +34,18 @@ Incidents are auto-raised from the simulator devices — **C1-ATM-001…**, **C1
| 9 | As `msp_a` scan the ATM column; then log in as `bcos_a` and compare | `msp_a` sees only **C1 and C2** devices (no C3); `bcos_a` sees **C1, C2 and C3** | | 9 | As `msp_a` scan the ATM column; then log in as `bcos_a` and compare | `msp_a` sees only **C1 and C2** devices (no C3); `bcos_a` sees **C1, C2 and C3** |
| 10 | Log in as `customer_a` and look at the left sidebar | There is **no Incident Mgmt menu** — read-only customers cannot open this list | | 10 | Log in as `customer_a` and look at the left sidebar | There is **no Incident Mgmt menu** — read-only customers cannot open this list |
## Per-institution ownership mode (#221)
Each institution can be **MSP-managed** (default: MSP works incidents, customer read-only) or **Customer-managed** (the institution's own customer users work incidents, MSP read-only; BCOS_ADMIN always overrides). Set the mode in the **MSP app** → *Institution Keys* → **"Incident management"**.
| # | Do this | ✅ Pass if |
|---|---------|-----------|
| 11 | In the **MSP app**, open *Institution Keys* | Each row shows an **Incidents** badge — *MSP-managed* or *Customer-managed* |
| 12 | Edit an institution (e.g. **C1BD**), set **Incident management → Customer-managed**, save | Saves without error; the row badge flips to **Customer-managed** |
| 13 | As `msp_a`, open a **C1BD** incident (now customer-managed) | Detail panel shows a **Read-only** badge; the status/assign/notes/bulk/**+ Add** controls are **hidden**. A **C2BD** incident still shows all controls |
| 14 | As `msp_a`, try to change a C1BD incident via API `POST /api/incident-management/{id}/notes` | Returns **403** (MSP is read-only for a customer-managed institution) |
| 15 | Set C1BD back to **MSP-managed** in the MSP app | `msp_a` regains full controls on C1BD incidents |
> **Known limitation (follow-up):** customer-role users still have no Incident Mgmt menu (`App.svelte` hides it for all customers), so in Customer-managed mode a customer can currently only work incidents via the API, not the UI. Exposing the list to customer-managed customers is tracked as follow-up to #221.
**Report a fail with:** the row #, which login you used, and what you actually saw. **Report a fail with:** the row #, which login you used, and what you actually saw.