Skip to content

Tenant isolation & the SQL guard

Memcove is multi-tenant: every caller sees only its own datasets, plus an optional shared read-only reference plane. Two mechanisms enforce this — a namespace scheme and the SQL guard that all read/derive/export/stream SQL passes through.

Private per-tenant namespaces

Each tenant maps to a private Iceberg schema named t_<id>. tenancy.normalize_tenant validates and normalizes the raw id (regex ^[a-z][a-z0-9_]{1,62}$, invalid characters replaced with _). A dataset labeled signups for tenant acme is physically the Iceberg table iceberg.t_acme.signups.

Isolation is layered:

  • Every registry row (metadata, lineage) is keyed by tenant.
  • Ingest binds upload handles to the caller: a handle must be prefixed uploads/{tenant}/, so one tenant cannot ingest another's pending upload.
  • All SQL is rewritten and validated by the guard (below).
  • Optionally, Trino impersonation re-checks access per tenant beneath the guard as defense-in-depth (see Security).

The shared reference plane

Beyond private data, operators can expose read-only reference schemas (configured via MEMCOVE_SHARED_SCHEMAS, default ["ref_market"]) that every tenant can query but none can write. These resolve to themselves rather than being rewritten into a tenant namespace. Agents discover them with discover_reference_data and read them by qualified name, e.g. SELECT * FROM ref_market.prices.

Nothing in the write path (PyIceberg) or the derive CTAS path ever targets these schemas, so they are effectively read-only to tenants. Use per-domain schemas (ref_market, ref_reference, …) rather than one catch-all to contain blast radius.

What the SQL guard does

sql_guard.validate_select(sql, tenant_ns, catalog, shared_schemas) is the single choke point for every read, derive, export, and stream query. It parses with sqlglot (Trino dialect) and enforces:

Read-only, single statement. Exactly one statement, and the top node must be a SELECT / UNION / INTERSECT / EXCEPT / subquery. INSERT, UPDATE, DELETE, MERGE, CREATE, DROP, ALTER, TRUNCATE, and raw commands are rejected. Agents never write SQL DDL/DML — persistence is API-driven through derive_dataset, which builds the CTAS itself from a validated SELECT.

Table-reference resolution. For every physical table reference, comparing names case-folded (so a quoted mixed-case identifier can't smuggle a foreign schema):

Reference Result
bare name (signups) or your own schema (t_acme.signups) rewritten to t_<tenant>.signups
a schema in shared_schemas (ref_market.prices) resolves to itself
a CTE name left alone (not a physical table)
any other tenant's schema rejected (cross-namespace)
a foreign catalog rejected
a metadata/enumeration schema (information_schema, system, jdbc, metadata, sys, pg_catalog) rejected
an unclassifiable FROM item (e.g. TABLE(system.query(...))) rejected (fail closed)

So SELECT * FROM signups becomes SELECT * FROM "iceberg"."t_acme"."signups" before it ever reaches Trino, and SELECT * FROM t_other.secrets never runs.

The guard returns a GuardedQuery with the rewritten SQL and the list of referenced labels (used to compute lineage on derive). wrap_preview additionally caps a validated SELECT at row_cap + 1 rows so callers can detect truncation, while preserving ORDER BY.

Why fail-closed matters

With MEMCOVE_TRINO_IMPERSONATION off (the default), the guard is the only isolation layer, so it rejects anything it cannot positively classify rather than passing it through. Turning impersonation on adds Trino's own access control beneath it as a second, independent layer.