310 lines
8.1 KiB
Markdown
310 lines
8.1 KiB
Markdown
# 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)
|