From 7dcc9103896cfe2411ded98518d22b0fb28727dd Mon Sep 17 00:00:00 2001 From: Johannes Date: Sun, 5 Jul 2026 06:04:57 -0400 Subject: [PATCH] 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 --- .../incident/incident-management/architect.md | 18 ++++++++++++++ .../incident/incident-management/claude.md | 24 ++++++++++++++++--- .../incident/incident-management/internal.md | 23 +++++++++++++++--- .../incident/incident-management/overview.md | 3 +++ .../incident/incident-management/testing.md | 14 +++++++++++ 5 files changed, 76 insertions(+), 6 deletions(-) diff --git a/backend/src/main/resources/content/incident/incident-management/architect.md b/backend/src/main/resources/content/incident/incident-management/architect.md index 576a893..8283e4b 100644 --- a/backend/src/main/resources/content/incident/incident-management/architect.md +++ b/backend/src/main/resources/content/incident/incident-management/architect.md @@ -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): diff --git a/backend/src/main/resources/content/incident/incident-management/claude.md b/backend/src/main/resources/content/incident/incident-management/claude.md index da013dd..e6fbc30 100644 --- a/backend/src/main/resources/content/incident/incident-management/claude.md +++ b/backend/src/main/resources/content/incident/incident-management/claude.md @@ -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`. diff --git a/backend/src/main/resources/content/incident/incident-management/internal.md b/backend/src/main/resources/content/incident/incident-management/internal.md index ca0c884..4dd530f 100644 --- a/backend/src/main/resources/content/incident/incident-management/internal.md +++ b/backend/src/main/resources/content/incident/incident-management/internal.md @@ -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 diff --git a/backend/src/main/resources/content/incident/incident-management/overview.md b/backend/src/main/resources/content/incident/incident-management/overview.md index ed4672b..42e1c6c 100644 --- a/backend/src/main/resources/content/incident/incident-management/overview.md +++ b/backend/src/main/resources/content/incident/incident-management/overview.md @@ -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). diff --git a/backend/src/main/resources/content/incident/incident-management/testing.md b/backend/src/main/resources/content/incident/incident-management/testing.md index dd24a05..5dc9c37 100644 --- a/backend/src/main/resources/content/incident/incident-management/testing.md +++ b/backend/src/main/resources/content/incident/incident-management/testing.md @@ -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.