docs(guide): add fleet.task-permissions module (#23)

The Task Permissions tab shipped in hiveops-fleet#119 and is live, but had no
guide module at all — the API returned 404 for fleet.task-permissions, and
fleet.tasks mentions permissions once without describing the grant model.

This is the setting that decides which commands a customer may push to their own
ATMs: deny-by-default, per-institution, per-command. Highest-consequence fleet
setting we have, least documented.

Adds the 5 canonical role tabs, grounded in hiveops-fleet source:

- Customer  — grant/revoke, why only Reboot + Restart Agent are offered
- Internal  — triage for "customer cannot reboot", what we never widen
- Architect — deny-by-default rationale, the two independent gates, replace-not-
              merge semantics, why GRANTABLE lives in code
- Testing   — role matrix + the 400-on-CONFIG_UPDATE case, against bcos.dev
- Claude    — ownership, table, endpoints, gotchas

Verified: all 5 tabs parse under MarkdownGuideService.parse(); reconciler counts
126 modules with no missing role-tabs. Role matrix confirmed live on bcos.dev
(CUSTOMER 403/403/200/403, MSP_ADMIN 200/200/200/200).

Refs #23. Follow-up to hiveops-fleet#119, hiveops-fleet#100.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 14:44:55 -04:00
parent 8cfc49a01c
commit 71d7b06dea
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.