---
module: fleet.task-permissions
title: Task Permissions Guide
tab: Claude
order: 40
audience: bcos
---

Act-without-guessing cheat-sheet. Everything confirmed in `hiveops-fleet` source (#119).

## Ownership
- **Service:** `hiveops-fleet` ONLY (`com.hiveops.fleet`, port 8097, DB `hiveiq_fleet`).
- **Authority:** fleet's `institution_task_permissions` table. **NOT** mgmt, **NOT** devices, **NOT** auth. No mirror, no Kafka topic — this state is read only by fleet, in-process.
- **UI:** `hiveops-fleet/frontend/src/components/FleetManagement/TaskPermissions.svelte`, tab id `permissions`, admin-only.

## Table (DB `hiveiq_fleet`)
| Table | Key columns |
|-------|-------------|
| `institution_task_permissions` | `id, institution_key (VARCHAR 50), task_kind (VARCHAR 40, enum STRING), granted_by, granted_at` · UNIQUE `(institution_key, task_kind)` · INDEX `idx_itp_institution_key` |

Migration `V9__institution_task_permissions.sql`. One row = one grant. **No row = denied** — there is no `enabled` flag to check.

## Endpoints (base `/api/fleet`)
| Method + Path | Roles | Notes |
|---|---|---|
| GET `/task-permissions` | MSP_ADMIN, BCOS_ADMIN | every institution's grants, keyed by institution |
| GET `/task-permissions/grantable` | MSP_ADMIN, BCOS_ADMIN | drives the UI columns |
| GET `/task-permissions/mine` | **none — open by design** | caller's own grants only |
| PUT `/task-permissions/{institutionKey}` | MSP_ADMIN, BCOS_ADMIN | **replaces** the whole set |

Verified live on bcos.dev: CUSTOMER → 403/403/200/403; MSP_ADMIN → 200/200/200/200.

`/mine` having no `@PreAuthorize` is **intentional**, not an oversight. Do not "fix" it.

## GRANTABLE — the security boundary
```java
GRANTABLE = EnumSet.of(FleetTaskKind.REBOOT, FleetTaskKind.RESTART_AGENT);
```
`FleetTaskPermissionService.GRANTABLE`, code not config. The other 13 `FleetTaskKind` values (`CONFIG_UPDATE`, `INSTALL_MODULE`, `UPDATE_AGENT`, `PACKET_CAPTURE`, `HOTFIX`, `HOTFIX_INSTALL`, `SOFTWARE`, `SOFTWARE_INSTALL`, `LOG_COLLECT`, `LOG_COLLECT_PATH`, `RUN_SCRIPT`, `UPDATE_SCRIPT`, `AUTO_UPDATE`) are **never** grantable.

**Do not widen `GRANTABLE` on your own initiative.** It is deliberately narrow and widening it is an explicit decision with review — not a side effect of a ticket asking to let some institution do one more thing. `SCRIPT_EXECUTION` is expected to join it when it lands: add the enum value + list it, no schema change.

## The gate — two checks, not one
`POST /api/fleet/tasks` (`FleetApiController.java:176-179`):
```java
@PreAuthorize("hasAnyRole('CUSTOMER','ADMIN','MSP_ADMIN','BCOS_ADMIN')")   // may you call it
taskPermissionService.assertMayPush(kind);                                 // may you push THIS kind
```
**Role alone is NOT the authorisation.** The permissive-looking annotation is not the boundary; `assertMayPush` is. If you are reasoning about who can reboot, read the service, not the annotation.

`assertMayPush` (`FleetTaskPermissionService.java:106`):
1. not a CUSTOMER → return immediately. **Admins and MSP_ADMINs are ungoverned by this table.**
2. kind ∉ GRANTABLE → 403 *"can never be pushed by a customer"*
3. not granted → 403 *"Ask your service provider to enable it"*

Both are 403 with different messages — 403s, never silent drops.

## Gotchas
- **PUT replaces, never merges.** `deleteByInstitutionKey` → `flush` → re-insert. Send complete desired state. Concurrent admin edits are last-write-wins with no error.
- **Only CUSTOMER is gated.** "Admin can still reboot with nothing ticked" is correct, not a bug.
- **`grantsForCurrentCaller()` returns ALL kinds for non-CUSTOMERs** (`resolveScope() == null` → unrestricted; MSP_ADMIN → scoped but not gated). Do not read it as "what this user has been granted" for an admin.
- **Illegal kinds are 400 at write time**, not filtered silently. The allowlist can't be widened through the API.
- **No revocation history.** Rows are deleted, not tombstoned; `granted_at` is the last edit to the institution's whole set, not when that kind was granted. "Who removed Reboot from AICU?" is unanswerable today.
- **Frontend hiding is cosmetic.** `App.svelte:37` `isAdmin = MSP_ADMIN || BCOS_ADMIN` filters the nav item; the backend does the real work.
- **The SPA has no login page and no auth interceptor.** The HiveIQ Browser injects the token at Electron session level. In a plain browser `/users/me` 401s → `userRole` null → admin tabs vanish from the sidebar. Not a permissions bug.

## Changing this area
- Adding a grantable kind → `FleetTaskKind` enum + `GRANTABLE` + this guide. No migration.
- The UI reads `grantable` to build its columns, so a new kind appears as a column automatically.
- Touching the gate → re-run the Testing tab against bcos.dev, especially the 400-on-`CONFIG_UPDATE` case. A 200 there is a critical security finding.
