Vapora/adrs/adr-010-multi-tenancy.ncl

86 lines
5.3 KiB
Text
Raw Normal View History

let d = import "adr-defaults.ncl" in
d.make_adr {
id = "adr-010",
title = "SurrealDB Scope-Based Multi-Tenancy with Application-Layer Defense-in-Depth",
status = 'Accepted,
date = "2024-11-01",
context = "Vapora serves multiple workspaces (tenants) from a single backend instance. Tenant isolation must guarantee that workspace A cannot read or write workspace B's data. The isolation mechanism must be enforced at a layer that application code bugs cannot bypass. SurrealDB scopes provide database-level isolation; application-layer tenant_id validation provides the defense-in-depth second layer.",
decision = "Multi-tenancy is enforced at two layers: (1) SurrealDB scopes — all database connections and queries execute within a scope tied to the workspace; (2) Application services validate tenant_id in every write and read query as a redundant check. The SurrealDB scope is the primary isolation guarantee. Application-layer filtering is defense-in-depth only — it must never be the sole isolation mechanism.",
rationale = [
{
claim = "Database-level scope enforcement cannot be bypassed by application code bugs",
detail = "A service layer bug that omits a WHERE tenant_id = ? clause will still fail to return another tenant's data if the connection is scoped. The scope check runs in the database before the query result is assembled.",
},
{
claim = "Application-layer validation catches bugs before they reach the database",
detail = "If the SurrealDB scope configuration has an error, application-layer tenant_id checks provide a second line of defense. Defense-in-depth means no single failure mode causes a tenant data leak.",
},
{
claim = "SurrealDB scopes are the most cost-effective isolation for a single-instance deployment",
detail = "Hard partitioning (separate database per tenant) would require N database connections, N migration runs, and N monitoring streams. Scopes achieve logical isolation at the query level without multiplying operational burden.",
},
],
consequences = {
positive = [
"Cross-tenant data leaks require both a scope misconfiguration AND an application bug simultaneously",
"Tenant onboarding is a scope creation operation — no schema migration or new instance needed",
"All SurrealQL queries are tenant-scoped by default — queries that omit tenant context fail, not return all data",
],
negative = [
"Cross-tenant analytics queries (aggregate usage across all workspaces) require a superuser scope connection — this is a privileged operation that must be explicitly controlled",
"SurrealDB scope token expiry handling must be implemented to prevent session leaks between tenant requests",
],
},
alternatives_considered = [
{
option = "Application-layer tenant_id filtering only",
why_rejected = "A single WHERE clause omission leaks all tenants' data. Application code bugs are more common than database configuration errors.",
},
{
option = "Separate database instance per tenant",
why_rejected = "N databases × all maintenance operations. Initial vapora deployment targets small-to-medium teams; the operational overhead of per-tenant instances is disproportionate.",
},
],
constraints = [
{
id = "no-application-only-isolation",
claim = "No service may rely solely on application-layer tenant_id filtering for tenant isolation — SurrealDB scope must be the primary mechanism",
scope = "vapora-backend/src/services/",
severity = 'Hard,
check = { tag = 'Grep, pattern = "\\.signin|scope|Scope", paths = ["crates/vapora-backend/src/services/"], must_be_empty = false },
rationale = "Application-layer-only filtering has caused multi-tenant data leaks in production systems. Database scope enforcement is not optional.",
},
{
id = "tenant-id-in-all-writes",
claim = "Every INSERT and UPDATE in service layer must include tenant_id binding",
scope = "vapora-backend/src/services/",
severity = 'Hard,
check = { tag = 'Grep, pattern = "tenant_id", paths = ["crates/vapora-backend/src/services/"], must_be_empty = false },
rationale = "Defense-in-depth requires the application layer to enforce tenant context independently of the scope mechanism.",
},
{
id = "no-cross-tenant-queries-without-superuser",
claim = "Queries that aggregate across tenant boundaries must use an explicitly designated superuser scope — not the tenant session",
scope = "vapora-backend/src/services/, vapora-analytics/",
severity = 'Hard,
check = { tag = 'Grep, pattern = "superuser|admin_scope|root", paths = ["crates/vapora-analytics/"], must_be_empty = false },
rationale = "A routine service accidentally executing with a superuser scope would leak data from all tenants in its result set.",
},
],
related_adrs = ["adr-004"],
ontology_check = {
decision_string = "SurrealDB scopes as primary tenant isolation; application tenant_id validation as defense-in-depth; no application-only isolation; no cross-tenant queries without superuser scope",
invariants_at_risk = ["multi-tenant-isolation", "surreal-persistence"],
verdict = 'Safe,
},
}