## Keyboard shortcuts

Press `←` or `→` to navigate between chapters

Press `S` or `/` to search in the book

Press `?` to show this help

Press `Esc` to hide this help

# 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:

    #![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)