Tenant Isolation
CORTEX enforces strict data isolation between tenants at multiple levels to ensure one tenant's data is never accessible to another.
Isolation Layers
1. Application Layer
Every database query automatically includes a tenant filter:
// Service layer example
async findUsers(tenantId: string): Promise<User[]> {
return this.prisma.user.findMany({
where: {
tenantId, // Always filtered by tenant
deletedAt: null,
},
});
}
2. Request Context
The tenant is extracted from the JWT token and injected into the request context:
@Injectable()
export class TenantGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const user = request.user;
// Set tenant context from authenticated user
request.tenantId = user.tenantId;
return true;
}
}
3. Database Level
Row Level Security (RLS) policies provide defense-in-depth:
-- Example RLS policy
CREATE POLICY tenant_isolation ON "User"
USING (tenant_id = current_setting('app.tenant_id')::uuid);
Cross-Tenant Access Responses
When a user attempts to access resources outside their tenant:
Accessing Another Tenant's Data
GET /users/550e8400-e29b-41d4-a716-446655440000
(where the user belongs to a different tenant)
Response: 404 Not Found
{
"type": "https://api.cortex.purplelab.ai/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "User not found"
}
Why 404 Instead of 403?
| Response | Information Revealed |
|---|---|
| 403 Forbidden | "This resource exists but you can't access it" |
| 404 Not Found | "This resource doesn't exist (or maybe it does)" |
By returning 404, we prevent:
- Tenant enumeration: Attackers can't discover valid tenant IDs
- Resource enumeration: Attackers can't discover valid resource IDs
- Information leakage: No confirmation of what exists in other tenants
Testing Isolation
Unit Test Example
describe('TenantIsolation', () => {
it('should not return users from other tenants', async () => {
// Create user in tenant A
const tenantA = await createTenant('tenant-a');
const userInA = await createUser(tenantA.id);
// Create user in tenant B
const tenantB = await createTenant('tenant-b');
const userInB = await createUser(tenantB.id);
// Query as tenant A
const users = await userService.findAll(tenantA.id);
// Should only see tenant A's user
expect(users).toHaveLength(1);
expect(users[0].id).toBe(userInA.id);
expect(users.map(u => u.id)).not.toContain(userInB.id);
});
});
API Test Example
it('should return 404 for cross-tenant access', async () => {
// Login as user in tenant A
const { accessToken: tokenA } = await login(userInTenantA);
// Try to access user in tenant B
const response = await request(app)
.get(`/users/${userInTenantB.id}`)
.set('Authorization', `Bearer ${tokenA}`);
expect(response.status).toBe(404);
});
Data That's Tenant-Scoped
All business data is scoped to a tenant:
| Entity | Tenant-Scoped |
|---|---|
| Users | ✓ |
| Organizations | ✓ |
| Roles | ✓ |
| Permissions | ✓ |
| Role Assignments | ✓ |
| Audit Logs | ✓ |
| Sessions | ✓ |
Platform-Level Data
Some data exists at the platform level (shared across tenants):
| Entity | Scope |
|---|---|
| Tenants | Platform |
| System Roles | Platform |
| System Permissions | Platform |
Only platform administrators can manage platform-level data.
Best Practices
For Developers
-
Always include tenantId in queries
// Good
prisma.user.findMany({ where: { tenantId } });
// Bad - no tenant filter!
prisma.user.findMany(); -
Use the @TenantScoped decorator
@Get('users')
@TenantScoped()
async getUsers(@TenantId() tenantId: string) {
return this.userService.findAll(tenantId);
} -
Test cross-tenant scenarios
- Always include tests that verify isolation
- Test both positive (same tenant) and negative (different tenant) cases
For Security Auditors
- Verify RLS policies are enabled
- Check that no queries bypass tenant filters
- Review audit logs for cross-tenant access attempts
- Penetration test with multiple tenant accounts