Merge pull request 'docs(guide): add fleet.task-permissions module (#23)' (#24) from feat/23-fleet-task-permissions-module into develop
CD - Develop (guide → bcos.dev) / build-and-deploy (push) Has started running

This commit was merged in pull request #24.
This commit is contained in:
2026-07-16 14:49:32 -04:00
5 changed files with 336 additions and 0 deletions
@@ -0,0 +1,69 @@
---
module: fleet.task-permissions
title: Task Permissions Guide
tab: Architect
order: 30
audience: bcos
---
Design and rationale of the per-institution task allowlist (#119).
## The Problem
`POST /api/fleet/tasks` was ADMIN|MSP_ADMIN|BCOS_ADMIN only, so a CUSTOMER got a 403 and could not reboot a machine they own. AICU and CBPA needed that, and more institutions will as we onboard.
Granting it to every customer was not acceptable: **a fleet task is remote code execution against an ATM.** So the answer is an explicit, per-institution, per-command allowlist.
## Shape
```
institution_task_permissions
(institution_key, task_kind) UNIQUE ← one row per grant
```
One row = "institution X may push command Y". No row = no permission. There is no `denied` column and no `enabled` flag, because **the absence of a row is the denial** — a deny-by-default model has no ambiguous middle state to get wrong.
Keyed on `institution_key`, the multi-tenant key (#290) that fleet already scopes `device_summary` by. Never a display name.
## Two Independent Gates
Role and grant are **not** the same check, and neither subsumes the other:
1. `@PreAuthorize("hasAnyRole('CUSTOMER','ADMIN','MSP_ADMIN','BCOS_ADMIN')")` on `POST /tasks` — decides *may you call this endpoint at all*.
2. `FleetTaskPermissionService.assertMayPush(kind)` — decides *may you push this particular kind*.
Role alone is not the authorisation. A CUSTOMER passes the first gate and can still be refused by the second. This split is why the endpoint annotation looks permissive: it is not the security boundary on its own.
## Two Layers of Refusal
`assertMayPush` refuses on two distinct grounds, with different meanings:
| Condition | Response | Meaning |
|---|---|---|
| kind ∉ `GRANTABLE` | 403 *"'X' can never be pushed by a customer."* | Structural. No setting can enable this. |
| kind ∈ `GRANTABLE` but not granted | 403 *"Your institution is not permitted to push 'X'. Ask your service provider to enable it."* | Configurable. An MSP admin can tick the box. |
The distinction matters: the first tells the caller to stop asking, the second tells them who to ask.
`setGrants` enforces the same boundary at write time — anything outside `GRANTABLE` is a **400**, not a silently-dropped row. The allowlist cannot be widened through the API.
## GRANTABLE Is Code, Not Config
```java
public static final Set<FleetTaskKind> GRANTABLE =
Collections.unmodifiableSet(EnumSet.of(FleetTaskKind.REBOOT, FleetTaskKind.RESTART_AGENT));
```
Deliberately narrow: the operations on the device Command Center, the customer-facing command surface. `CONFIG_UPDATE` can brick every ATM an institution owns, `INSTALL_MODULE` runs arbitrary code on them, `UPDATE_AGENT` is fleet-wide agent replacement, `PACKET_CAPTURE` is a data-exfiltration path.
Living in code rather than a table is the point: **widening the blast radius requires a commit and a review**, not a checkbox someone can tick at 2am. `SCRIPT_EXECUTION` belongs here when it lands — add the enum value and list it, no schema change.
## Replace, Not Merge
`setGrants` is wholesale: `deleteByInstitutionKey` → `flush` → re-insert the requested set. `PUT /task-permissions/{key}` therefore takes the **complete desired state**, not a delta.
The UI reflects this — toggling one checkbox sends the whole array. Two admins editing the same institution concurrently is last-write-wins, and the loser's grant disappears with no error. Acceptable for a low-frequency admin setting; worth knowing before blaming the database.
## Scope Resolution
`grantsForCurrentCaller()` is the UI's honesty mechanism, letting a customer UI hide commands it would only 403 on:
- `resolveScope() == null` (ADMIN/BCOS_ADMIN, unrestricted) → **all kinds**
- not a CUSTOMER (MSP_ADMIN — scoped, but not gated) → **all kinds**
- CUSTOMER → **union of grants across their institutions** (in practice, their single institution)
This is why `GET /task-permissions/mine` carries no `@PreAuthorize`: it is deliberately open, returns only the caller's own grants, and leaks nothing about other institutions.
## Audit
`granted_by` records the authenticated principal name at write time; `granted_at` defaults to `now()`. Because grants are replaced wholesale, `granted_at` is the time of the **last edit to that institution's set**, not the time that specific permission was first given. There is no revocation history — the row is deleted, not tombstoned. If we ever need "who took Reboot away from AICU and when", this table cannot answer it today.
@@ -0,0 +1,70 @@
---
module: fleet.task-permissions
title: Task Permissions Guide
tab: Claude
order: 40
audience: bcos
---
Act-without-guessing cheat-sheet. Everything confirmed in `hiveops-fleet` source (#119).
## Ownership
- **Service:** `hiveops-fleet` ONLY (`com.hiveops.fleet`, port 8097, DB `hiveiq_fleet`).
- **Authority:** fleet's `institution_task_permissions` table. **NOT** mgmt, **NOT** devices, **NOT** auth. No mirror, no Kafka topic — this state is read only by fleet, in-process.
- **UI:** `hiveops-fleet/frontend/src/components/FleetManagement/TaskPermissions.svelte`, tab id `permissions`, admin-only.
## Table (DB `hiveiq_fleet`)
| Table | Key columns |
|-------|-------------|
| `institution_task_permissions` | `id, institution_key (VARCHAR 50), task_kind (VARCHAR 40, enum STRING), granted_by, granted_at` · UNIQUE `(institution_key, task_kind)` · INDEX `idx_itp_institution_key` |
Migration `V9__institution_task_permissions.sql`. One row = one grant. **No row = denied** — there is no `enabled` flag to check.
## Endpoints (base `/api/fleet`)
| Method + Path | Roles | Notes |
|---|---|---|
| GET `/task-permissions` | MSP_ADMIN, BCOS_ADMIN | every institution's grants, keyed by institution |
| GET `/task-permissions/grantable` | MSP_ADMIN, BCOS_ADMIN | drives the UI columns |
| GET `/task-permissions/mine` | **none — open by design** | caller's own grants only |
| PUT `/task-permissions/{institutionKey}` | MSP_ADMIN, BCOS_ADMIN | **replaces** the whole set |
Verified live on bcos.dev: CUSTOMER → 403/403/200/403; MSP_ADMIN → 200/200/200/200.
`/mine` having no `@PreAuthorize` is **intentional**, not an oversight. Do not "fix" it.
## GRANTABLE — the security boundary
```java
GRANTABLE = EnumSet.of(FleetTaskKind.REBOOT, FleetTaskKind.RESTART_AGENT);
```
`FleetTaskPermissionService.GRANTABLE`, code not config. The other 13 `FleetTaskKind` values (`CONFIG_UPDATE`, `INSTALL_MODULE`, `UPDATE_AGENT`, `PACKET_CAPTURE`, `HOTFIX`, `HOTFIX_INSTALL`, `SOFTWARE`, `SOFTWARE_INSTALL`, `LOG_COLLECT`, `LOG_COLLECT_PATH`, `RUN_SCRIPT`, `UPDATE_SCRIPT`, `AUTO_UPDATE`) are **never** grantable.
**Do not widen `GRANTABLE` on your own initiative.** It is deliberately narrow and widening it is an explicit decision with review — not a side effect of a ticket asking to let some institution do one more thing. `SCRIPT_EXECUTION` is expected to join it when it lands: add the enum value + list it, no schema change.
## The gate — two checks, not one
`POST /api/fleet/tasks` (`FleetApiController.java:176-179`):
```java
@PreAuthorize("hasAnyRole('CUSTOMER','ADMIN','MSP_ADMIN','BCOS_ADMIN')") // may you call it
taskPermissionService.assertMayPush(kind); // may you push THIS kind
```
**Role alone is NOT the authorisation.** The permissive-looking annotation is not the boundary; `assertMayPush` is. If you are reasoning about who can reboot, read the service, not the annotation.
`assertMayPush` (`FleetTaskPermissionService.java:106`):
1. not a CUSTOMER → return immediately. **Admins and MSP_ADMINs are ungoverned by this table.**
2. kind ∉ GRANTABLE → 403 *"can never be pushed by a customer"*
3. not granted → 403 *"Ask your service provider to enable it"*
Both are 403 with different messages — 403s, never silent drops.
## Gotchas
- **PUT replaces, never merges.** `deleteByInstitutionKey` → `flush` → re-insert. Send complete desired state. Concurrent admin edits are last-write-wins with no error.
- **Only CUSTOMER is gated.** "Admin can still reboot with nothing ticked" is correct, not a bug.
- **`grantsForCurrentCaller()` returns ALL kinds for non-CUSTOMERs** (`resolveScope() == null` → unrestricted; MSP_ADMIN → scoped but not gated). Do not read it as "what this user has been granted" for an admin.
- **Illegal kinds are 400 at write time**, not filtered silently. The allowlist can't be widened through the API.
- **No revocation history.** Rows are deleted, not tombstoned; `granted_at` is the last edit to the institution's whole set, not when that kind was granted. "Who removed Reboot from AICU?" is unanswerable today.
- **Frontend hiding is cosmetic.** `App.svelte:37` `isAdmin = MSP_ADMIN || BCOS_ADMIN` filters the nav item; the backend does the real work.
- **The SPA has no login page and no auth interceptor.** The HiveIQ Browser injects the token at Electron session level. In a plain browser `/users/me` 401s → `userRole` null → admin tabs vanish from the sidebar. Not a permissions bug.
## Changing this area
- Adding a grantable kind → `FleetTaskKind` enum + `GRANTABLE` + this guide. No migration.
- The UI reads `grantable` to build its columns, so a new kind appears as a column automatically.
- Touching the gate → re-run the Testing tab against bcos.dev, especially the 400-on-`CONFIG_UPDATE` case. A 200 there is a critical security finding.
@@ -0,0 +1,55 @@
---
module: fleet.task-permissions
title: Task Permissions Guide
tab: Internal
order: 20
audience: internal
---
Support-side view: what to check when a customer cannot push a command, and what we must never widen.
## Where It Lives
- **Service:** `hiveops-fleet` only (port 8097, DB `hiveiq_fleet`).
- **UI:** Fleet Mgmt → 🔐 Task Permissions. Admin-only submenu item.
- **Table:** `institution_task_permissions` in `hiveiq_fleet`.
## The Model in One Line
**Deny by default.** The absence of a row *is* the denial. An institution with no rows can push nothing, and nothing changed for anyone when #119 shipped — every institution started empty.
## Only Customers Are Gated
This is the point people get wrong. The gate applies to **CUSTOMER role only**:
| Role | Effect of these settings |
|---|---|
| CUSTOMER | Gated — may push only what their institution has been granted |
| MSP_ADMIN | **Not gated** — full command set, scoped to their institutions |
| ADMIN / BCOS_ADMIN | **Not gated** — full command set, unrestricted |
So "I ticked nothing and admins can still reboot" is correct behaviour, not a bug.
## Triage: "Customer cannot reboot"
1. **Check the grant.** Fleet Mgmt → Task Permissions → is **Reboot** ticked for their institution key?
2. **Check the role.** Are they actually CUSTOMER? An MSP_ADMIN is never gated, so if they are blocked the cause is elsewhere.
3. **Check the institution.** A customer can only target devices in their own institution. Wrong institution reads as a permission problem but is a scope problem.
4. **Read the error.** The API is explicit rather than silent:
- *"Your institution is not permitted to push 'REBOOT'. Ask your service provider to enable it."* → no grant. Tick the box.
- *"'CONFIG_UPDATE' can never be pushed by a customer."* → they are asking for a non-grantable command. Nothing to tick; this one is by design.
> A customer who presses Reboot and gets a cheerful nothing is worse than one who is told they lack permission — that is why these are 403s with messages, not silent drops.
## What We Will Never Grant
Only **REBOOT** and **RESTART_AGENT** are grantable. The rest are operator powers and are refused even if someone tries to set them through the API:
| Command | Why it is not grantable |
|---|---|
| `CONFIG_UPDATE` | Can brick every ATM an institution owns |
| `INSTALL_MODULE` | Runs arbitrary code on them |
| `UPDATE_AGENT` | Fleet-wide agent replacement |
| `PACKET_CAPTURE` | A data-exfiltration path |
Widening that set is a deliberate decision with a code change and a review — not a config toggle, and not a side effect of "let AICU reboot a machine". If a customer asks for more, it is an engineering conversation, not a support one.
## Escalate If
- A CUSTOMER successfully pushes something outside REBOOT/RESTART_AGENT. That is a security incident, not a bug.
- A customer can command a device outside their institution. Same.
- Grants vanish on their own. Setting an institution's grants **replaces** the whole set (see the Architect tab) — suspect a concurrent admin edit before suspecting the database.
@@ -0,0 +1,50 @@
---
module: fleet.task-permissions
title: Task Permissions Guide
tab: Customer
order: 10
audience: customer
---
Which commands each institution's customer users may send to their own devices.
## 🔐 What Task Permissions Do
1. A fleet task is a command sent to a real ATM — a reboot is a machine out of service for minutes.
2. By default a customer user can send **nothing**. Permission does not exist until you grant it.
3. A grant is per **institution** and per **command**: "Customer 1 may reboot", not "customers may reboot".
4. A customer can only ever target devices belonging to their own institution. Granting Reboot to one institution gives them no reach into another.
> **Nothing is permitted until you grant it.** An institution with no ticks can push no commands at all — that is the safe default, and it is where every institution starts.
## ✅ Grant a Command
1. Find the institution's row. The name is shown with its **institution key** underneath.
2. Tick the box under the command you want to allow.
3. The change saves immediately — a green confirmation names the institution.
There is no Save button and no undo. Ticking a box grants the permission the moment you click it, and unticking revokes it just as fast.
## 🚫 Revoke a Command
1. Untick the box.
2. The permission is gone immediately. The next time that customer tries the command they are told they lack permission.
> Revoking does **not** cancel tasks already in flight. A reboot that was already accepted will still run.
## 🧭 Which Commands Can Be Granted
Only two commands can be granted to a customer at any setting:
| Command | What it does |
|---|---|
| **Reboot** | Restarts the ATM |
| **Restart Agent** | Restarts the HiveIQ agent process on the ATM |
These are the operations on the device Command Center. Everything else — configuration changes, module installs, agent updates, packet capture — is an operator power and is never offered here, no matter what you tick. If a command you expect is missing from this page, it is missing by design.
## 👤 Who Can See This Page
Task Permissions is visible only to **MSP admins** and **BCOS admins**. Customer users never see the page, and cannot grant themselves anything.
Your own admin users are not affected by these settings. Only customer users are gated — an admin keeps the full command set regardless of what is ticked here.
## ❓ A Customer Says They Cannot Reboot
1. Check this page: does their institution have **Reboot** ticked?
2. If it is ticked and they still cannot, confirm the device belongs to their institution — a customer cannot command another institution's machines.
3. The error they see names the missing permission: *"Your institution is not permitted to push 'REBOOT'. Ask your service provider to enable it."*
@@ -0,0 +1,92 @@
---
module: fleet.task-permissions
title: Task Permissions Guide
tab: Testing
order: 35
audience: internal
---
How to verify the gate actually holds. Run against **bcos.dev** — never prod.
## ⚠️ These Tests Write
`PUT /fleet/task-permissions/{key}` replaces an institution's grants for real. On bcos.dev, `C1BD` normally has `["REBOOT"]`. Record the starting state and restore it, or use `C2BD`/`C3BD` as scratch.
```bash
curl -s https://api.bcos.dev/fleet/task-permissions -H "Authorization: Bearer $ADMIN" # record this first
```
## Tokens
```bash
login() { curl -s https://api.bcos.dev/auth/api/login -H 'Content-Type: application/json' \
-d "{\"email\":\"$1\",\"password\":\"Passw0rd-d3v!\"}" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])'; }
ADMIN=$(login bcos_a@bcos.dev) # BCOS_ADMIN
MSP=$(login msp_a@msp1.bcos.dev) # MSP_ADMIN, scope C1BD+C2BD
CUST=$(login customer_a@customer1.bcos.dev) # CUSTOMER, C1BD
```
## 1. Role Gate on the Admin Endpoints
A CUSTOMER must be refused. The sidebar hiding the tab is cosmetic — this is the real control.
```bash
for p in fleet/task-permissions fleet/task-permissions/grantable; do
curl -s -o /dev/null -w "$p %{http_code}\n" "https://api.bcos.dev/$p" -H "Authorization: Bearer $CUST"
done
```
**Expect 403 on both.** MSP_ADMIN and BCOS_ADMIN get 200.
## 2. `/mine` Is Open By Design
```bash
curl -s https://api.bcos.dev/fleet/task-permissions/mine -H "Authorization: Bearer $CUST"
```
**Expect 200** returning only that customer's own grants. This is intentional, not a hole — it lets the UI hide commands the caller would 403 on. A 403 here is the bug.
## 3. Deny By Default
```bash
curl -s -X PUT https://api.bcos.dev/fleet/task-permissions/C3BD \
-H "Authorization: Bearer $ADMIN" -H 'Content-Type: application/json' -d '[]'
curl -s -X POST https://api.bcos.dev/fleet/tasks \
-H "Authorization: Bearer $(login customer_a@customer3.bcos.dev)" -H 'Content-Type: application/json' \
-d '{"taskKind":"REBOOT","atmIds":["C3-ATM-001"]}' -o /dev/null -w '%{http_code}\n'
```
**Expect 403**, message *"Your institution is not permitted to push 'REBOOT'…"*.
## 4. Grant, Then It Works
```bash
curl -s -X PUT https://api.bcos.dev/fleet/task-permissions/C3BD \
-H "Authorization: Bearer $ADMIN" -H 'Content-Type: application/json' -d '["REBOOT"]'
```
Re-run the POST from step 3 → **expect 200/201**. This actually reboots a simulator; that is the point of a dev simulator.
## 5. The Allowlist Cannot Be Widened
```bash
curl -s -X PUT https://api.bcos.dev/fleet/task-permissions/C3BD \
-H "Authorization: Bearer $ADMIN" -H 'Content-Type: application/json' \
-d '["REBOOT","CONFIG_UPDATE"]'
```
**Expect 400**, naming `CONFIG_UPDATE` and listing the grantable set. A 200 here is a **critical** finding — the allowlist is the security boundary.
## 6. Non-Grantable Is Structurally Refused
Even with everything ticked, a customer pushing `CONFIG_UPDATE` gets **403** *"'CONFIG_UPDATE' can never be pushed by a customer."* — a different message from the un-granted case. Both are 403; the messages are the assertion.
## 7. Admins Are Not Gated
With `C3BD` set to `[]`, push `REBOOT` as `$ADMIN` → **expect success**. Admins are ungoverned by this table; a 403 here means the gate is over-reaching.
## 8. Replace, Not Merge
```bash
curl -s -X PUT .../C3BD -d '["REBOOT","RESTART_AGENT"]' ... # both
curl -s -X PUT .../C3BD -d '["RESTART_AGENT"]' ... # REBOOT should be GONE
curl -s https://api.bcos.dev/fleet/task-permissions -H "Authorization: Bearer $ADMIN"
```
**Expect `C3BD: ["RESTART_AGENT"]`.** PUT takes complete desired state, not a delta.
## 🔄 Restore
```bash
curl -s -X PUT https://api.bcos.dev/fleet/task-permissions/C1BD \
-H "Authorization: Bearer $ADMIN" -H 'Content-Type: application/json' -d '["REBOOT"]'
```
Then diff the full map against what you recorded at the top.
## UI Checks
The SPA has no login page and no auth interceptor — the HiveIQ Browser injects the token at session level. In a plain browser `/users/me` 401s, `userRole` stays null, and the admin-only tab is filtered out of the sidebar. Test the UI **in the HiveIQ Browser**, or the tab will appear "missing" for reasons that have nothing to do with permissions.