Skip to main content

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?

ResponseInformation 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:

EntityTenant-Scoped
Users
Organizations
Roles
Permissions
Role Assignments
Audit Logs
Sessions

Platform-Level Data

Some data exists at the platform level (shared across tenants):

EntityScope
TenantsPlatform
System RolesPlatform
System PermissionsPlatform

Only platform administrators can manage platform-level data.

Best Practices

For Developers

  1. Always include tenantId in queries

    // Good
    prisma.user.findMany({ where: { tenantId } });

    // Bad - no tenant filter!
    prisma.user.findMany();
  2. Use the @TenantScoped decorator

    @Get('users')
    @TenantScoped()
    async getUsers(@TenantId() tenantId: string) {
    return this.userService.findAll(tenantId);
    }
  3. 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