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
| Layer | Mechanism | Responsibility |
|---|---|---|
| JWT / API Key | tenantId embedded in token payload | Authenticates caller and establishes tenant context |
| Application | @TenantId() decorator + mandatory where clause | All Prisma queries filter by tenantId |
| PostgreSQL RLS | Policy: tenant_id = current_setting(app.tenant_id) | Blocks cross-tenant reads at DB level |
| Redis | Key prefix: {tenantId}:{resource} | Session and cache data namespaced by tenant |
| Kafka | tenantId in message headers | Events 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:
| Resource | Value |
|---|---|
| Tenant record | Unique UUID, slug, plan tier |
| Admin user | First admin account for the tenant |
| Default settings | Interview duration, webhook secret |
| API key | Initial 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?