Skip to main content

Tenant Filtering

Documentation of how tenant-scoped data isolation is enforced at the ORM level, including intentional bypasses and their justifications.

How Tenant Filtering Works​

All entities extending TenantBaseEntity or TenantOrganizationBaseEntity are automatically scoped by tenantId:

// TenantAwareCrudService automatically adds:
// WHERE tenant_id = :currentTenantId
const employees = await this.employeeService.findAll();
// Returns only employees for the authenticated user's tenant

Automatic Filtering​

Services that extend TenantAwareCrudService automatically inject the current user's tenantId into:

  • find() / findAll() β€” SELECT queries
  • findOne() β€” single record queries
  • create() β€” INSERT operations (sets tenantId)
  • update() β€” UPDATE operations (scoped by tenantId)
  • delete() β€” DELETE operations (scoped by tenantId)

Intentional Bypasses​

Some scenarios intentionally bypass tenant filtering. Every bypass must be documented and justified.

Category 1: Cross-Tenant by Design​

These services must query across all tenants as part of their core functionality:

ServiceJustification
TenantService.findAll()Super Admin listing all tenants
UserService.findByEmail()Email lookup during login (pre-auth)
AuthService.validateUser()Authentication (no tenant context yet)
HealthService.check()System health check (no tenant context)

Category 2: Manual QueryBuilder Usage​

Services using createQueryBuilder() bypass automatic tenant scoping and must manually include tenantId:

// βœ… CORRECT β€” includes tenant_id filter
const result = await this.repository
.createQueryBuilder("employee")
.where("employee.tenantId = :tenantId", {
tenantId: RequestContext.currentTenantId(),
})
.getMany();

// ❌ WRONG β€” missing tenant_id filter
const result = await this.repository.createQueryBuilder("employee").getMany();

Category 3: Plugin Services​

Integration plugins that sync external data may operate outside standard tenant context:

PluginJustification
GitHub IntegrationWebhook handlers receive data without user context
Upwork IntegrationBackground sync jobs with stored credentials
Job SearchCross-tenant job matching

Category 4: Base Class Internal​

Internal methods in CrudService and TenantAwareCrudService that implement the filtering logic itself:

MethodJustification
CrudService.findAll()Base implementation without tenant scope
CrudService.findOne()Overridden by TenantAwareCrudService

Audit Rules​

New Services​

All new services must:

  1. Extend TenantAwareCrudService (default)
  2. Or document the bypass with justification
  3. Never use raw Repository without tenant filtering

Manual QueryBuilder​

All createQueryBuilder usages must:

  1. Include .where('entity.tenantId = :tenantId', { tenantId }) clause
  2. Use RequestContext.currentTenantId() for the tenant ID
  3. Be audited for tenant isolation compliance

Code Review Checklist​

  • βœ… Does the service extend TenantAwareCrudService?
  • βœ… If using createQueryBuilder, is tenantId in the WHERE clause?
  • βœ… If bypassing tenant filtering, is there a documented justification?
  • βœ… Are plugin services properly scoped when handling multi-tenant data?
  • βœ… Do background jobs use stored tenant context?

Configuration​

Super Admin Cross-Tenant Access​

# Allow Super Admin to query across tenants
ALLOW_SUPER_ADMIN_ROLE=true

Super Admin users can access the TenantController to list and manage all tenants.

Maintenance Guidelines​

Adding a New Bypass​

  1. Document the service, method, and justification in this page
  2. Add a code comment explaining the bypass
  3. Ensure Super Admin or system-level authorization is in place
  4. Submit for code review with explicit security review

Auditing Existing Bypasses​

Periodically audit all services for:

  • Unauthorized Repository.find() usage (bypasses tenant filter)
  • Missing tenantId in createQueryBuilder queries
  • New plugin services without documentation
  • Deprecated bypasses that can be removed