Data Isolation
Data Isolation
Section titled “Data Isolation”Mantis enforces strict data isolation between tenants at every level of the application.
Overview
Section titled “Overview”Tenant isolation is enforced through multiple layers:
Authentication-Based Isolation
Section titled “Authentication-Based Isolation”JWT Claims Structure
Section titled “JWT Claims Structure”User tokens contain tenant scope information:
{ "sub": "019b937d-4862-8e07-ac3a-1b8589d1b807", "username": "alice", "email": "alice@acme.com", "tenant_id": "019b937d-4862-84ae-9c2a-6ac230cc4c7e", "roles": ["operator"], "exp": 1704931200, "iat": 1704844800}| Claim | Isolation Impact |
|---|---|
sub | User ID — audit trail attribution (parsed server-side) |
tenant_id | Scopes all queries to this tenant |
roles | Determines permitted operations |
User Types
Section titled “User Types”| User Type | tenant_id | Access Scope |
|---|---|---|
Platform Admin (admin role) | null | All resources across all tenants |
| Tenant-less non-admin | null | None — 403 on every scoped request |
| Tenant User | <tenant_id> | Only resources for their tenant |
API-Level Enforcement
Section titled “API-Level Enforcement”Request Flow
Section titled “Request Flow”Access Verification Function
Section titled “Access Verification Function”// From mandible/src/routes/tenants/helpers.rspub fn verify_tenant_access(claims: &JwtClaims, requested_tenant_id: uuid::Uuid) -> Result<(), ApiError> { // Admins (no tenant_id AND the admin role) can access all tenants if claims.tenant_id.is_none() && claims.is_admin() { return Ok(()); }
// Tenant users can only access their own tenant if let Some(user_tenant_id) = claims.tenant_id { if user_tenant_id == requested_tenant_id { return Ok(()); } }
// Return NotFound to prevent information leakage Err(ApiError::NotFound("Tenant not found".to_string()))}Security Response Pattern
Section titled “Security Response Pattern”Cross-tenant access attempts return 404 Not Found rather than 403 Forbidden:
This prevents tenant enumeration attacks by not revealing that a resource exists in another tenant.
Database-Level Filtering
Section titled “Database-Level Filtering”Automatic Query Filtering
Section titled “Automatic Query Filtering”All tenant-aware queries include automatic filtering:
// From mandible/src/routes/deployments/handlers.rs:236-239let parsed_tenant_id = crate::routes::tenants::helpers::effective_tenant_scope( &auth.claims, query.tenant_id.as_deref())?;effective_tenant_scope also pins a tenant caller, so a ?tenant_id= in the
query cannot widen scope beyond the caller’s own tenant.
Tenant-Scoped Tables
Section titled “Tenant-Scoped Tables”These tables are filtered by tenant_id:
| Table | Filter Column | On tenant delete |
|---|---|---|
deployment_history | tenant_id | Set NULL |
tenants_solutions_environments | tenant_id | Cascade |
tenants_targets | tenant_id | Cascade |
variables | scope_tenant_id | Restrict |
variables | owner_tenant_id | Cascade |
variable_sets | tenant_id | Restrict |
audit_log_entries | tenant_id | No FK |
users | tenant_id | Set NULL |
Query Examples
Section titled “Query Examples”Listing Deployments (tenant-scoped):
-- Platform admin (tenant_id = null): sees allSELECT * FROM deployment_history ORDER BY created_at DESC;
-- Tenant user (tenant_id = 1): filtered automaticallySELECT * FROM deployment_historyWHERE tenant_id = 1ORDER BY created_at DESC;Audit Log Filtering:
Scope is resolved by effective_tenant_scope(&claims, tenant_id_param); the query
filters to the caller’s tenant or global/system rows (tenant_id IS NULL), so
tenant callers also see system-wide entries. Admins may narrow with ?tenant_id=.
let scope = effective_tenant_scope(&auth.claims, params.tenant_id.as_deref())?;if let Some(tid) = scope { query = query.filter(tenant_id.eq(tid).or(tenant_id.is_null()));}Resource Isolation Boundaries
Section titled “Resource Isolation Boundaries”Optionally Tenant-Scoped Resources
Section titled “Optionally Tenant-Scoped Resources”Actions, Sequences, Solutions, and Environments each carry a nullable tenant_id:
when it is NULL the resource is global (visible to all tenants, with entitlements);
when it is set the resource is tenant-private (visible only within that tenant). They
are therefore not platform-only/shared — they are optionally tenant-scoped.
Tenant Resources (Isolated)
Section titled “Tenant Resources (Isolated)”These resources are strictly isolated per tenant:
| Resource | Isolation | Cross-Tenant Access |
|---|---|---|
| Variables | Complete | Never visible |
| Targets | Complete | Never visible |
| Deployments | Complete | Never visible |
| Users | Complete | Never visible |
| Audit Logs | Complete | Platform admin only |
Entitlement-Based Access
Section titled “Entitlement-Based Access”Access to platform resources is granted through entitlements. (Note: a rejected
cross-tenant access is not written to the audit trail — verify_tenant_access
emits nothing; only permission denials from check_permission are audited.)
Deployment creation validates tenant existence, environment existence, and the solution/environment entitlement — it does not re-check target assignment at create time. Target scoping is enforced upstream by tenant-scoped target listing (a tenant only ever sees and selects its own targets), not by a create-time 403.
Real-Time Event Isolation
Section titled “Real-Time Event Isolation”SSE Event Filtering
Section titled “SSE Event Filtering”Server-Sent Events are filtered by tenant:
// From mandible/src/routes/sse/handlers.rs:254 (filter at :273-277)let tenant_id = sse_auth.claims.tenant_id;
// Events filtered before broadcastloop { match rx.recv().await { Ok(event) => { // Only emit events for user's tenant if let Some(tid) = tenant_id { if !event.matches_tenant(tid) { continue; } } // ... yield event } }}Event Visibility
Section titled “Event Visibility”| Event Type | Tenant User Visibility | Platform Admin Visibility |
|---|---|---|
| Deployment progress | Own tenant only | All tenants |
| Target status | Own tenant only | All tenants |
| Audit events | Own tenant only | All tenants |
Variable Isolation
Section titled “Variable Isolation”Encrypted Variables
Section titled “Encrypted Variables”Tenant variables are encrypted with variable-specific AAD (Additional Authenticated Data) that binds the ciphertext to the variable’s identity:
// instinct::crypto::encryption::build_variable_aad (encryption.rs:402-404)// Produces: "variable:{variable_id}:value"let aad = build_variable_aad(variable_id);let encrypted = encryptor.encrypt_string(value, &aad)?;This ensures:
- Variables cannot be decrypted without the specific variable ID binding
- Key compromise doesn’t expose all tenant data
Variable Resolution
Section titled “Variable Resolution”Variable precedence respects tenant boundaries:
Audit Isolation
Section titled “Audit Isolation”Audit Log Filtering
Section titled “Audit Log Filtering”Tenant users only see their own audit events:
audit_log_entries has FORCE ROW LEVEL SECURITY, so this applies to direct SQL
too — including as the table owner. A bare SELECT returns only the system rows
(tenant_id IS NULL), silently, with no error. Declare the scope first:
-- One tenant's eventsSET mantis.audit_tenant_id = '<tenant-uuid>';SELECT * FROM audit_log_entriesWHERE tenant_id = '<tenant-uuid>'ORDER BY occurred_at DESC;
-- Every tenant: assume the reader role AND declare the scope. Neither alone-- widens visibility, deliberately.SET ROLE mantis_audit_reader;SET mantis.audit_scope = 'all';SELECT * FROM audit_log_entries ORDER BY occurred_at DESC;Audit Trail Per Tenant
Section titled “Audit Trail Per Tenant”| Event | Captured | Tenant Visible |
|---|---|---|
| Deployment started | Yes | Own tenant only |
| Variable changed | Yes | Own tenant only |
| User login | Yes | Own tenant only |
| Cross-tenant access attempt | Yes | Platform admin only |
SSO Isolation
Section titled “SSO Isolation”Tenant-Specific IdPs
Section titled “Tenant-Specific IdPs”Identity providers can be tenant-scoped:
-- Identity provider with tenant scopeCREATE TABLE identity_providers ( id UUID PRIMARY KEY, tenant_id UUID REFERENCES tenants(id), -- ... other fields);| Configuration | Behavior |
|---|---|
tenant_id = NULL | Platform-wide IdP |
tenant_id = 1 | Only Tenant 1 users can use |
User Provisioning
Section titled “User Provisioning”Users created via SSO inherit the IdP’s tenant scope:
Security Best Practices
Section titled “Security Best Practices”Defense in Depth
Section titled “Defense in Depth”Layer 1: Authentication └── JWT validation └── Token expiration
Layer 2: Authorization └── Permission checks └── Tenant access verification
Layer 3: Data Access └── Query filtering (every tenant-scoped table) └── Row-level security (audit_log_entries only)
Layer 4: Audit └── Event logging └── Access trackingRecommendations
Section titled “Recommendations”- Never trust client-provided tenant_id - Always use JWT claims
- Return 404 for cross-tenant access - Prevent enumeration
- Audit all access attempts - Track suspicious patterns
- Encrypt sensitive variables - Use tenant-specific AAD
- Separate platform admins - Minimize cross-tenant access
Common Attack Vectors
Section titled “Common Attack Vectors”| Vector | Mitigation |
|---|---|
| JWT tampering | Signature verification |
| Tenant ID injection | Server-side extraction only |
| IDOR (Insecure Direct Object Reference) | Tenant filtering at query level |
| Privilege escalation | Role-based permission checks |
| Audit log tampering | Append-only audit storage |
Verification Checklist
Section titled “Verification Checklist”For Platform Administrators
Section titled “For Platform Administrators”- All API endpoints enforce tenant filtering
- No sensitive data leaks in error messages
- Audit logs capture all access attempts
- SSE events filtered by tenant
- Variable encryption uses tenant-specific AAD
For Security Audits
Section titled “For Security Audits”- Cross-tenant access returns 404 (not 403)
- No tenant_id in URL paths that could be tampered
- All queries include tenant_id WHERE clause
- Platform admin actions are logged separately
- Token refresh respects original tenant scope
Troubleshooting
Section titled “Troubleshooting”User Seeing Wrong Tenant Data
Section titled “User Seeing Wrong Tenant Data”-
Check JWT claims:
Terminal window # Decode JWT payloadecho $TOKEN | cut -d'.' -f2 | base64 -d | jq '.tenant_id' -
Verify user assignment:
SELECT tenant_id FROM users WHERE id = $user_id;
Audit Logs Missing Events
Section titled “Audit Logs Missing Events”- Check tenant filter in query
- Verify audit logging is enabled
- Check if event is platform-level (no tenant_id)
Next Steps
Section titled “Next Steps”- Tenant Administration - Manage tenants day-to-day
- Audit Logs - Audit configuration
- Users - User management
