---
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.
