Files
hiveops-guide/backend/src/main/resources/content/fleet/task-permissions/architect.md
T
johannes 71d7b06dea 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>
2026-07-16 14:44:55 -04:00

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:

  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

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: deleteByInstitutionKeyflush → 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.