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>
4.5 KiB
module, title, tab, order, audience
| module | title | tab | order | audience |
|---|---|---|---|---|
| fleet.task-permissions | Task Permissions Guide | Architect | 30 | 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:
@PreAuthorize("hasAnyRole('CUSTOMER','ADMIN','MSP_ADMIN','BCOS_ADMIN')")onPOST /tasks— decides may you call this endpoint at all.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
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.