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).
## 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)
`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 |
| GET | `/api/incident-management/{id}/links` · `/audit-trail` · `/stats/summary` | 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).
## Roles
- Every mutation: `@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')")`. Reads: authenticated only.
## Roles & per-institution ownership mode (#221)
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`)
- `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).
- 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.
- **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`).
- `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`.
@@ -57,7 +74,8 @@ Bulk close body: `{ "incidentIds": ["1","2"], "newStatus": "CLOSED" }` (ids are
## Do NOT
- 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 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 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`.
@@ -6,11 +6,11 @@ order: 20
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
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 |
|--------|----------|-----------|
@@ -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 |
| 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
@@ -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.
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
- **ATM** — the device the incident belongs to.
- **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** |
| 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.