---
module: msp.users
title: Users
tab: Architect
order: 30
audience: dev
---

How the **Users** screen is built. It is a two-service read/write feature: user records are owned by **hiveops-auth**; the institution dropdown/scoping data is owned by **hiveops-mgmt**. The `msp` SPA has **no backend of its own** — it calls both services directly. There is **no Kafka** and no executor→record-store pattern in this feature; every mutation is a synchronous REST call. See [platform.service-ownership].

## Services involved

| Service | Role for this feature |
|---|---|
| `msp` frontend (`UsersView.svelte`, port 5186 / `msp.bcos.cloud`) | Svelte 4 SPA; loads the full list (`size=200`) and does search/filter/group/paginate client-side |
| **hiveops-auth** (port 8082, NGINX `/api/auth/`, DB `hiveiq_auth`) | **Owns the user records.** `MspUserController` = list/edit/enable/disable/reset-password |
| **hiveops-mgmt** (port 8080, NGINX `/api/mgmt/` → gateway strips `/mgmt`, DB `hiveiq_mgmt`) | Institution dropdown (`MspController`) + internal institution-key resolver used by auth for scoping |

Two axios clients in `lib/api.ts`: `authApi` (base → auth, all `/auth/api/...` calls) and `api` (base → gateway, `/mgmt/...` and `/devices/...`). Both inject `Authorization: Bearer <localStorage token>`.

## Data flow — read (list)

```
UsersView.load()
  --GET /auth/api/msp/users?size=200-->  hiveops-auth  MspUserController.listUsers
     1. extractToken(); isBcosAdmin(token) from JWT 'role' claim
     2. non-BCOS: resolveInstitutionKeys(token)
          --GET /api/internal/customers/domain/{domain}/institution-keys (X-Internal-Secret)-->
          hiveops-mgmt CustomerInternalController  (MgmtServiceClient)
     3. UserRepository.findByInstitutionKeys(keys) | searchByInstitutionKeys(keys, q)
          (BCOS_ADMIN with no filter -> findAll / search)
     4. post-filter role/status in-memory, drop SYSTEM role, toMap()
     5. return { content:[...], page:{totalElements,totalPages,number,size} }
```

- Institution dropdown loads separately: `UsersView.loadInstitutions()` → `GET /mgmt/api/v1/msp/institutions` → mgmt `MspController.getInstitutions` reads `customer_institution_access` + `customers` + `licenses` (DB `hiveiq_mgmt`), deriving the MSP from the caller's email domain (DB-fresh, not JWT-cached).
- **Scope resolution is fresh per-request**: auth calls mgmt's internal endpoint every time rather than trusting the JWT institution claim (which goes stale when institutions are added post-login). BCOS_ADMIN bypasses scoping entirely.

## Data flow — write

All four writes are `MspUserController` handlers; each re-verifies ownership via `isOwned(token, user)` (BCOS_ADMIN → always true; else target's `customer_license_key` ∈ scope keys, or email-domain→`customer_domains`→institutionKey ∈ scope keys):

| Action | Handler | Effect on `users` row |
|---|---|---|
| Edit | `PUT /users/{id}` | sets `name`, `role` (via `Role.valueOf`), `fleet_task_approver` |
| Enable | `POST /users/{id}/enable` | `enabled=true`, `failed_login_attempts=0`, `locked_until=null` |
| Disable | `POST /users/{id}/disable` | `enabled=false`; blocks self-disable (caller email == target → 400) |
| Reset PW | `POST /users/{id}/reset-password` | no user mutation; delegates to `PasswordResetService` |

Reset-password chain: `resetPassword` → `PasswordResetService.initiatePasswordReset(email)` → invalidate prior unused tokens → write a new row to `password_reset_tokens` → `EmailService.sendPasswordResetEmail` (async, via `mail.bcos.cloud`). Failures are caught + logged; endpoint returns 200 regardless (no enumeration leak).

## DB tables

**hiveops-auth (`hiveiq_auth`):**
- **`users`** (entity `User`) — the record of truth. Read for the list; written on edit/enable/disable. Relevant columns: `id`, `email` (unique, immutable here), `name`, `role`, `enabled`, `mfa_enabled`, `customer_license_key` (len 29), `fleet_task_approver`, `failed_login_attempts`, `locked_until`, `last_login_at`, `created_at`. `Role` enum includes `SYSTEM` (always excluded), `CUSTOMER`, `MSP_ADMIN`, `ADMIN`, `BCOS_ADMIN`.
- **`customer_domains`** (entity `CustomerDomain`) — email-domain → institutionKey/institutionName. Used both for scope-ownership fallback and to populate `institutionKey`/`institution` in the response DTO (`toMap`).
- **`password_reset_tokens`** — written by the reset flow.

**hiveops-mgmt (`hiveiq_mgmt`):** `customer_institution_access`, `customers`, `licenses` — read by `MspController.getInstitutions` and the internal institution-keys resolver.

See [platform.data-architecture].

## Key API endpoints

| Method + path | Service | Auth |
|---|---|---|
| `GET /auth/api/msp/users` | hiveops-auth | `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` (class-level) |
| `PUT /auth/api/msp/users/{id}` | hiveops-auth | same + `isOwned` check |
| `POST /auth/api/msp/users/{id}/enable` | hiveops-auth | same + `isOwned` |
| `POST /auth/api/msp/users/{id}/disable` | hiveops-auth | same + `isOwned` + self-disable guard |
| `POST /auth/api/msp/users/{id}/reset-password` | hiveops-auth | same + `isOwned` |
| `GET /api/v1/msp/institutions` (SPA path `/mgmt/api/v1/msp/institutions`) | hiveops-mgmt | `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` |
| `GET /api/internal/customers/domain/{domain}/institution-keys` | hiveops-mgmt | `X-Internal-Secret` (service-to-service, not user JWT) |

## Kafka

None. This feature is entirely synchronous HTTP across auth ↔ mgmt. No topics are produced or consumed for user list/edit/enable/disable/reset. See [platform.kafka].

## Notes / caveats

- **Pagination is a bit of a lie when role/status filter server-side**: `listUsers` applies role/status filtering in-memory *after* the page is fetched, but `page.totalElements/totalPages` reflect the pre-filter count. The SPA sidesteps this by pulling `size=200` and filtering client-side, but a direct API consumer using `role=`/`status=` will get inconsistent counts.
- **`customer_license_key IN keys` rarely matches** in `findByInstitutionKeys`: license keys (`XXXXX-…`, len 29) are not institution keys, so scoping effectively rides on the `customer_domains` email-domain join.
- `fleetTaskApprover` is transmitted as a string (`"true"`/`"false"`) and parsed with `Boolean.parseBoolean`; the flag is consumed downstream by the fleet task-approval feature (out of scope here).
