ADR-025: SurrealDB Scope-Based Multi-Tenancy
Status: Accepted | Implemented Date: 2024-11-01 Deciders: Security & Architecture Team Technical Story: Implementing defense-in-depth tenant isolation with database scopes
Decision
Implementar multi-tenancy via SurrealDB scopes + tenant_id fields para defense-in-depth isolation.
Rationale
- Defense-in-Depth: Tenants isolated en dos niveles (scopes + queries)
- Database-Level: SurrealDB scopes enforced en DB (no app bugs can leak)
- Application-Level: Services validate tenant_id (redundant safety)
- Performance: Scope filtering efficient (pushes down to DB)
Alternatives Considered
❌ Application-Level Only
- Pros: Works with any database
- Cons: Bugs in app code can leak data
❌ Database-Level Only (Hard Partitioning)
- Pros: Very secure
- Cons: Hard to query across tenants (analytics), complex schema
✅ Dual-Level (Scopes + Validation) (CHOSEN)
- Both layers + application simplicity
Trade-offs
Pros:
- ✅ Tenant data isolated at DB level (SurrealDB scopes)
- ✅ Application-level checks prevent mistakes
- ✅ Flexible querying (within tenant)
- ✅ Analytics possible (aggregate across tenants)
Cons:
- ⚠️ Requires discipline (always filter by tenant_id)
- ⚠️ Complexity in schema (every model has tenant_id)
- ⚠️ SurrealDB scope syntax to learn
Implementation
Model Definition with tenant_id:
#![allow(unused)] fn main() { // crates/vapora-shared/src/models.rs pub struct Project { pub id: String, pub tenant_id: String, // ← Mandatory field pub title: String, pub description: Option<String>, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, } pub struct Task { pub id: String, pub tenant_id: String, // ← Mandatory field pub project_id: String, pub title: String, pub status: TaskStatus, pub created_at: DateTime<Utc>, } }
SurrealDB Scope Definition:
-- Create scope for tenant isolation
DEFINE SCOPE tenant_scope
SESSION 24h
SIGNUP (
CREATE user SET
email = $email,
pass = crypto::argon2::encrypt($pass),
tenant_id = $tenant_id
)
SIGNIN (
SELECT * FROM user
WHERE email = $email AND crypto::argon2::compare(pass, $pass)
);
-- Tenant-scoped table with access control
DEFINE TABLE projects
SCHEMALESS
PERMISSIONS
FOR SELECT WHERE tenant_id = $auth.tenant_id,
FOR CREATE WHERE $input.tenant_id = $auth.tenant_id,
FOR UPDATE WHERE tenant_id = $auth.tenant_id,
FOR DELETE WHERE tenant_id = $auth.tenant_id;
DEFINE TABLE tasks
SCHEMALESS
PERMISSIONS
FOR SELECT WHERE tenant_id = $auth.tenant_id,
FOR CREATE WHERE $input.tenant_id = $auth.tenant_id,
FOR UPDATE WHERE tenant_id = $auth.tenant_id,
FOR DELETE WHERE tenant_id = $auth.tenant_id;
Service-Level Validation:
#![allow(unused)] fn main() { // crates/vapora-backend/src/services/project_service.rs impl ProjectService { pub async fn get_project( &self, tenant_id: &str, project_id: &str, ) -> Result<Project> { // 1. Query with tenant_id filter (database-level isolation) let project = self.db .query( "SELECT * FROM projects \ WHERE id = $1 AND tenant_id = $2" ) .bind((project_id, tenant_id)) .await? .take::<Option<Project>>(0)? .ok_or_else(|| VaporaError::ProjectNotFound(project_id.to_string()))?; // 2. Verify tenant_id matches (application-level check, redundant) if project.tenant_id != tenant_id { return Err(VaporaError::Unauthorized( "Tenant mismatch".to_string() )); } Ok(project) } pub async fn create_project( &self, tenant_id: &str, title: &str, description: &Option<String>, ) -> Result<Project> { let project = Project { id: uuid::Uuid::new_v4().to_string(), tenant_id: tenant_id.to_string(), // ← Always set from authenticated user title: title.to_string(), description: description.clone(), ..Default::default() }; // Database will enforce tenant_id matches auth scope self.db .create("projects") .content(&project) .await?; Ok(project) } pub async fn list_projects( &self, tenant_id: &str, limit: u32, ) -> Result<Vec<Project>> { // Always filter by tenant_id let projects = self.db .query( "SELECT * FROM projects \ WHERE tenant_id = $1 \ ORDER BY created_at DESC \ LIMIT $2" ) .bind((tenant_id, limit)) .await? .take::<Vec<Project>>(0)? .unwrap_or_default(); Ok(projects) } } }
Tenant Context Extraction:
#![allow(unused)] fn main() { // crates/vapora-backend/src/auth/middleware.rs pub struct TenantContext { pub user_id: String, pub tenant_id: String, } pub fn extract_tenant_context( request: &Request, ) -> Result<TenantContext> { // 1. Get JWT token from Authorization header let token = extract_bearer_token(request)?; // 2. Decode JWT let claims = decode_jwt(&token)?; // 3. Extract tenant_id from claims let tenant_id = claims.get("tenant_id") .ok_or(VaporaError::Unauthorized("No tenant".into()))?; Ok(TenantContext { user_id: claims.get("sub").unwrap().to_string(), tenant_id: tenant_id.to_string(), }) } }
API Handler with Tenant Validation:
#![allow(unused)] fn main() { pub async fn get_project( State(app_state): State<AppState>, Path(project_id): Path<String>, request: Request, ) -> Result<Json<Project>, ApiError> { // 1. Extract tenant from JWT let tenant = extract_tenant_context(&request)?; // 2. Call service (tenant passed explicitly) let project = app_state .project_service .get_project(&tenant.tenant_id, &project_id) .await .map_err(ApiError::from)?; Ok(Json(project)) } }
Key Files:
/crates/vapora-shared/src/models.rs(models with tenant_id)/crates/vapora-backend/src/services/(tenant validation in queries)/crates/vapora-backend/src/auth/(tenant context extraction)
Verification
# Test tenant isolation (can't access other tenant's data)
cargo test -p vapora-backend test_tenant_isolation
# Test service enforces tenant_id
cargo test -p vapora-backend test_service_tenant_check
# Integration: create projects in two tenants, verify isolation
cargo test -p vapora-backend test_multi_tenant_integration
# Verify database permissions enforced
# (Run manual query as one tenant, try to access another tenant's data)
surreal sql --conn ws://localhost:8000
> USE ns vapora db main;
> CREATE project SET tenant_id = 'other:tenant', title = 'Hacked'; // Should fail
Expected Output:
- Tenant cannot access other tenant's projects
- Database permissions block cross-tenant access
- Service validation catches tenant mismatches
- Only authenticated user's tenant_id usable
Consequences
Schema Design
- Every model must have tenant_id field
- Queries always include tenant_id filter
- Indexes on (tenant_id, id) for performance
Query Patterns
- Services always filter by tenant_id
- No queries without WHERE tenant_id = $1
- Lint/review to enforce
Data Isolation
- Tenant data completely isolated
- No risk of accidental leakage
- Safe for multi-tenant SaaS
Scaling
- Can shard by tenant_id if needed
- Analytics queries group by tenant
- Compliance: data export per tenant simple
References
- SurrealDB Scopes Documentation
/crates/vapora-shared/src/models.rs(tenant_id in models)/crates/vapora-backend/src/services/(tenant filtering)- ADR-004 (SurrealDB)
- ADR-010 (Cedar Authorization)
Related ADRs: ADR-004 (SurrealDB), ADR-010 (Cedar), ADR-020 (Audit Trail)