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

A user’s tenant_id is its default tenant, not the limit of its reach: membership lives in the user_tenants join table, so one account can belong to several tenants.

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>Resources of every tenant it is a member of
// mandible/src/routes/tenants/helpers.rs — verify_tenant_access
pub fn verify_tenant_access(
claims: &JwtClaims,
requested_tenant_id: uuid::Uuid,
) -> Result<(), ApiError> {
// Admin users can access all tenants
if claims.is_platform_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 tenant enumeration
Err(ApiError::NotFound("Tenant not found".to_string()))
}

is_platform_admin() is tenant_id.is_none() && is_admin(), written once in instinct/src/auth/jwt.rs so no call site can test the role alone and hand a tenant’s own administrator authority over every tenant.

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:

// mandible/src/routes/deployments/handlers.rs — the deployments list handler
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
solutionstenant_idCascade
sequencestenant_idCascade
actionstenant_idCascade
environmentstenant_idCascade
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) and enforced by row-level security: a tenant caller reads its own tenant’s entries and none of the platform’s (tenant_id IS NULL). Admins read everything and may narrow with ?tenant_id=.

mandible/src/routes/audit/handlers.rs
let scope = match effective_tenant_scope(&auth.claims, params.tenant_id.as_deref())? {
Some(tid) => AuditScope::Tenant(tid), // that tenant's entries only
None => AuditScope::All,
};
with_audit_scope(&mut conn, scope, |conn| /* the query, unfiltered by tenant */).await?;

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:

// mandible/src/routes/sse/handlers.rs — entity_event_stream
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
// 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 no rows, 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
Permission denialYesOwn tenant only

A rejected cross-tenant access is not captured: verify_tenant_access returns 404 Tenant not found and emits nothing. Only permission denials from check_permission are audited, so an empty audit trail is not evidence that no cross-tenant probe occurred.

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 permission denials - check_permission writes them; a cross-tenant refusal is not audited, so do not plan detection around it
  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 permission denials
  • 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)