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 aTenantQuery 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.
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.