M4d closes the last two tenant_cfg-derived runtime concerns that were primary-tenant-captured at server-build time after M4c. There are no operator-config schema changes; the rate_limit: and audit_sinks: blocks per tenant in tenants.yaml now actually apply per-tenant. Two changes operators will notice when upgrading from v0.6.x to v0.7.0-m4d:
- Per-tenant rate-limit budgets enforce. Previously, every session shared the primary tenant's
rate_limit:budget — a rogue session on tenant B could exhaust tenant A's token bucket. M4d populatesInProcessRateLimiter.per_tenantfromregistry.all_tenants()at boot, so each tenant_id gets an isolated token bucket sized from its owntenant.rate_limit:block. - Per-tenant audit sinks route. Previously,
MultiSinkAuditEmittercaptured the primary tenant'saudit_sinks:and fanned every event there regardless of the calling session's tenant. M4d refactors the emitter to dual-track:global_sinks(always-on, defaults to[StderrSink()]) plusper_tenant_sinks: {tenant_id: [...]}(overlay).emit(session)routes to globals +per_tenant_sinks.get(session.tenant_id, []). Cross-tenant audit leak is closed structurally.
One observability delta:
mcp_audit_drops_totalPrometheus counter gains atenantlabel dimension. Cardinality grows by N tenants in deployments usingQueuedSinkwrappers (Wazuh Indexer sink and any future async sinks).
Spec: docs/superpowers/specs/2026-04-27-wazuh-mcp-m4d-design.md. This doc walks the operator-facing pieces.
No schema change. The existing tenant.rate_limit: block in tenants.yaml now actually applies per-tenant:
tenants:
- tenant_id: tenant_a
rate_limit:
tenant: { capacity: 100, refill_per_sec: 10.0 }
session: { capacity: 10, refill_per_sec: 1.0 }
# ...
- tenant_id: tenant_b
rate_limit:
tenant: { capacity: 5, refill_per_sec: 0.5 }
session: { capacity: 5, refill_per_sec: 0.5 }
# ...At boot, build_http_app calls registry.all_tenants(), builds {t.tenant_id: t.rate_limit for t in all_tenants}, and passes it as InProcessRateLimiter(default=..., per_tenant=...). The limiter's existing _cfg(tenant_id) lookup hits the per-tenant map first and falls through to the default config only for tenant_ids not in the map.
Behavior delta from M4c: previously, a session minted for tenant_b would draw against tenant_a's 100-capacity bucket (the primary tenant's limit, not its own). Under M4d, that session draws against tenant_b's explicit 5-capacity bucket. Tenants no longer compete for shared budget.
Stdio mode is single-tenant by construction. build_app populates per_tenant={cfg.tenant.tenant_id: cfg.tenant.rate_limit} — a single-entry map functionally equivalent to the default but defense-in-depth so any future code path that consults the explicit map hits a real entry.
No-tenant fallback. If http_cfg.registry is absent (legacy callers, defensive paths) the per-tenant dict is {} and every tenant draws against the default. Production callers always pass a registry.
No schema change. The existing tenant.audit_sinks: block now actually applies per-tenant. Sample two-tenant config:
tenants:
- tenant_id: tenant_a
audit_sinks:
- kind: wazuh_indexer
index_prefix: tenant-a-audit
- tenant_id: tenant_b
audit_sinks:
- kind: wazuh_indexer
index_prefix: tenant-b-audit
- kind: jsonl
path: /var/log/wazuh-mcp/tenant-b.jsonlAt boot, build_http_app calls _build_per_tenant_sinks(registry.all_tenants(), indexer_pool=...) — a thin wrapper around the existing _build_sinks(tenant, indexer_pool=...) that builds a per-tenant dict and tags any construction failure with the offending tenant_id:
RuntimeError: audit sinks for tenant 'tenant_b' failed to build: <reason>
The dict is passed to MultiSinkAuditEmitter(per_tenant_sinks=...). global_sinks is left as the default [StderrSink()] — the always-on operator safety net.
Routing. On every audited tool call, emit(session) writes the event to:
- Every sink in
global_sinks(always — the safety net). - Every sink in
per_tenant_sinks.get(session.tenant_id, [])(overlay — empty list for unknown tenants).
Unknown tenant_id (no entry in the dict) routes to globals only. This preserves audit visibility for the M4c resolver-miss audit shape (tool="<rbac.resolve>", error_code="forbidden", error_reason="tenant_not_registered") and any future defense-in-depth paths that emit events for sessions whose tenant isn't registered.
Lifecycle. start() iterates the flat _all_sinks list (globals + per-tenant in dict order) and rolls back on any failure: previously-started sinks get stop()'d in reverse order before the exception propagates. stop() is best-effort across all sinks; failures are aggregated into a BaseExceptionGroup. The M4a sink-lifecycle invariants are preserved verbatim across the dual-track refactor.
Single-tenant deployments see no behavioral change. build_app (stdio) calls _build_per_tenant_sinks([cfg.tenant], indexer_pool=None) — a one-entry dict equivalent to the old single-list semantics, plus the implicit [StderrSink()] global default that was always there.
The drop-metric counter (incremented when a QueuedSink's bounded queue overflows or shutdown drains incomplete) gains a tenant label:
mcp_audit_drops_total{sink="wazuh_indexer", tenant="tenant_a", reason="overflow"}
mcp_audit_drops_total{sink="wazuh_indexer", tenant="<global>" , reason="overflow"}
Sentinel values:
tenant="<global>"— the dropping sink belongs toglobal_sinks(operator-shared safety net).tenant="<unknown>"— sink instance not found in either map (defensive; should not occur in practice).tenant="tenant_X"— sink belongs toper_tenant_sinks["tenant_X"].
Cardinality grows by N tenants in QueuedSink-wrapping deployments. With 50 tenants and 2 reasons per sink, the worst-case series count is N_tenants × N_sinks × 2 — manageable for typical deployments.
Identity-keyed labels. The label resolution uses Python id(sink) lookup, so two distinct sink instances with identical configuration (e.g. two WazuhIndexerSink(index_prefix="…") constructed from different tenant blocks) get distinct labels. Operators should pass distinct sink instances per tenant — the same physical instance shared across tenants would collapse to one label and is a configuration error.
After M4d, no production path routes one tenant's events to another tenant's sinks. The structural guarantees:
emit(session)readssession.tenant_idand looks up sinks inper_tenant_sinks.get(session.tenant_id, []). The lookup is O(1) and cannot return a different tenant's list._build_per_tenant_sinksbuilds the dict fromregistry.all_tenants()at boot — no inter-tenant aliasing.- The
global_sinkslist is intentionally tenant-agnostic. Operators who want NO global cross-tenant log should setglobal_sinks=[](empty list) explicitly when constructing aMultiSinkAuditEmitterdirectly. Note: stdio + HTTP boot paths always supply onlyper_tenant_sinks=, so the default[StderrSink()]global remains. Removing the stderr safety net is a code-level customization, not a YAML knob.
The unit suite pins the routing invariants in tests/unit/test_per_tenant_sink_fanout.py (six routing tests) and the lifecycle behavior in tests/unit/test_audit_emitter_lifecycle_multi_tenant.py (four start/stop/rollback tests). Per-tenant rate-limit isolation is pinned in tests/unit/test_per_tenant_rate_limiter.py (five tests).
tests/integration/conftest.py now declares two tenants in the inline tenants.yaml for the mcp_http_server fixture:
local— existing baseline.capacity=100, generous; indexlocal-audit-*.tenant_b— new.capacity=2, restrictive; indextenant-b-audit-*.
This is the M5 cross-tenant leak suite prerequisite. The integration tests in tests/integration/test_m4d_multi_tenant.py skip with a clear rationale: per-tenant token mint requires either a second Keycloak realm or a tenant_id claim mapper in the existing realm, and both are deferred to M5. Per-tenant fan-out and rate-limit are fully covered at the unit level today; the integration validation is gated on the M5 fixture refactor.
There is no operator-facing migration. The kwarg rename MultiSinkAuditEmitter(sinks=) → MultiSinkAuditEmitter(global_sinks=, per_tenant_sinks=) is internal to the codebase — no downstream consumer of the wazuh-mcp package constructs MultiSinkAuditEmitter directly. Pre-1.0.0 internal kwarg renames are not semver-breaking by project convention.
Tenant configs that already declare rate_limit: and audit_sinks: per tenant will see the intended behavior take effect on upgrade — no YAML changes required. Operators running single-tenant deployments will see no behavioral change.