---
module: devices.device-groups
title: Device Groups
tab: Internal
order: 20
audience: internal
---

> **Internal · ops/support/admin.** Grounded in `hiveops-devices` backend + `AtmGroups.svelte` (2026-07-01). Owner service: **hiveops-devices** (port 8096, DB `hiveiq_devices`).

## What this module actually is
A CRUD screen over the `atm_groups` table plus its `atm_group_members` join table. Groups are named, colored collections of devices used for **filtered device lists** and **bulk hardware-profile pushes**. Deleting a group or dropping a device only touches the join table — no device is ever deleted.

## Roles required
- **Read** (list groups, view a group): any authenticated JWT. `GET /api/atm-groups` and `GET /api/atm-groups/{id}` have **no `@PreAuthorize`** — they are readable by any logged-in user, scoped by institution (see below).
- **All mutations** (create, update, delete, assign ATMs, add/remove single ATM, assign hardware profile): `@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')")`. A non-admin PUT/POST/DELETE returns **403**.

## Institution scoping (why a user "can't see a group")
`AtmGroupService` calls `InstitutionContext.resolveScope()` on every read/write:
- scope `null` → full access (MSP/BCOS admin) — sees all groups.
- scope empty list → sees **zero** groups.
- scope non-empty → only groups whose `institution_key` is in scope (`findAllWithAtmsByInstitutionKeyIn`).
- On **create**, if the caller is scoped to exactly one institution, the group's `institution_key` is **forced** to that key — the Institution dropdown selection in the form is ignored for single-institution users.

First thing to check when "my group vanished / I can't edit it": the caller's JWT institution scope vs the group's `institution_key`.

## Admin-only actions on the screen
- **+ New Group** → `POST /api/atm-groups`.
- **Edit** → `PUT /api/atm-groups/{id}` (name/description/color/institution/membership; only non-null fields are applied).
- **Assign Devices** → `PUT /api/atm-groups/{id}/atms` with a full `[atmId, ...]` list — this **replaces** the whole membership set, it is not additive.
- **Assign Profile** → `POST /api/atm-groups/{id}/hardware-profile` `{ "profileKey": "..." }`, then the **frontend** fires a `POST /fleet/tasks` (`CONFIG_UPDATE`, patch `atm.hardware.profile`).
- **Delete** → `DELETE /api/atm-groups/{id}`.

## Set Default / Clear Default — NOT server state
The **Set Default** / **✕ Clear** buttons are **purely client-side**. The chosen group id is stored in browser `localStorage` under key **`hiveops_defaultGroupId`** (`stores.ts`). It is:
- per-browser, per-machine — never synced to the account or DB;
- used only to pre-filter the device list (passes `groupId` to `/api/atms/paginated`).

"My default group didn't follow me to another machine" is expected behaviour, not a bug. To reset it: clear site data / `localStorage`.

## Common failure modes + fixes
| Symptom | Likely cause | Fix |
|--------|--------------|-----|
| **409 / 500 on create or edit** with terse message | `atm_groups.name` is `UNIQUE` **globally** (not per-institution). A duplicate name — even across institutions — violates the constraint. `server.error.include-message=never` hides the detail. | Pick a unique name; check DB for the colliding row. |
| **403 on any save** | Caller lacks `MSP_ADMIN`/`BCOS_ADMIN`. | Use an admin JWT. |
| **Group list empty for a real user** | Institution scope is empty, or all groups are tied to institutions outside scope. | Check JWT `institutionKey` claim / scope. |
| **"Assign Profile" says N devices but no config lands on ATMs** | Two steps: profile save succeeded but the follow-up fleet task failed, or ATMs are offline/haven't polled. | Check `/fleet/tasks` for the queued `CONFIG_UPDATE`; verify agents are polling. |
| **Assigning devices wiped others out** | `Assign Devices` is a full replace, not add. | Re-open, select the complete intended set. |
| **Removed a device but it's "back"** | Edit form / Assign panel was opened from stale data. | Reload groups (`GET /api/atm-groups`) and retry. |
| **Group shows "All" institution but a scoped admin can still edit it** | `verifyGroupAccess` only blocks when `institution_key` is non-null. Null (All) groups are editable by any in-scope admin. | Intended today; note it before blaming data. |

## Quick checks
```bash
# List groups (any authed JWT)
curl -s https://api.bcos.cloud/devices/api/atm-groups -H "Authorization: Bearer $JWT"

# DB: group + member counts
SELECT g.id, g.name, g.institution_key, count(m.atm_id) AS atms
FROM atm_groups g LEFT JOIN atm_group_members m ON m.group_id = g.id
GROUP BY g.id ORDER BY g.name;
```

See also [devices.device-groups] Overview (customer) and the Architect tab for data flow.
