---
module: devices.config-audit
title: Config Audit
tab: Architect
order: 30
audience: dev
---

> **Internal · architecture view.** How Config Audit is built inside `hiveops-devices`. Grounded in source 2026-07-01.

## Shape at a glance

Config Audit is a **single-service, synchronous, DB-backed** feature. There is **no Kafka, no async executor, and no record-store fan-out** for this feature — the audit row is written in the same transaction as the config save it records. (Contrast with the executor→Kafka→record-store pattern used elsewhere in the platform; it does **not** apply here.)

- **Service owner:** `hiveops-devices` (backend port **8096**, SPA `devices.bcos.cloud` port 5177). Devices owns all device/institution config data. See [platform.service-ownership].
- **Database:** `hiveiq_devices`. See [platform.data-architecture] / [platform.data-stores].
- **Messaging:** none for this feature. See [platform.kafka] for topics the rest of `hiveops-devices` produces/consumes (`hiveops.device.events`, etc.) — Config Audit touches none of them.

## Components

| Layer | Class | Role |
|-------|-------|------|
| Frontend | `Settings/ConfigAudit.svelte` | Table + institution filter + paging + per-row diff expander |
| Frontend API | `institutionKeyAPI.getConfigAudit()` (`lib/api.ts`) → `devicesApi` (base `VITE_API_URL`, else `http://localhost:8096`) | Calls `GET /api/institution-keys/config-audit` |
| Controller | `InstitutionKeyController` (`@RequestMapping("/api/institution-keys")`) | `getConfigAudit` (read), `saveAgentProperties` (write trigger) |
| Service | `InstitutionKeyService` | `getAuditLog()` (read + diff), `saveAgentProperties()` (write + audit insert) |
| Repository | `InstitutionConfigAuditRepository` (JPA) | `findAllByOrderByChangedAtDesc`, `findByInstitutionKeyOrderByChangedAtDesc` |
| Entity / table | `InstitutionConfigAudit` → `institution_config_audit` | Stored snapshots |
| Scoping | `InstitutionContext.resolveScope()` | Per-role institution key scope from JWT |

## Write path (how a row is created)

The audit row is a **side effect of saving agent properties** — there is no standalone "create audit" API.

```
Agent Properties save (PUT /api/institution-keys/{key}/agent-properties)
  → InstitutionKeyController.saveAgentProperties()  [@PreAuthorize MSP_ADMIN|BCOS_ADMIN, verifyKeyAccess]
    → InstitutionKeyService.saveAgentProperties(key, dto)
        1. load existing InstitutionAgentProperties (institution_agent_properties)
        2. previousConfig = entity.hiveopsConfig   (old JSON string)
        3. newConfig      = serialize(dto.hiveopsConfig)
        4. save entity (updates institution_agent_properties)   ← evicts agentProperties cache
        5. changedBy = SecurityContext principal name, else "system"
        6. auditRepository.save(InstitutionConfigAudit{key, changedBy, now, previousConfig, newConfig})
```

- Snapshots stored are the **serialized `hiveopsConfig` JSON only** (the `ejConfig` half of the DTO is persisted to `institution_agent_properties` but is **not** captured in the audit snapshot).
- Both writes happen in the same service call; a serialization failure throws before either save (`RuntimeException("Failed to save agent properties")`).

## Read path (how the diff is produced)

```
GET /api/institution-keys/config-audit?institutionKey&page&size   (no @PreAuthorize)
  → InstitutionKeyController.getConfigAudit()
      scopedKeys = InstitutionContext.resolveScope()
      effectiveKey = (scoped) ? scopedKeys.get(0) : institutionKey   // param used only for unrestricted roles
  → InstitutionKeyService.getAuditLog(effectiveKey, page, size)
      - PageRequest.of(page, size)
      - repo query: byInstitutionKey…Desc if key present, else findAll…Desc
      - for each row: computeDiff(previous_config, new_config) → Map<field, {from,to}>
      - map to InstitutionConfigAuditDTO {id, institutionKey, institutionName, changedBy, changedAt, changes}
```

- **Diff is computed on read**, not stored. `computeDiff` parses both JSON snapshots with Jackson and walks fields: added/changed (`!newVal.equals(oldVal)`) then removed (present in prev, absent in next → `to = null`). Parse errors are swallowed (warn-logged), yielding a partial/empty diff.
- `institutionName` is resolved by joining every audit row against an in-memory map built from `institution_keys.findAll()` on each request (cheap; small table).
- Paging: server returns Spring `Page` (`content/totalPages/number`); default size 50, matching the frontend request.

## DB tables

| Table | Access | Notes |
|-------|--------|-------|
| `institution_config_audit` | **written** on save, **read** for the log | `id` (identity), `institution_key` (≤10), `changed_by`, `changed_at` (default `now()`), `previous_config` TEXT, `new_config` TEXT. Indexes: `(institution_key)`, `(changed_at DESC)`. Created in `V7__create_institution_config_audit.sql`. |
| `institution_agent_properties` | written on save, read for previous snapshot | Source of the config being audited (`hiveops_config`, `ej_config`) |
| `institution_keys` | read | Key → institution name resolution + filter dropdown |

## Key endpoints

| Method | Path | Purpose | Auth |
|--------|------|---------|------|
| `GET` | `/api/institution-keys/config-audit` | Paged audit log (+ computed diffs) | authenticated; scoped by role |
| `PUT` | `/api/institution-keys/{key}/agent-properties` | Save agent props → creates audit row | `MSP_ADMIN`/`BCOS_ADMIN` |
| `GET` | `/api/institution-keys` | Filter dropdown (delegates to `/accessible`) | authenticated; scoped |

Cross-links: [platform.data-architecture], [platform.kafka], [platform.service-ownership], [technical.devices].
