Skip to content

Data Isolation

Mantis enforces strict data isolation between tenants at every level of the application.

Tenant isolation is enforced through multiple layers:

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
}
ClaimIsolation Impact
subUser ID — audit trail attribution (parsed server-side)
tenant_idScopes all queries to this tenant
rolesDetermines permitted operations
User Typetenant_idAccess Scope
Platform AdminnullAll resources across all tenants
Tenant User<tenant_id>Only resources for their tenant
// From mandible/src/routes/tenants/helpers.rs
pub 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()))
}

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.

All tenant-aware queries include automatic filtering:

// From mandible/src/routes/deployments/handlers.rs:182-184
let tenant_scope = crate::routes::tenants::helpers::resolve_tenant_scope(&auth.claims)?;
if let Some(tenant_id) = tenant_scope {
query.tenant_id = Some(tenant_id.to_string());
}

These tables are filtered by tenant_id:

TableFilter ColumnCascade Delete
deployment_historytenant_idYes
tenants_solutions_environmentstenant_idYes
tenants_targetstenant_idYes
tenant_variablestenant_idYes
tenant_common_variablestenant_idYes
audit_log_entriestenant_idNo
userstenant_idNo (nullable)

Listing Deployments (tenant-scoped):

-- Platform admin (tenant_id = null): sees all
SELECT * FROM deployment_history ORDER BY created_at DESC;
-- Tenant user (tenant_id = 1): filtered automatically
SELECT * FROM deployment_history
WHERE tenant_id = 1
ORDER BY created_at DESC;

Audit Log Filtering:

// Filter audit logs by tenant for tenant-scoped users
// mandible/src/routes/audit/handlers.rs:171-172
let user_tenant_id = auth.claims.tenant_id;
if let Some(tid) = user_tenant_id {
query = query.filter(tenant_id.eq(Some(tid)));
}

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.

These resources are strictly isolated per tenant:

ResourceIsolationCross-Tenant Access
VariablesCompleteNever visible
TargetsCompleteNever visible
DeploymentsCompleteNever visible
UsersCompleteNever visible
Audit LogsCompletePlatform admin only

Access to platform resources is granted through entitlements:

Server-Sent Events are filtered by tenant:

// From mandible/src/routes/sse/handlers.rs:236
let tenant_id = sse_auth.claims.tenant_id;
// Events filtered before broadcast
loop {
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 TypeTenant User VisibilityPlatform Admin Visibility
Deployment progressOwn tenant onlyAll tenants
Target statusOwn tenant onlyAll tenants
Audit eventsOwn tenant onlyAll tenants

Tenant variables are encrypted with variable-specific AAD (Additional Authenticated Data) that binds the ciphertext to the variable’s identity:

// instinct::crypto::encryption::build_tenant_var_aad
// Produces: "var:{variable_id}:value"
let aad = build_tenant_var_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 precedence respects tenant boundaries:

Tenant users only see their own audit events:

-- Automatic filter applied for tenant users
SELECT * FROM audit_log_entries
WHERE tenant_id = $user_tenant_id
ORDER BY created_at DESC;
EventCapturedTenant Visible
Deployment startedYesOwn tenant only
Variable changedYesOwn tenant only
User loginYesOwn tenant only
Cross-tenant access attemptYesPlatform admin only

Identity providers can be tenant-scoped:

-- Identity provider with tenant scope
CREATE TABLE identity_providers (
id UUID PRIMARY KEY,
tenant_id UUID REFERENCES tenants(id),
-- ... other fields
);
ConfigurationBehavior
tenant_id = NULLPlatform-wide IdP
tenant_id = 1Only Tenant 1 users can use

Users created via SSO inherit the IdP’s tenant scope:

Layer 1: Authentication
└── JWT validation
└── Token expiration
Layer 2: Authorization
└── Permission checks
└── Tenant access verification
Layer 3: Data Access
└── Query filtering
└── Row-level security
Layer 4: Audit
└── Event logging
└── Access tracking
  1. Never trust client-provided tenant_id - Always use JWT claims
  2. Return 404 for cross-tenant access - Prevent enumeration
  3. Audit all access attempts - Track suspicious patterns
  4. Encrypt sensitive variables - Use tenant-specific AAD
  5. Separate platform admins - Minimize cross-tenant access
VectorMitigation
JWT tamperingSignature verification
Tenant ID injectionServer-side extraction only
IDOR (Insecure Direct Object Reference)Tenant filtering at query level
Privilege escalationRole-based permission checks
Audit log tamperingAppend-only audit storage
  • 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
  • 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
  1. Check JWT claims:

    Terminal window
    # Decode JWT payload
    echo $TOKEN | cut -d'.' -f2 | base64 -d | jq '.tenant_id'
  2. Verify user assignment:

    SELECT tenant_id FROM users WHERE id = $user_id;
  1. Check tenant filter in query
  2. Verify audit logging is enabled
  3. Check if event is platform-level (no tenant_id)