nomyo-router/doc/compliance.md

342 lines
13 KiB
Markdown

# Compliance Plugin (aissurance.eu)
The compliance plugin turns the NOMYO Router from a pure traffic proxy into an
**AI compliance agent**. When enabled, it periodically snapshots your
AI-infrastructure landscape — model inventory, endpoint registry, and aggregate
telemetry — and sends signed, structured evidence to
[aissurance.eu](https://www.aissurance.eu), the EU AI Act compliance platform.
The plugin runs **in-process** inside the Router; it is not a separate service.
It only reads infrastructure metadata the Router already maintains for routing.
**No prompt or completion content is ever read, stored, or transmitted.**
> **Non-critical by design.** Any failure inside the plugin is logged and
> contained — it never affects request routing. If the upstream is unreachable,
> the Router keeps proxying and buffers evidence for later.
---
## How it works
```
Router state (models, endpoints, usage, cache, token DB)
│ read-only snapshot, every polling_interval
Snapshot Collector → Payload Builder → HMAC-SHA256 Signer
HTTPS POST (outbound only) ──────────────► aissurance.eu
│ 202 Accepted → done /api/v1/discovery/receive
│ network error / 5xx → encrypt + buffer to disk, retry next cycle
Encrypted offline buffer (replayed oldest-first on reconnect)
```
**All communication is outbound from the Router.** aissurance.eu never
initiates contact, so the Router can run behind NAT, firewalls, or strict egress
policies.
---
## What is and isn't collected
This is the most important part to understand before enabling the plugin.
### Collected (infrastructure metadata only)
- **Model inventory** — name, version, quantization, weight size, serving endpoint
- **Endpoint registry** — URL, type, health status, concurrency, TLS, auth method
- **Aggregate telemetry** — active connections, uptime, global cache hit rate,
24h token totals
### Never collected
- Individual request payloads (prompt / completion content)
- User identity or auth tokens from proxied requests
- Conversation content passing through the proxy
- End-user client IP addresses
### Emitted as `null` (not yet instrumented)
The Router does not currently measure the following, so the plugin emits `null`
rather than fabricating values. They are candidates for a future instrumentation
phase:
| Field | Where |
|---|---|
| `request_count_24h`, `error_count_24h` | per model |
| `avg_latency_ms`, `p99_latency_ms` | per model |
| `cache_hit_rate`, `avg_input_tokens`, `avg_output_tokens` | per model |
| `total_requests_24h`, `error_rate` | telemetry |
> **Note on byte fields.** The Router tracks **tokens**, not raw bytes.
> `total_bytes_in_24h` / `total_bytes_out_24h` therefore carry 24h **token**
> counts as the closest available signal.
---
## Quick start
1. Obtain your `AISSURANCE_KEY` (and optionally `AISSURANCE_TENANT_ID`) from
aissurance.eu during tenant signup. This is **distinct** from the Router's
own `nomyo-router-api-key` — see [Dual-key model](#dual-key-model).
2. Export the secret (never commit it):
```bash
export AISSURANCE_KEY="aiss_live_…"
export AISSURANCE_TENANT_ID="550e8400-e29b-41d4-a716-446655440000" # optional
```
3. Add the `compliance` block to `config.yaml`:
```yaml
compliance:
enabled: true
server_url: "https://www.aissurance.eu/api/v1/discovery/receive"
api_key: "${AISSURANCE_KEY}"
tenant_id: "${AISSURANCE_TENANT_ID}"
polling_interval: 300
```
4. Restart the Router. On startup you should see structured logs:
```json
{"event":"plugin_started","router_id":"router-prod-001","polling_interval":300,"buffered":0}
```
---
## Configuration options
All options live under the `compliance:` block in `config.yaml`.
| Option | Type | Default | Description |
|---|---|---|---|
| `enabled` | `bool` | `false` | Master switch. All other options are ignored when `false`. |
| `server_url` | `str` | `https://www.aissurance.eu/api/v1/discovery/receive` | Discovery receive URL. Must be HTTPS. Health and config-report URLs are derived from it. |
| `api_key` | `str` | `${AISSURANCE_KEY}` | Tenant secret. Used as the Bearer token and as the HMAC signing key. |
| `tenant_id` | `str` | `${AISSURANCE_TENANT_ID}` | Tenant UUID. Optional if embedded in the key. |
| `router_id` | `str` | machine hostname | Unique identifier for this Router instance. Multiple instances per tenant are allowed. |
| `polling_interval` | `int` | `300` | Seconds between discovery snapshots. Minimum `60`. |
| `health_interval` | `int` | `3600` | Seconds between health / keepalive calls. |
| `batch_size` | `int` | `50` | Max models per payload. On `413` the payload is auto-split into batches. |
| `max_retry_attempts` | `int` | `10` | In-cycle send retries before the payload is buffered to disk. |
| `retry_backoff_base` | `int` | `2` | Exponential backoff base in seconds (`base ** attempt`, capped at 60s). |
| `buffer_dir` | `str` | `./compliance-buffer` | Directory for encrypted offline retry files. |
| `max_buffer_payloads` | `int` | `100` | Buffer cap. Oldest file is dropped first when exceeded. |
### `polling_interval`
How often a snapshot is collected and sent. The server enforces a maximum of one
payload per `polling_interval` per tenant; sending faster yields `429`. Values
below `60` are rejected at startup.
### `batch_size`
Caps how many models a single payload carries. If the upstream replies `413
Payload Too Large`, the plugin splits the model list into `batch_size`-sized
chunks, re-signs each chunk, and sends them individually.
### `buffer_dir` and `max_buffer_payloads`
When the upstream is unreachable, each signed payload is encrypted and written to
`buffer_dir`. The directory is bounded by `max_buffer_payloads`; once full, the
oldest file is dropped before a new one is written. Buffered payloads are
replayed oldest-first on the next successful cycle. Add `buffer_dir` to your
backup exclusions — files are encrypted but transient.
---
## Environment variables
| Variable | Required | Description |
|---|---|---|
| `AISSURANCE_KEY` | Yes | Tenant secret issued at signup. Referenced by `api_key: "${AISSURANCE_KEY}"`. |
| `AISSURANCE_HMAC_SECRET` | No | Per-tenant HMAC secret override. When set, it signs payloads instead of `AISSURANCE_KEY`. |
| `AISSURANCE_TENANT_ID` | No | Tenant UUID, if not embedded in the key. |
| `AISSURANCE_VERIFY_TLS` | No | Set to `"0"` to allow self-signed certs (development only — logged as a warning). |
Values referenced as `${VAR}` in `config.yaml` are expanded at load time. If a
referenced variable is unset, the corresponding environment-variable default
above takes over.
---
## Outbound endpoints
All three are Router-initiated POSTs. URLs are derived from `server_url` by
replacing the trailing `/receive` path.
| Endpoint | When called | Purpose |
|---|---|---|
| `POST …/discovery/receive` | every `polling_interval` | Send the signed discovery payload. |
| `POST …/discovery/health/{tenant_id}` | on startup + every `health_interval` | Connectivity check / keepalive / config sync. |
| `POST …/discovery/config/report` | on startup handshake | Report the active compliance config for acknowledgment. |
Every request carries:
```
Authorization: Bearer ${AISSURANCE_KEY}
X-Router-Version: <router version>
X-Router-Id: <router_id>
Content-Type: application/json
```
### Response handling
| Status | Meaning | Plugin action |
|---|---|---|
| `202` / `200` | Accepted | Mark sent; replay any buffered payloads. |
| `401` / `403` | Invalid key / blacklisted | **Stop reporting**, clear the buffer, log `auth_failed`. |
| `429` | Rate limited | Drop this snapshot (the next one is just as fresh). |
| `413` | Too large | Split by `batch_size`, re-sign, resend. |
| `5xx` / network error | Server / connectivity failure | Retry with backoff, then encrypt + buffer to disk. |
---
## Payload structure
```json
{
"tenant_id": "550e8400-e29b-41d4-a716-446655440000",
"router_id": "router-prod-001",
"router_version": "0.9",
"timestamp": "2026-06-10T10:30:00Z",
"models": [
{
"name": "mistral-7b-instruct",
"version": "latest",
"quantization": "Q4_K_M",
"size_gb": 4.1,
"endpoint": "http://192.168.0.50:11434",
"last_used": "2026-06-10T10:28:14Z",
"request_count_24h": null,
"avg_latency_ms": null,
"p99_latency_ms": null,
"error_count_24h": null,
"cache_hit_rate": null,
"avg_input_tokens": null,
"avg_output_tokens": null
}
],
"endpoints": [
{
"url": "http://192.168.0.50:11434",
"type": "ollama",
"status": "healthy",
"concurrent_connections": 2,
"max_concurrent_connections": 4,
"tls_enabled": false,
"auth_method": "none"
}
],
"telemetry": {
"total_requests_24h": null,
"total_cache_hits": 1547,
"cache_hit_rate": 0.634,
"error_rate": null,
"total_bytes_in_24h": 9120344,
"total_bytes_out_24h": 14882910,
"active_connections": 2,
"uptime_hours": 72.4
},
"hmac_signature": "sha256=abcdef…"
}
```
`endpoint.type` is best-effort: `ollama`, `llama_cpp`, `vllm`, `openai`,
`gemini`, `anthropic`, `cohere`, `cerebras`, `inception labs`, or `other`.
`endpoint.status` maps the Router's health probe onto `healthy` / `unhealthy`.
---
## Security
### Dual-key model
| Credential | Protects | Managed by |
|---|---|---|
| `nomyo-router-api-key` | The Router's own API / dashboard | Router operator |
| `AISSURANCE_KEY` | The upstream reporting channel | aissurance.eu (issued at signup) |
These are independent. See [Router API key usage](configuration.md#using-the-router-api-key)
for the former.
### Payload signing
Every payload is HMAC-SHA256 signed. The signature covers all fields **except**
`hmac_signature`, over a canonical `sort_keys` serialization, so any tampering
with the body invalidates it. The signing key is `AISSURANCE_HMAC_SECRET` if set,
otherwise `AISSURANCE_KEY`.
### Buffer encryption
Offline buffer files are encrypted at rest with a key derived from your
`AISSURANCE_KEY` via HKDF-SHA256 (Fernet / AES-128-CBC + HMAC). A leaked buffer
file is useless without the tenant secret. Corrupt or wrong-key files are dropped
on read.
### Transport
TLS is required (`server_url` must be HTTPS). `AISSURANCE_VERIFY_TLS=0` disables
certificate verification for development only and is logged as a warning.
---
## Observability
The plugin writes structured JSON logs to stderr (one object per line):
```json
{"timestamp":"2026-06-10T10:30:00Z","level":"info","module":"aissurance","event":"payload_sent","tenant_id":"abc123","router_id":"router-prod-001","models_count":12,"response_code":202,"latency_ms":245}
```
Event types:
| Event | Meaning |
|---|---|
| `plugin_started` / `plugin_stopped` | Lifecycle. `plugin_stopped` reports buffered + successful counts. |
| `plugin_disabled` | Startup validation failed (e.g. missing key, non-HTTPS URL). Reasons included. |
| `payload_sent` | A discovery payload (or a replayed buffer file) was accepted. |
| `payload_buffered` | Upstream unreachable; payload encrypted to disk. |
| `payload_retry` | A transient failure is being retried with backoff. |
| `payload_rate_limited` | Server returned `429`; snapshot dropped. |
| `auth_failed` | Key rejected (`401`/`403`); reporting halted, buffer cleared. **Operator action required.** |
| `config_reported` | Config handshake completed; any server suggestion included. |
| `snapshot_too_slow` | Collection exceeded its 10s budget; cycle skipped. |
| `tenant_id_missing` | No tenant id resolved; payloads will carry `tenant_id: null`. |
---
## Lifecycle
**Startup** — validate config (HTTPS URL, key present, `polling_interval ≥ 60`);
on failure the plugin logs `plugin_disabled` and stays dormant without affecting
the Router. On success it performs a health + config-report handshake, then
starts the snapshot and health loops.
**Runtime** — the first snapshot is emitted immediately so evidence appears
without waiting a full interval, then the plugin settles into `polling_interval`.
Snapshot collection runs under a 10s budget and reads from shared Router state
without blocking request routing.
**Shutdown** — the loops stop, the HTTP client closes, and a `plugin_stopped`
log records how many payloads were buffered vs. sent. The Router continues and
completes its own shutdown normally.
> **Config changes require a restart.** Like the rest of the Router config, the
> `compliance` block is read at startup. Hot-reload of the compliance settings is
> not yet supported.
---
## Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| `plugin_disabled` at startup | Missing key, non-HTTPS `server_url`, or `polling_interval < 60` | Check the `reasons` array in the log line. |
| `auth_failed`, reporting stops | `AISSURANCE_KEY` invalid/expired/blacklisted | Re-provision the key on aissurance.eu and restart. |
| Buffer files accumulating | Upstream unreachable | Confirm egress to aissurance.eu over HTTPS; check firewall/NAT. |
| `tenant_id` is `null` in payloads | No `AISSURANCE_TENANT_ID` / `compliance.tenant_id` | Set it, unless your key embeds the tenant. |
| `snapshot_too_slow` warnings | Slow/unhealthy backends delaying health probes | Investigate endpoint health; the cycle self-recovers next tick. |