Merge pull request 'docs(guide): add fleet.task-permissions module (#23)' (#24) from feat/23-fleet-task-permissions-module into develop
CD - Develop (guide → bcos.dev) / build-and-deploy (push) Has started running
CD - Develop (guide → bcos.dev) / build-and-deploy (push) Has started running
This commit was merged in pull request #24.
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.
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
module: fleet.task-permissions
|
||||
title: Task Permissions Guide
|
||||
tab: Internal
|
||||
order: 20
|
||||
audience: internal
|
||||
---
|
||||
|
||||
Support-side view: what to check when a customer cannot push a command, and what we must never widen.
|
||||
|
||||
## Where It Lives
|
||||
- **Service:** `hiveops-fleet` only (port 8097, DB `hiveiq_fleet`).
|
||||
- **UI:** Fleet Mgmt → 🔐 Task Permissions. Admin-only submenu item.
|
||||
- **Table:** `institution_task_permissions` in `hiveiq_fleet`.
|
||||
|
||||
## The Model in One Line
|
||||
**Deny by default.** The absence of a row *is* the denial. An institution with no rows can push nothing, and nothing changed for anyone when #119 shipped — every institution started empty.
|
||||
|
||||
## Only Customers Are Gated
|
||||
This is the point people get wrong. The gate applies to **CUSTOMER role only**:
|
||||
|
||||
| Role | Effect of these settings |
|
||||
|---|---|
|
||||
| CUSTOMER | Gated — may push only what their institution has been granted |
|
||||
| MSP_ADMIN | **Not gated** — full command set, scoped to their institutions |
|
||||
| ADMIN / BCOS_ADMIN | **Not gated** — full command set, unrestricted |
|
||||
|
||||
So "I ticked nothing and admins can still reboot" is correct behaviour, not a bug.
|
||||
|
||||
## Triage: "Customer cannot reboot"
|
||||
1. **Check the grant.** Fleet Mgmt → Task Permissions → is **Reboot** ticked for their institution key?
|
||||
2. **Check the role.** Are they actually CUSTOMER? An MSP_ADMIN is never gated, so if they are blocked the cause is elsewhere.
|
||||
3. **Check the institution.** A customer can only target devices in their own institution. Wrong institution reads as a permission problem but is a scope problem.
|
||||
4. **Read the error.** The API is explicit rather than silent:
|
||||
- *"Your institution is not permitted to push 'REBOOT'. Ask your service provider to enable it."* → no grant. Tick the box.
|
||||
- *"'CONFIG_UPDATE' can never be pushed by a customer."* → they are asking for a non-grantable command. Nothing to tick; this one is by design.
|
||||
|
||||
> A customer who presses Reboot and gets a cheerful nothing is worse than one who is told they lack permission — that is why these are 403s with messages, not silent drops.
|
||||
|
||||
## What We Will Never Grant
|
||||
Only **REBOOT** and **RESTART_AGENT** are grantable. The rest are operator powers and are refused even if someone tries to set them through the API:
|
||||
|
||||
| Command | Why it is not grantable |
|
||||
|---|---|
|
||||
| `CONFIG_UPDATE` | Can brick every ATM an institution owns |
|
||||
| `INSTALL_MODULE` | Runs arbitrary code on them |
|
||||
| `UPDATE_AGENT` | Fleet-wide agent replacement |
|
||||
| `PACKET_CAPTURE` | A data-exfiltration path |
|
||||
|
||||
Widening that set is a deliberate decision with a code change and a review — not a config toggle, and not a side effect of "let AICU reboot a machine". If a customer asks for more, it is an engineering conversation, not a support one.
|
||||
|
||||
## Escalate If
|
||||
- A CUSTOMER successfully pushes something outside REBOOT/RESTART_AGENT. That is a security incident, not a bug.
|
||||
- A customer can command a device outside their institution. Same.
|
||||
- Grants vanish on their own. Setting an institution's grants **replaces** the whole set (see the Architect tab) — suspect a concurrent admin edit before suspecting the database.
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
module: fleet.task-permissions
|
||||
title: Task Permissions Guide
|
||||
tab: Customer
|
||||
order: 10
|
||||
audience: customer
|
||||
---
|
||||
|
||||
Which commands each institution's customer users may send to their own devices.
|
||||
|
||||
## 🔐 What Task Permissions Do
|
||||
1. A fleet task is a command sent to a real ATM — a reboot is a machine out of service for minutes.
|
||||
2. By default a customer user can send **nothing**. Permission does not exist until you grant it.
|
||||
3. A grant is per **institution** and per **command**: "Customer 1 may reboot", not "customers may reboot".
|
||||
4. A customer can only ever target devices belonging to their own institution. Granting Reboot to one institution gives them no reach into another.
|
||||
|
||||
> **Nothing is permitted until you grant it.** An institution with no ticks can push no commands at all — that is the safe default, and it is where every institution starts.
|
||||
|
||||
## ✅ Grant a Command
|
||||
1. Find the institution's row. The name is shown with its **institution key** underneath.
|
||||
2. Tick the box under the command you want to allow.
|
||||
3. The change saves immediately — a green confirmation names the institution.
|
||||
|
||||
There is no Save button and no undo. Ticking a box grants the permission the moment you click it, and unticking revokes it just as fast.
|
||||
|
||||
## 🚫 Revoke a Command
|
||||
1. Untick the box.
|
||||
2. The permission is gone immediately. The next time that customer tries the command they are told they lack permission.
|
||||
|
||||
> Revoking does **not** cancel tasks already in flight. A reboot that was already accepted will still run.
|
||||
|
||||
## 🧭 Which Commands Can Be Granted
|
||||
Only two commands can be granted to a customer at any setting:
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| **Reboot** | Restarts the ATM |
|
||||
| **Restart Agent** | Restarts the HiveIQ agent process on the ATM |
|
||||
|
||||
These are the operations on the device Command Center. Everything else — configuration changes, module installs, agent updates, packet capture — is an operator power and is never offered here, no matter what you tick. If a command you expect is missing from this page, it is missing by design.
|
||||
|
||||
## 👤 Who Can See This Page
|
||||
Task Permissions is visible only to **MSP admins** and **BCOS admins**. Customer users never see the page, and cannot grant themselves anything.
|
||||
|
||||
Your own admin users are not affected by these settings. Only customer users are gated — an admin keeps the full command set regardless of what is ticked here.
|
||||
|
||||
## ❓ A Customer Says They Cannot Reboot
|
||||
1. Check this page: does their institution have **Reboot** ticked?
|
||||
2. If it is ticked and they still cannot, confirm the device belongs to their institution — a customer cannot command another institution's machines.
|
||||
3. The error they see names the missing permission: *"Your institution is not permitted to push 'REBOOT'. Ask your service provider to enable it."*
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
module: fleet.task-permissions
|
||||
title: Task Permissions Guide
|
||||
tab: Testing
|
||||
order: 35
|
||||
audience: internal
|
||||
---
|
||||
|
||||
How to verify the gate actually holds. Run against **bcos.dev** — never prod.
|
||||
|
||||
## ⚠️ These Tests Write
|
||||
`PUT /fleet/task-permissions/{key}` replaces an institution's grants for real. On bcos.dev, `C1BD` normally has `["REBOOT"]`. Record the starting state and restore it, or use `C2BD`/`C3BD` as scratch.
|
||||
|
||||
```bash
|
||||
curl -s https://api.bcos.dev/fleet/task-permissions -H "Authorization: Bearer $ADMIN" # record this first
|
||||
```
|
||||
|
||||
## Tokens
|
||||
```bash
|
||||
login() { curl -s https://api.bcos.dev/auth/api/login -H 'Content-Type: application/json' \
|
||||
-d "{\"email\":\"$1\",\"password\":\"Passw0rd-d3v!\"}" \
|
||||
| python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])'; }
|
||||
|
||||
ADMIN=$(login bcos_a@bcos.dev) # BCOS_ADMIN
|
||||
MSP=$(login msp_a@msp1.bcos.dev) # MSP_ADMIN, scope C1BD+C2BD
|
||||
CUST=$(login customer_a@customer1.bcos.dev) # CUSTOMER, C1BD
|
||||
```
|
||||
|
||||
## 1. Role Gate on the Admin Endpoints
|
||||
A CUSTOMER must be refused. The sidebar hiding the tab is cosmetic — this is the real control.
|
||||
|
||||
```bash
|
||||
for p in fleet/task-permissions fleet/task-permissions/grantable; do
|
||||
curl -s -o /dev/null -w "$p %{http_code}\n" "https://api.bcos.dev/$p" -H "Authorization: Bearer $CUST"
|
||||
done
|
||||
```
|
||||
**Expect 403 on both.** MSP_ADMIN and BCOS_ADMIN get 200.
|
||||
|
||||
## 2. `/mine` Is Open By Design
|
||||
```bash
|
||||
curl -s https://api.bcos.dev/fleet/task-permissions/mine -H "Authorization: Bearer $CUST"
|
||||
```
|
||||
**Expect 200** returning only that customer's own grants. This is intentional, not a hole — it lets the UI hide commands the caller would 403 on. A 403 here is the bug.
|
||||
|
||||
## 3. Deny By Default
|
||||
```bash
|
||||
curl -s -X PUT https://api.bcos.dev/fleet/task-permissions/C3BD \
|
||||
-H "Authorization: Bearer $ADMIN" -H 'Content-Type: application/json' -d '[]'
|
||||
curl -s -X POST https://api.bcos.dev/fleet/tasks \
|
||||
-H "Authorization: Bearer $(login customer_a@customer3.bcos.dev)" -H 'Content-Type: application/json' \
|
||||
-d '{"taskKind":"REBOOT","atmIds":["C3-ATM-001"]}' -o /dev/null -w '%{http_code}\n'
|
||||
```
|
||||
**Expect 403**, message *"Your institution is not permitted to push 'REBOOT'…"*.
|
||||
|
||||
## 4. Grant, Then It Works
|
||||
```bash
|
||||
curl -s -X PUT https://api.bcos.dev/fleet/task-permissions/C3BD \
|
||||
-H "Authorization: Bearer $ADMIN" -H 'Content-Type: application/json' -d '["REBOOT"]'
|
||||
```
|
||||
Re-run the POST from step 3 → **expect 200/201**. This actually reboots a simulator; that is the point of a dev simulator.
|
||||
|
||||
## 5. The Allowlist Cannot Be Widened
|
||||
```bash
|
||||
curl -s -X PUT https://api.bcos.dev/fleet/task-permissions/C3BD \
|
||||
-H "Authorization: Bearer $ADMIN" -H 'Content-Type: application/json' \
|
||||
-d '["REBOOT","CONFIG_UPDATE"]'
|
||||
```
|
||||
**Expect 400**, naming `CONFIG_UPDATE` and listing the grantable set. A 200 here is a **critical** finding — the allowlist is the security boundary.
|
||||
|
||||
## 6. Non-Grantable Is Structurally Refused
|
||||
Even with everything ticked, a customer pushing `CONFIG_UPDATE` gets **403** *"'CONFIG_UPDATE' can never be pushed by a customer."* — a different message from the un-granted case. Both are 403; the messages are the assertion.
|
||||
|
||||
## 7. Admins Are Not Gated
|
||||
With `C3BD` set to `[]`, push `REBOOT` as `$ADMIN` → **expect success**. Admins are ungoverned by this table; a 403 here means the gate is over-reaching.
|
||||
|
||||
## 8. Replace, Not Merge
|
||||
```bash
|
||||
curl -s -X PUT .../C3BD -d '["REBOOT","RESTART_AGENT"]' ... # both
|
||||
curl -s -X PUT .../C3BD -d '["RESTART_AGENT"]' ... # REBOOT should be GONE
|
||||
curl -s https://api.bcos.dev/fleet/task-permissions -H "Authorization: Bearer $ADMIN"
|
||||
```
|
||||
**Expect `C3BD: ["RESTART_AGENT"]`.** PUT takes complete desired state, not a delta.
|
||||
|
||||
## 🔄 Restore
|
||||
```bash
|
||||
curl -s -X PUT https://api.bcos.dev/fleet/task-permissions/C1BD \
|
||||
-H "Authorization: Bearer $ADMIN" -H 'Content-Type: application/json' -d '["REBOOT"]'
|
||||
```
|
||||
Then diff the full map against what you recorded at the top.
|
||||
|
||||
## UI Checks
|
||||
The SPA has no login page and no auth interceptor — the HiveIQ Browser injects the token at session level. In a plain browser `/users/me` 401s, `userRole` stays null, and the admin-only tab is filtered out of the sidebar. Test the UI **in the HiveIQ Browser**, or the tab will appear "missing" for reasons that have nothing to do with permissions.
|
||||
Reference in New Issue
Block a user