# 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 1. **Defense-in-Depth**: Tenants isolated en dos niveles (scopes + queries) 2. **Database-Level**: SurrealDB scopes enforced en DB (no app bugs can leak) 3. **Application-Level**: Services validate tenant_id (redundant safety) 4. **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**: ```rust // crates/vapora-shared/src/models.rs pub struct Project { pub id: String, pub tenant_id: String, // ← Mandatory field pub title: String, pub description: Option, pub created_at: DateTime, pub updated_at: DateTime, } 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, } ``` **SurrealDB Scope Definition**: ```sql -- 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**: ```rust // crates/vapora-backend/src/services/project_service.rs impl ProjectService { pub async fn get_project( &self, tenant_id: &str, project_id: &str, ) -> Result { // 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::>(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, ) -> Result { 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> { // 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::>(0)? .unwrap_or_default(); Ok(projects) } } ``` **Tenant Context Extraction**: ```rust // 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 { // 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**: ```rust pub async fn get_project( State(app_state): State, Path(project_id): Path, request: Request, ) -> Result, 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 ```bash # 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](https://surrealdb.com/docs/surrealql/statements/define/scope) - `/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)