Vapora/docs/adrs/0025-multi-tenancy.md

310 lines
8.1 KiB
Markdown
Raw Normal View History

# 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<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**:
```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<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**:
```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<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**:
```rust
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
```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)