Skip to main content

Tenant isolation

Composes with: the tamper-evident agent ledger.
Tracelane is multi-tenant from day one. Reads and writes are scoped to one workspace UUID in ClickHouse and in Postgres, with a CI check on each.

Isolation surfaces

1. ClickHouse — per-tier resource caps

Every ClickHouse read goes through a TenantQuery wrapper that attaches a SETTINGS block sized to the workspace’s plan tier: A misconfigured query bouncing off a WHERE clause that scans 5 B rows on a Builder workspace returns a ClickHouse-side MEMORY_LIMIT_EXCEEDED or TIMEOUT_EXCEEDED error — surfaced to the user, never silently downgraded — instead of taking down the shared node. Caps are enforced by ClickHouse itself; no application cooperation is required once the SETTINGS clause is attached. Wrapper file location: Read-path row and time-range caps are applied to every dashboard query. The CI guard no-raw-ch-query.sh fails the build if any new ClickHouse read is added outside these wrappers. The existing tenant-isolation-check CI job (unchanged from prior releases) blocks any new ClickHouse query that references FROM tracelane.<table> without a tenant_id filter nearby.

2. Postgres — WHERE tenant_id = ? + admin audit trail

Every Postgres row-bearing table has a tenant_id (or workspace_id) column indexed for the tenant-scoped query path. The CI grep tenant-isolation-check blocks any new query that selects from a ClickHouse table without a tenant_id filter; the equivalent on Postgres-side queries is enforced by Drizzle’s query-builder typing (every where() clause is statically checked against the schema). Adjacent to the row-level isolation, V1 ships admin_audit_log: a durable trail of selected mutating admin actions with the actor user id, workspace id, target type / id, before / after JSON snapshots, IP, and user agent. Indexed for “show me everything I did to this workspace in the last 30 days” — see Admin audit log.

Identity flow — where tenant_id comes from

The single security invariant: tenant_id MUST come from a JWT-verified session claim or a SPIFFE-attested workload SVID — never from a request body, URL parameter, or header.
For mTLS-only ingest (crates/ingest/src/auth.rs), the same value arrives via the SPIFFE SVID’s path component /tenant/<uuid>/ingest-worker TenantId::from_spiffe_svid(uuid) is structurally distinct from TenantId::from_jwt_claim(uuid) — downstream code can compile-time-prove which path the identity took.

Failure modes