Fleet Scaling Plan: Postgres Registry, Redis Caching, RBAC and Audit
Status: Draft for review · Date: 2026-10-09 · Roadmap items: DEVELOPMENT_PLAN.md G3 (multi-controller orchestration), P2 (tool-level RBAC), P3 (append-only audit log), Phase 5 Redis event bus Implements the runbook:MULTI_CONTROLLER.md
1. Goal and decisions
Let one deployment of the MCP server manage hundreds to thousands of UniFi gateways and cloud accounts, scaling horizontally across replicas.
Hard constraint: with no
DATABASE_URL and no REDIS_URL set, behavior is byte-for-byte what it is today: one controller from UNIFI_* env vars, no new dependencies imported, and all 275 tools keep their current schemas apart from the new optional controller argument. The MCP_AUTH_TOKEN and stdio callers keep full access, and audit records keep going to the JSONL file.
2. Where the code stands today (survey, 2026-10-09)
3. Target architecture
3.1 The central idea: route in the wrapper, not in 275 tools
_make_tool_wrapper already removes settings from each tool’s public signature and injects it. We extend it to:
- Add an optional
controller: str | None = Noneparameter to the public signature (skipped for tools that are controller-agnostic, e.g. registry management tools). - On each call, resolve the target: explicit argument →
ctx.get_state("active_controller")→ registry default → error. - Build (or fetch from an LRU) a per-controller
Settingsviabase_settings.model_copy(update=profile.settings_overrides()), and inject that assettings. - Bind
controller_idinto aContextVarfor the duration of the call, so the client pool, cache keys, audit records, and logs all pick it up without signature changes.
UniFiClient(settings), and because settings is now per-controller, every existing tool becomes multi-controller without editing it. This is the low-churn path that makes the plan feasible.
The same wrapper is also where access control and auditing happen, so each call runs one fixed pipeline: identify the principal → resolve the controller → authorize (§3.6) → apply read_only/dry_run → run the tool → write the audit record (§3.7). Doing all of it in one place is what makes coverage automatic instead of per-tool.
Mutation safety: if a tool declares confirm (the existing mutating-tool convention), the resolver requires the target to come from an explicit argument or a session selection made in this session. A registry default alone is not enough when more than one controller is registered. That keeps the “accidental cross-controller write” failure mode in MULTI_CONTROLLER.md closed.
3.2 Postgres (system of record)
Stack: SQLAlchemy 2.0 async + asyncpg + Alembic, shipped as an optional[fleet] extra so the base install is unchanged.
The registry is read through a
ControllerRegistry protocol with two implementations:
EnvRegistry: a single controller nameddefault, built from today’sUNIFI_*env vars. Always available, so this is the zero-config path.PostgresRegistry: backed by the tables above, with an in-process snapshot refreshed on a short interval and onregistry:changedpub/sub events, so the hot path never queries Postgres per tool call.
3.3 Redis (shared, ephemeral state)
All keys are namespacedunifi:v1:{controller_id}:... (the version segment lets a deploy invalidate everything by bumping it).
3.4 Connection management at thousands of controllers
- ClientPool keyed by
controller_id, holding one long-livedhttpx.AsyncClientper controller. LRU-bounded (FLEET_MAX_OPEN_CLIENTS, default 256) with idle eviction, since keeping a thousand TLS pools open per replica isn’t viable.UniFiClient.__aenter__takes the pooled transport when aContextVarpool is active, and__aexit__does not close it. Without a pool, behavior is unchanged. - Per-controller circuit breaker: after consecutive failures, mark
health=downand fail fast with a clear error for a cool-off period instead of burning timeouts. The worker’s health checks close it again. - Fan-out reads (new
fleet_*tools, read-only): bounded concurrency (asyncio.Semaphore, configurable), a per-controller timeout, and partial results: each controller returns{ok, data}or{error}, never all-or-nothing. They preferdevice_inventorysnapshots when fresh enough, and go live only on request.
3.5 Worker tier
A new entry pointpython -m src.worker (same image, different command):
- Controllers are sharded across workers by consistent hashing over Redis-registered worker IDs. Shard ownership is held by leases, so a dead worker’s shard is picked up after lease expiry.
- Jobs: health check (cheap probe, updates
health/capabilities), site sync →sites, inventory sync →device_inventory(staggered with jitter so a thousand controllers aren’t all polled at :00), and cloud-account discovery (Site Manager/v1/hosts→ upsertcontrollerswithcloud_account_id). - API replicas never poll. They read snapshots and do targeted live calls.
3.6 Access control (RBAC)
Risk tiers. At registration every tool gets exactly one tier, recorded in a tool → tier map:
A snapshot test pins the whole tool → tier map, so any change in classification shows up in review. A second test flags any mutating tool whose name suggests deletion or reset but which isn’t in
DESTRUCTIVE_TOOLS, unless it’s explicitly exempted. The A2A _required_permission name-prefix logic is replaced by a lookup in the same map.
Roles are built in, not configurable, so they stay easy to reason about:
A token can be narrowed, never widened, beyond its role:
allow_modules / deny_modules (tool modules such as firewall or protect_*) and controller_selector (for example {"labels": {"site_group": "retail"}}), which limits the controllers it can resolve.
Principals. build_auth_provider returns the token’s claims (token_id, name, role, filters). The wrapper puts a Principal in a ContextVar for the call, where the resolver, the authorizer and the audit writer all read it.
- DB tokens are 32 random bytes generated by the server, shown once, and stored as SHA-256 (enough for high-entropy tokens; no password hashing needed). They support
expires_atandrevoked_at. Revocation reaches every replica through thetokens:changedpub/sub message, and token lookups are cached for at most 30 seconds. MCP_AUTH_TOKENtokens map toadmin, which keeps today’s behavior. They serve as break-glass access and are flagged in audit records.- stdio is a local, single-user channel: its principal is
admin(configurable withUNIFI_STDIO_ROLE).
PermissionDeniedError that names the required tier but not what the token could have done, and it produces a denied audit record. As a usability step, tools a principal can’t call can also be hidden from that session’s tool list (FastMCP’s per-session disable_components). Hiding is cosmetic; enforcement does not depend on it.
Token management (fleet-admin only, confirm=True, each one an audited admin event): create_api_token (returns the token once), list_api_tokens (never returns secrets), revoke_api_token, update_api_token_role, plus an unifi-mcp tokens CLI for bootstrapping the first fleet-admin token.
3.7 Audit logging
What gets recorded. The wrapper writes one record for every:- mutating call, including dry-run, with result
success,errorordry_run - denied call: RBAC refusals and rejected write targets (§3.1), with result
denied - admin/config event: token create/revoke/role change, controller registry changes, credential and key rotation, audit queries and exports, and server start with its effective safety configuration (
read_only,dry_run, profile, registry source)
UNIFI_AUDIT_READS=true (added later, AC-531): then each read gets the same attempt and outcome records as a mutating call, with its arguments but never its result, and UNIFI_AUDIT_FAIL_CLOSED covers it.
Record (schema v2):
Write-ahead for mutations. For a mutating call the wrapper writes an
attempt record before contacting the controller, and the outcome after. If the primary audit store can’t take the attempt record, the call is refused (UNIFI_AUDIT_FAIL_CLOSED=true, the default for writes), so no write happens without an audit trail. Denied and admin records follow the same rule.
Migration from manual calls. The 167 existing log_audit/audit_action calls keep working during the transition. While the wrapper’s record is open, those calls attach their details to it instead of writing a second record. They’re then removed module by module, and a test confirms each mutating tool produces exactly one record.
Sinks. An AuditSink protocol with these implementations:
JsonlSink: today’s file, still the default without a database. It gets size/date rotation, and the chain continues across files.PostgresSink: the append-onlyaudit_logtable (§3.2). It’s primary wheneverDATABASE_URLis set.- SIEM exporters: syslog (RFC 5424 over TLS), OTLP logs and an HMAC-signed HTTPS webhook. They’re asynchronous and best-effort, with an on-disk retry buffer, so a slow SIEM never blocks a tool call. They receive the stored (encrypted-payload) form of each record. Configuring a target requires
UNIFI_AUDIT_CHAIN_KEY(AC-531).
hash is an HMAC-SHA256, keyed by UNIFI_AUDIT_CHAIN_KEY (separate from the payload encryption key), over the canonical JSON of the stored record plus prev_hash. Each replica keeps its own chain (chain_id = instance id), so writes don’t serialize across replicas. Because hashes cover the stored ciphertext, verification doesn’t need the payload key. Chain heads are regularly written to audit_chain_heads and sent to the SIEM as anchors, so truncating a chain or rewriting it wholesale is detectable from outside the server. Verification uses verify_audit_chain (MCP tool) and unifi-mcp audit verify (CLI).
Retention. UNIFI_AUDIT_RETENTION_DAYS (unset = keep forever). Expired records are verified, exported to compressed JSONL with their chain heads, and only then deleted; JSONL files are rotated and archived the same way. (As built in 5c: no monthly partitions; see Phase 5.)
Querying (fleet-admin only; every query is itself an audited admin event): search_audit_log (filters: time range, principal, controller, tool, tier, result, event type; cursor pagination; payloads decrypted only when the payload key is configured), verify_audit_chain, and export_audit_log. The existing python -m src.utils.audit_decrypt CLI keeps working for JSONL.
4. Phased delivery
Each phase is independently shippable, keeps the zero-config path identical, and has explicit verification.Phase 0: Foundations and fixes (no behavior change)
- Add
redis_url/redis_*fields toSettingsand fixCacheClientto honor them (fixes the documented-but-ignoredREDIS_HOST). Move to a shared connection pool. - Add
database_url,fleet_*settings. Add[fleet]extra (sqlalchemy[asyncio],asyncpg,alembic,redis). - Add
ControllerProfile(Pydantic) and theControllerRegistryprotocol withEnvRegistry. docker-compose.fleet.ymlwith Postgres 16 + Redis 7 for development and integration tests.- Verify: full unit suite unchanged; a new test proves
REDIS_HOSTis honored; an import test proves the base install imports nosqlalchemy/asyncpg/redis. - Status (2026-10-09): done, with three items deferred to Phase 2 so that no setting ships before the code that reads it:
DATABASE_URL/fleet_*settings, the Postgres/SQLAlchemy/Alembic packages in[fleet](which holds onlyredisfor now), anddocker-compose.fleet.yml(the existingdocker-compose.ymlalready runs Redis). The import test blocks the fleet packages instead of checkingsys.modules, because a developer venv can carryredistransitively: an installedpydocketmakes FastMCP import it, although the locked FastMCP does not depend on it.
Phase 1: Routing core
- Extend
_make_tool_wrapperper §3.1:controllerparameter, resolver, per-controllerSettingsLRU, and acontroller_idContextVar. - New tools:
list_controllers,select_controller,get_active_controller. - Tier map and principal plumbing (§3.6): classify every tool, add
DESTRUCTIVE_TOOLS, the tier snapshot test, and thePrincipalContextVar. Point A2A’s permission check at the tier map. Every caller is stilladmin, so behavior doesn’t change yet. - Wrapper-level audit (§3.7) with schema v2 and the attempt/outcome pattern, written to
JsonlSinkwith the HMAC chain, plusunifi-mcp audit verify. Start migrating the manual audit calls. - Record
controllerin every audit record and log line. Expose a per-controller call-count gauge only, with no controller label on the per-tool histogram (275 tools × 1,000 controllers would be a cardinality blow-up; see DEVELOPMENT_PLAN risk register). - Verify: concurrency tests where two sessions with different selected controllers interleave 1,000 calls and every request hits its own controller (respx-mocked hosts). A mutating tool with only a registry default and ≥2 controllers fails with a clear error. Tool JSON schemas are snapshot-tested: only the added optional
controllerfield changes. Every mutating tool produces exactly one audit record per call, including dry-run and error paths. Editing, deleting or reordering a JSONL line makesaudit verifyfail. - Status (2026-10-09): done. Notes from implementation:
- Each call writes an
attemptand an outcome record sharing acall_id, so “one record per call” is one record pair; manuallog_auditcalls inside a call fold into the outcome’sdetails. The 167 manual calls remain and are harmless; removing them is cleanup, not a correctness need. - The verifier is
python -m src.utils.audit_verify(matchingaudit_decrypt) rather than aunifi-mcp audit verifysubcommand. WithoutUNIFI_AUDIT_CHAIN_KEYthe chain uses plain SHA-256. - Tiers implemented:
read,write,destructive(45 tools).fleet-adminarrives with the first admin tools in Phase 2a/2b. - Classifying tiers surfaced that A2A delegation treated
restore_backup,upgrade_deviceand other mutating tools as reads with no confirmation; A2A now uses the registry tiers. - MCP resources still read the default controller.
- Each call writes an
Phase 2a: Postgres registry and encrypted credentials
- Alembic baseline migration for the §3.2 tables.
-
PostgresRegistrywith snapshot +registry:changedrefresh. Credential encryption reusingaudit_encryptionprimitives underUNIFI_FLEET_CREDENTIAL_KEY. -
Management tools (
fleet-admintier,confirm=True, dry-run aware):register_controller,update_controller,disable_controller,register_cloud_account,rotate_fleet_credentials(re-encrypt under the newest key). -
unifi-mcp fleet importCLI that seeds the registry from the current env config, for migration. - Verify: migration up/down tests against a real Postgres (testcontainers, in CI when Actions is unblocked). No API key or plaintext credential ever appears in logs, audit, errors, or tool output (property test modeled on the PR #193 key-never-leaks tests). Registry outage → the last snapshot keeps serving reads, and registry writes fail loudly.
-
Status (2026-10-09): done. Notes from implementation:
- Tables created:
credentials,cloud_accounts,controllers.sites,device_inventory,api_tokensand the audit tables come with the phases that use them (2b, 4), as the Phase 0 rule requires.controllershas noconsole_id,healthorcapabilitiesyet (Phase 4 discovery and health checks). - First start with an empty registry seeds the
UNIFI_*controller asdefault(audited);python -m src.fleet.cli importregisters it under another name. Migrations run only throughpython -m src.fleet.cli migrate; the server checks the revision at startup and refuses to run on an older schema. - The snapshot refreshes on an interval and right after this server’s own changes. Cross-replica
registry:changedpub/sub is deferred to Phase 4 with the rest of the multi-replica work; until then other servers see a change within the refresh interval. disable_controllerisupdate_controller(enabled=false).- Tests run against a real Postgres 16 started by
pgserver(a dev dependency), so CI needs no Docker. Thedocker-compose.fleet.ymlimage build was not exercised locally (Docker Hub pulls fail on the development machine).
- Tables created:
Phase 2b: Access control and the audit store
-
api_tokens, roles and enforcement (§3.6):create/list/revoke/update_api_tokentools and theunifi-mcp tokensCLI, module filters,controller_selector, revocation viatokens:changed,PermissionDeniedErroranddeniedrecords, optional per-session tool hiding. -
PostgresSinkas primary audit store with the INSERT/SELECT-only role,audit_chain_heads, fail-closed write-ahead, and admin/config events. -
search_audit_log,verify_audit_chain,export_audit_log(fleet-adminonly). -
Verify: a role × tier matrix test that runs every role against a sample tool of every tier, plus module and controller filters, and checks allowed calls succeed and denied ones neither reach the (mocked) controller nor skip the
deniedrecord. A revoked token stops working on every replica within 30 seconds. With the audit store down, mutating calls are refused and reads still work. The database role can’tUPDATEorDELETEaudit_log(tested against real Postgres). No token secret appears in any log, audit record or tool output. -
Status (2026-10-10): done. Notes from implementation:
- Decisions: break-glass callers (
MCP_AUTH_TOKEN, stdio) arefleet-admin, notadmin, so enforcement removes nothing they could do;UNIFI_STDIO_ROLElowers stdio. Tokens default to a 90-day lifetime. With DATABASE_URL, audit records go to Postgres only. - Append-only is a trigger refusing UPDATE/DELETE/TRUNCATE for every role, plus an optional least-privilege runtime role (
MIGRATION_DATABASE_URLfor the schema owner; grants in SECURITY.md) rather than a required one. A tamper test bypasses the trigger as a superuser and the hash chain still catches the edit. - Revocation propagates through the verifier’s 30-second cache rather than
tokens:changedpub/sub (Phase 4). audit_logis not partitioned yet; partitioning and the archive-then-drop retention arrive together in Phase 5.- Optional per-session tool hiding is not implemented; enforcement does not depend on it.
- Refusals of reads are audited too (the plan listed RBAC refusals without limiting them to writes).
- Decisions: break-glass callers (
Phase 3: Connection pool and caching on the hot path
-
ClientPoolwith LRU/idle eviction and circuit breakers (§3.4). Auth/site-map caching (§3.3). - Wrapper-level read-through cache for an allowlist of high-traffic read tools (devices, clients, sites, networks, WLANs), plus mutation-driven invalidation.
-
Verify: benchmark harness with 500 simulated controllers on a mock server: p50/p95 tool latency and controller-side request count before vs. after (target: ≥80% fewer controller requests for repeated reads; no
/ea/sitesprobe per call). A stale-read test confirms a mutation is visible on the next read. -
Status (2026-10-10): done. Notes from implementation:
- Decisions: the read cache is active only with explicit Redis configuration; TTLs are the existing
CacheConfigvalues; cached tools gain an optionalfreshargument. - Benchmark (
scripts/benchmark_fleet.py, 500 controllers × 5 reads, 20 ms simulated latency): baseline 5,000 controller requests and 2,500 auth probes; pool 3,000 and 500 (p50 43 → 22 ms); pool + cache 1,000 and 500, an 80% reduction. Latency with the cache is not representative there (fakeredis runs in the benchmark’s process). - The benchmark exposed that a pool smaller than the fleet gave no benefit for round-robin access, because evicting a connection dropped its auth state. Per-controller state (auth, rate limiter, breaker) now lives in a separate LRU of up to 10,000 controllers, independent of open connections.
- Shared connections make
UNIFI_RATE_LIMIT_REQUESTSeffective for the first time (each call used to get a fresh limiter); recorded under Changed. - Invalidation clears all of a controller’s cached reads on any write instead of per resource family: simpler, and writes are rare next to reads.
- The auth/site-map cache is in-process rather than in Redis, and single-flight is per process; both become cross-replica concerns in Phase 4.
- Statistics and RF-health tools are not cached (live metrics).
ProtectClientshares the pooled connection but keeps its own auth probe and has no breaker;SiteManagerClient(one cloud endpoint) is not pooled.
- Decisions: the read cache is active only with explicit Redis configuration; TTLs are the existing
Phase 4: Horizontal scale
-
Redis(dropped, see the spike below), distributed rate limiting, pub/sub invalidation.session_state_store -
Session affinity decision: spike done (2026-10-10). Two FastMCP 3.2 replicas sharing one state store, driven with real MCP JSON-RPC:
FastMCP keys session state by the
mcp-session-idheader, and falls back to a fresh UUID when it is missing. A stateless server never issues one, so every request gets a new key. That rules out both options as written: a shared Redis store cannot carry a session across replicas (the session itself is gone, so the client re-initializes with a new id), and Option B would breakselect_controllereven on one replica. Decision: sticky routing per client is the supported multi-replica mode (MULTI_CONTROLLER.mdhas a load-balancer example).MCP_STATELESS_HTTP=trueruns without sessions so any replica can answer any request; thereselect_controlleris refused with a clear error and calls passcontroller=<name>(or use the registry default). The Redissession_state_storeitem is dropped. -
Done in 4a:
MCP_STATELESS_HTTP; the session guard (issue #173) is skipped in that mode, since it would refuse every sessionless call.- Per-controller rate limits shared through Redis: a sliding window in a
MULTItransaction, one window per API key for api.ui.com, and a local fallback when Redis is down. registry:changedpub/sub, so every replica reloads its snapshot right after a change.
-
Done in 4b (worker tier, §3.5):
python -m src.worker(and aworkerservice indocker-compose.fleet.yml). Migration0004addssites,device_inventoryand thehealth,health_detail,health_checked_at,last_seen_atandcapabilitiescolumns ofcontrollers.- Sharding: workers keep a heartbeat in a Redis sorted set and assign each controller by rendezvous hashing (highest
hash(worker, controller)), which moves only the leaving or joining worker’s share. A worker works only on controllers whoseSET NX PXlease it holds; a dead worker’s controllers move after the lease (UNIFI_FLEET_WORKER_LEASE_SECONDS), and a clean shutdown releases them at once. Without Redis one worker owns everything. - Jobs: health (authentication probe plus the Network version on local controllers;
degradedon a failure,downafterUNIFI_FLEET_WORKER_DOWN_AFTERin a row), site sync for every controller, device inventory per site for local controllers. First runs are offset by a hash of the controller name across the interval, later ones jittered ±10%, with at mostUNIFI_FLEET_WORKER_CONCURRENCYjobs at once. Jobs go through the pooledUniFiClient, so with Redis the worker shares each controller’s rate limit with the API replicas. - Decisions and gaps: rendezvous hashing instead of a hash ring (same properties, no ring to maintain). Lease renewal is a pipelined
GETthenPEXPIRE(no Lua, as in 4a); if a lease expires between the two, at worst one job runs twice, and every job is an idempotent snapshot replace. Cloud controllers get sites only, because the cloud APIs list devices per host rather than per site. Cloud-account discovery is deferred: cloud controllers route by API key, not by console, so a discovered console would have nothing to route to until per-console routing exists. API replicas do not read the snapshots yet (the per-client site map stays, and health does not close circuit breakers); both are left for Phase 5 (see 4c below).
-
Done in 4c (fleet-wide reads, §3.4):
fleet_health,fleet_sitesandfleet_inventoryanswer from the worker’s snapshots (one query, no controller calls, ages on every entry; health older than three intervals isstale).fleet_inventoryfilters by model, state andfirmware_below(numeric version comparison).fleet_fanoutruns one read-tier tool live on many controllers withUNIFI_FLEET_FANOUT_CONCURRENCY, a per-controller timeout and an overall deadline, returning{ok, data}or{ok: false, error}per controller. Each per-controller call goes through FastMCP and the tool wrapper, so role, module filters, controller scope and the read cache apply as for a direct call; write, destructive andfleet_*tools are refused.- All four are
readtier in modulefleet, see only controllers in the caller’s scope, and are registered withDATABASE_URL. docker-compose.fleet-scale.yml(Redis, three replicas behind nginx hashing on client address and bearer token, two workers) andmake verify-fleet-scale.- Not done: snapshot data does not yet replace the per-client site map on API replicas, and worker health does not close replicas’ circuit breakers; both are optimizations rather than correctness gaps, and stay open for Phase 5.
- Verify: a 3-replica + 2-worker compose test. Killing a worker leads to its shard being reassigned within lease TTL + 1 interval. Rate-limit test: 3 replicas combined never exceed one controller’s budget. Fan-out over 1,000 mocked controllers with 5% failing returns partial results within the configured deadline.
-
Status (2026-10-11): done. Docker cannot run on the development machine, so the compose file was validated with
docker compose configonly, and the topology was verified with real processes instead (scripts/verify_fleet_scale.py: threepython -m srcreplicas and twopython -m src.workerprocesses, Postgres from pgserver, Redis from fakeredis over TCP, an HTTPS mock answering as 40 controllers, two of them unreachable). Results, the same over four runs of the final script:- The workers split the 40 controllers (17/23 to 21/19); after
SIGKILLof one, the survivor held all its controllers 3.1–3.9 s later (lease 3 s, tick up to 5 s), and every controller was health-checked again within 10 s. - A controller registered on one replica was listed by the other two within 0.26 s with the periodic refresh at 300 s.
- Three replicas calling one controller 18 times together, plus its worker, never put more than 5 requests in any 1 s window at a limit of 5/s (measured on arrival, with 50 ms allowed for network jitter).
fleet_fanoutover 41 live controllers returned 39 results and 2 errors in 0.5–0.7 s; the unit test does the same over 1,000 controllers with 5% failing and one hanging, inside the deadline.fleet_healthandfleet_inventoryreturned the synced snapshots through a replica.
- The workers split the 40 controllers (17/23 to 21/19); after
Phase 5: Hardening and documentation
- SIEM export (syslog/OTLP/webhook) with the on-disk retry buffer, and chain-head anchoring to the SIEM. Retention (
UNIFI_AUDIT_RETENTION_DAYS): verify, archive, then drop. - Security drills: an RBAC deny-path review of every tool tier, a tamper test that edits, deletes and truncates records in both stores and expects
verify_audit_chainto catch each one, and a SIEM-outage test that confirms tool calls aren’t blocked. - Degradation drills: Redis down → cache bypass, local rate limiter at
budget / expected_replicas, explicitcontrollerrequired (no session default). Postgres down → snapshot reads only. - Update
MULTI_CONTROLLER.md(registry now real),SECURITY.md(roles, tokens, audit integrity), README configuration,.env.example, API.md (new tools), DEVELOPMENT_PLAN G3 status. - Verify: chaos tests in the compose environment; docs reviewed against the implemented env vars.
- Done in 5a (drills):
- RBAC deny-path drill (
tests/unit/fleet/test_drill_rbac.py): every one of the 364 tools registered in local mode with the fleet tools (212 read, 93 write, 45 destructive, 11 fleet-admin; the 3 session tools check scope themselves) is called as every role lacking its tier: 149 refusals forviewer, 56 foroperator, 11 foradmin. Each is refused before any network call and leaves adeniedaudit record. Nothing escaped. - Tamper drill (
test_drill_tamper.py): edits, deletions, reordering, deleting the newest records, a whole chain and the whole table, as the superuser with triggers disabled. Found: Postgres verification did not compare chains withaudit_chain_heads, so deleting the newest records or a whole chain verified clean. Fixed:verify_audit_chainchecks each chain ends exactly at its recorded head (rows and heads read in one snapshot), andaudit_verify --head CHAIN:SEQ:HASHdoes the same for files with an anchor kept elsewhere. Review added two more: a chain whose head row was deleted is reported, and each row’s searchable columns (whichsearch_audit_logfilters on) are checked against the sealed record. Rewriting records and heads together still needs an external anchor (5b), and the drill documents that limit. - Degradation drills (
test_drill_degradation.py, plus the existing outage tests): Redis down falls back to a local rate limit ofbudget / UNIFI_FLEET_EXPECTED_REPLICAS(new; previously each replica kept the full budget, so N replicas sent N times it; one fallback bucket per controller per process, so clients built outside the pool share it; count workers too, asdocker-compose.fleet-scale.ymldoes with 5), the cache is bypassed and workers pause rather than double-working. Postgres down: the snapshot tools, audit queries and worker fail or retry without echoing database credentials, and the worker keeps going. - Not applicable: “explicit
controllerrequired (no session default)” when Redis is down. That item assumed sessions in Redis, which the Phase 4 spike dropped; sessions live in each replica’s memory and survive a Redis outage.
- RBAC deny-path drill (
- Done in 5b (SIEM export and anchors):
src/utils/siem.py: syslog (RFC 5424 over TLS, RFC 5425 framing), HMAC-signed HTTPS webhook and OTLP/HTTP JSON logs, all on the existinghttpx/stdlib with no new dependency.UNIFI_AUDIT_SIEM_TARGETStakes several at once. Plain-text transports are refused (webhook) or warned about (syslog, OTLP) unless the host is loopback.- Each target has its own on-disk buffer (0700/0600; the directory name is a hash, so no URL lands on disk) of sealed segments delivered in order with backoff. Records are exported only after they are stored, in the stored (encrypted-payload) form, so the SIEM’s copy verifies with the chain key alone.
forward()never raises. - Anchors: every
UNIFI_AUDIT_SIEM_ANCHOR_SECONDSand on shutdown, ananchorevent per chain that moved.audit_verify --anchors FILEtakes the anchors exported from the SIEM. This closes the 5a limit: the tamper drill now rewrites records and heads together in Postgres, verifies clean on the server, and is caught against the SIEM anchor. - SIEM-outage drill (
tests/unit/utils/test_siem.py): with the target down, 50 audited calls each cost under 10 ms in the audit path, everything is delivered in order once it comes back, the buffer survives a restart, the cap drops the oldest and counts them, and shutdown while down keeps the buffer. - Not done here: chain-head writes to
audit_chain_headsstay per-record (5a);anchored_atis still unused, since anchors are tracked by the SIEM rather than the database.
- Done in 5c, part 1 (Postgres retention):
python -m src.fleet.cli audit-retention(dry run by default,--confirmto act) withUNIFI_AUDIT_RETENTION_DAYSandUNIFI_AUDIT_ARCHIVE_DIR. It verifies the whole store, verifies the expired records again inside the deleting transaction, writes them to a gzip JSON-lines archive (0600, in a 0700 directory) plus a manifest of each chain’s first and last record and the archive’s SHA-256, fsyncs both and their directory, and only then deletes. Only each chain’s expired prefix is read. The run’sadminevent is written in the deleting transaction, so there is no delete without it. A failed delete removes the archive again, unless the database cannot confirm the transaction rolled back (a lost COMMIT reply), in which case the archive is kept.UNIFI_READ_ONLYrefuses--confirm, and the job needs no controller credentials. Downgrading below migration0005is refused onceaudit_archivesholds checkpoints.- Each chain loses a prefix only: everything from its first not-yet-expired record stays, so the rest still links (a record that looks old after a newer one, from clock skew, stays too).
- New append-only
audit_archivestable records where each chain was cut.verify_audit_chainresumes each chain right after its latest archive, reports gaps between successive archives, and accepts a fully archived chain whose head is its last archived record (a fully archived chain whose head row was deleted is reported). Deleting archive rows to hide records is caught. audit_verify ARCHIVE.jsonl.gz --manifest ARCHIVE.manifest.jsonchecks an archive on its own: its SHA-256, its record count, that every chain in it is in the manifest, and that each runs exactly from its recorded start to its end. A truncated or corrupt archive fails the check.- The append-only trigger now allows one thing: a DELETE from
audit_logby the table’s owner in a transaction that setunifi_mcp.audit_retention. Other roles, even with a DELETE grant, and TRUNCATE are still refused; the least-privilege runtime role needs onlySELECTonaudit_archives. - Deviation from the plan: no monthly partitions. Partitioning
audit_logwould need the partition key (ts) in every unique constraint, which would drop the database’s own guarantee that(chain_id, seq)andevent_idare unique. Retention deletes in batches of 1,000 instead, run by an operator or a scheduled job as the schema owner; the server never deletes audit records.
- Done in 5c, part 2 (JSONL rotation and retention):
UNIFI_AUDIT_LOG_ROTATE_MB: once the audit file reaches that size the writer renames it to<log>.<UTC time>Z(microseconds, with a-Nsuffix on a clash, so names sort oldest first) under the same lock that orders the chain. The chain carries on in the next file. A new, empty file is created at once. If the rename fails (for example a file bind mount in Docker), rotation turns itself off with one error until restart. Only files whose whole suffix is a rotation timestamp count as rotated.get_recent_operationsreads back through rotated files.audit_verifytakes several files as one log (audit.log.[0-9]* audit.log), and reads.gzarchives.python -m src.utils.audit_retention LOG(dry run unless--confirm): verifies all files, then takes the leading rotated files whose every record, and the file’s modification time, are older than the cutoff. It never touches the file being written, so it is safe while the server runs. It writes one archive with the same manifest as the Postgres job (sharedsrc/utils/audit_archive.py), appends one line per run to<log>.checkpoints.jsonlholding every chain’s last archived record (a single append, fsynced, and the directory fsynced when the file is new, so a crash leaves at most a torn last line, which is ignored), and only then deletes the files. A run stopped between the checkpoints and the delete is finished by the next one, once that run’s archive is confirmed present and unchanged. A delete that fails is reported rather than ignored. Records are streamed into the archive, so memory use does not grow with the log.audit_verify ... --checkpoints FILEresumes chains there.- The run is an audited
adminevent in the log itself;UNIFI_READ_ONLYrefuses--confirm. - Limit: someone who can rewrite the files can also rewrite the checkpoint file, so truncating the oldest records and moving the checkpoint forward is caught only by a copy kept elsewhere (the SIEM, or the archives on separate storage); the same holds for
audit_archivesin Postgres.
- Done in 5d (Phase 4 leftovers and docs):
- Worker health half-opens replicas’ circuit breakers: after each successful check the worker publishes the controller’s connection key (a hash, no credential) and how long the check took on
unifi:health:v1:up(HealthBus,src/fleet/bus.py). Each replica dates the check on its own monotonic clock (arrival minus duration; pub/sub delay only makes it look later), so no two hosts’ clocks are compared, and moves an open breaker for that controller to half-open if it opened before the check started: calls resume at once, one more failure re-opens it. Without Redis the cooldown applies as before; a worker that cannot reach Redis warns once per outage, not per controller. - Not done, by decision: replacing the per-client site map with the
sitessnapshot. The pool already caches the authentication probe and site map per controller for 5 minutes, and the probe is the/ea/sitesrequest the map is built from, so reading the snapshot would save no request. - Still deferred: cloud-account discovery, for the reason in 4b (no per-console routing).
- Docs reviewed against the settings: a test now requires every setting in API.md and
.env.example, and that neither names a setting that does not exist. That removedDRY_RUN(the setting isUNIFI_DRY_RUN),UNIFI_CONTROLLERSandUNIFI_WEBHOOK_REDIS_URL(never implemented) andMCP_LOG_LEVEL(the setting isLOG_LEVEL), and documented the 10 settings that were missing (7 from API.md, 6 from.env.example).UNIFI_CACHE_TTLis documented as unused: read-cache lifetimes are fixed per resource type.
- Worker health half-opens replicas’ circuit breakers: after each successful check the worker publishes the controller’s connection key (a hash, no credential) and how long the check took on
5. Risks and open questions
6. Out of scope (for this plan)
- Multi-tenant isolation (row-level security, per-tenant quotas). The schema doesn’t preclude adding
tenant_idlater, but nothing here builds it. - External secret stores (Vault, AWS Secrets Manager). Credentials live encrypted in Postgres, per the decision above.
- A web UI for the registry. Management is via MCP tools and the CLI.
- OIDC/JWT identities from an external IdP. The
Principalabstraction leaves room for another token verifier later, but this plan only issues its own tokens. - Auditing read calls, and per-tool (rather than per-tier and per-module) permission lists.
