Vapora/docs/adrs/0025-multi-tenancy.md
Jesús Pérez 7110ffeea2
Some checks failed
Rust CI / Security Audit (push) Has been cancelled
Rust CI / Check + Test + Lint (nightly) (push) Has been cancelled
Rust CI / Check + Test + Lint (stable) (push) Has been cancelled
chore: extend doc: adr, tutorials, operations, etc
2026-01-12 03:32:47 +00:00

8.1 KiB

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:

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

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

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

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)