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:
@@ -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.
|
||||
Reference in New Issue
Block a user