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.
Platform and Tenant Hierarchy
The system uses a three-tier hierarchy: Teamcast SuperAdmin at the top, then Platforms (integration partners such as LinkedIn), and then Tenants (the Platform's customers, e.g. Acme Corp as a LinkedIn customer).
Teamcast SuperAdmin
│
├── Platform: LinkedIn
│ │ One API key issued by Teamcast SuperAdmin.
│ │ All tenant requests flow through this key.
│ │
│ ├── Tenant: Acme Corp (X-Tenant-ID: tenant_acme)
│ │ ├── Interviews, users, API keys, webhook config
│ │ └── candidateRetentionDays: 90, autoApprovePlans: true
│ │
│ └── Tenant: TechCo (X-Tenant-ID: tenant_techco)
│ ├── Interviews, users, API keys, webhook config
│ └── candidateRetentionDays: 30, autoApprovePlans: false
│
└── Platform: AnotherATS
│ Separate API key, separate tenants.
└── Tenant: ...| Layer | Description |
|---|---|
| SuperAdmin | Teamcast internal — registers platforms and issues platform API keys |
| Platform | Integration partner (e.g. LinkedIn) — holds a single API key covering all their tenants |
| Tenant | Platform customer (e.g. Acme Corp) — identified per-request via X-Tenant-ID header |
X-Tenant-ID header identifying the specific customer. This means LinkedIn manages one key and identifies each of their customers via the header.Per-Tenant Webhook Configuration
Each tenant has an independent webhook configuration registered at onboarding time. Webhooks, auto-approve behaviour, and data retention windows are all tenant-scoped — not platform-wide.
| Setting | Description | Default |
|---|---|---|
| callbackUrl | HTTPS endpoint to receive signed webhook events | Required |
| secret | HMAC-SHA256 signing secret (min 32 chars) | Required |
| autoApprovePlans | Skip manual plan approval — link issued automatically | false |
| candidatePiiRetentionDays | Days before candidate PII is auto-purged | 90 |
| assessmentRetentionDays | Days assessment records are retained | 365 |
| recordingRetentionDays | Days audio recordings are retained | 30 |
Isolation Layers
| Layer | Mechanism | Responsibility |
|---|---|---|
| Platform API Key | Issued per Platform by SuperAdmin | Authenticates the platform and establishes which tenants are accessible |
| X-Tenant-ID header | Passed by platform on every request | Identifies the specific tenant customer |
| 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.
// 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 = ....
-- 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
// 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 linked to the Platform |
| Webhook config | callbackUrl, HMAC secret, retention windows, autoApprovePlans flag |
| Admin user | First admin account for the tenant |
tenantId filter. PR reviews must check that all Prisma findMany and findFirst calls include where: { tenantId }.