Architecture

Multi-tenancy

Row-Level Security, tenant context propagation, and isolation guarantees.

Every resource in the platform belongs to exactly one tenant. Isolation is enforced at two layers: application-level filtering (always filtering by tenantId) and PostgreSQL Row-Level Security (RLS), which blocks data leaks even if application code has a bug.

Isolation Layers

LayerMechanismResponsibility
JWT / API KeytenantId embedded in token payloadAuthenticates caller and establishes tenant context
Application@TenantId() decorator + mandatory where clauseAll Prisma queries filter by tenantId
PostgreSQL RLSPolicy: tenant_id = current_setting(app.tenant_id)Blocks cross-tenant reads at DB level
RedisKey prefix: {tenantId}:{resource}Session and cache data namespaced by tenant
KafkatenantId in message headersEvents carry tenant context across services

Tenant Context in Controllers

The @TenantId() decorator extracts the tenant from the JWT payload or API key lookup and injects it as a method parameter. No manual extraction is needed.

typescript
// Controller: always extract tenantId via decorator
@Get()
@RequirePermissions('interview:read')
async findAll(
  @TenantId() tenantId: string,
  @Query() query: ListInterviewsDto,
) {
  return this.interviewService.findAll(tenantId, query);
}

// Service: always pass tenantId to every query
async findAll(tenantId: string, query: ListInterviewsDto) {
  return this.prisma.interview.findMany({
    where: {
      tenantId,  // Required — never omit!
      ...(query.state ? { workflowState: query.state } : {}),
    },
    orderBy: { createdAt: 'desc' },
    skip: (query.page - 1) * query.limit,
    take: query.limit,
  });
}

PostgreSQL RLS Policies

RLS policies are applied at the schema level in migrations. The application sets the tenant context at the start of each query session using SET LOCAL app.tenant_id = ....

sql
-- Enable RLS on the interviews table
ALTER TABLE interviews ENABLE ROW LEVEL SECURITY;

-- Policy: users can only see their own tenant's rows
CREATE POLICY tenant_isolation ON interviews
  USING (tenant_id = current_setting('app.tenant_id', true)::uuid);

-- The Prisma service sets this before every query
SET LOCAL app.tenant_id = '<tenant-uuid>';

Prisma Tenant Context

typescript
// PrismaService method used before sensitive operations
async setTenantContext(tenantId: string): Promise<void> {
  await this.prisma.$executeRaw`
    SET LOCAL app.tenant_id = ${tenantId}
  `;
}

// Usage in service
async findById(tenantId: string, id: string) {
  await this.prisma.setTenantContext(tenantId);
  return this.prisma.interview.findFirst({
    where: { id, tenantId },  // Double protection
  });
}

Tenant Onboarding

Each new tenant is provisioned with:

ResourceValue
Tenant recordUnique UUID, slug, plan tier
Admin userFirst admin account for the tenant
Default settingsInterview duration, webhook secret
API keyInitial API key for A2A integrations
bash
# Create tenant via internal API (super-admin only)
curl -X POST http://localhost:3009/api/v1/internal/tenants \
  -H "Authorization: Bearer <super-admin-jwt>" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme Corp",
    "slug": "acme",
    "adminEmail": "admin@acme.com",
    "plan": "PROFESSIONAL"
  }'

Testing Multi-Tenancy

bash
# Login as Tenant A admin
curl -X POST http://localhost:3009/api/v1/auth/login \
  -d '{"email":"admin@tenant-a.com","password":"..."}'

# Create interview under Tenant A
curl -X POST http://localhost:3009/api/v1/interviews \
  -H "Authorization: Bearer <tenant-a-jwt>" \
  ...

# Attempt to read with Tenant B credentials — returns empty list
curl http://localhost:3009/api/v1/interviews \
  -H "Authorization: Bearer <tenant-b-jwt>"
# Response: { "data": [], "meta": { "total": 0 } }
Never query the database without a tenantId filter. PR reviews must check that all Prisma findMany and findFirst calls include where: { tenantId }.
Was this page helpful?