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, }, }