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 Admin (admin role)nullAll resources across all tenants
Tenant-less non-adminnullNone — 403 on every scoped request
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:236-239
let 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.

These tables are filtered by tenant_id:

TableFilter ColumnOn tenant delete
deployment_historytenant_idSet NULL
tenants_solutions_environmentstenant_idCascade
tenants_targetstenant_idCascade
variablesscope_tenant_idRestrict
variablesowner_tenant_idCascade
variable_setstenant_idRestrict
audit_log_entriestenant_idNo FK
userstenant_idSet NULL

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:

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=.

mandible/src/routes/audit/handlers.rs
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()));
}

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. (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.

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 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_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 precedence respects tenant boundaries:

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 events
SET mantis.audit_tenant_id = '<tenant-uuid>';
SELECT * FROM audit_log_entries
WHERE 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;
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 (every tenant-scoped table)
└── Row-level security (audit_log_entries only)
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)