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).

text
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: ...
LayerDescription
SuperAdminTeamcast internal — registers platforms and issues platform API keys
PlatformIntegration partner (e.g. LinkedIn) — holds a single API key covering all their tenants
TenantPlatform customer (e.g. Acme Corp) — identified per-request via X-Tenant-ID header
Tenants do not have their own API keys. All requests from a platform arrive under the platform API key, with the 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.

SettingDescriptionDefault
callbackUrlHTTPS endpoint to receive signed webhook eventsRequired
secretHMAC-SHA256 signing secret (min 32 chars)Required
autoApprovePlansSkip manual plan approval — link issued automaticallyfalse
candidatePiiRetentionDaysDays before candidate PII is auto-purged90
assessmentRetentionDaysDays assessment records are retained365
recordingRetentionDaysDays audio recordings are retained30

Isolation Layers

LayerMechanismResponsibility
Platform API KeyIssued per Platform by SuperAdminAuthenticates the platform and establishes which tenants are accessible
X-Tenant-ID headerPassed by platform on every requestIdentifies the specific tenant customer
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 linked to the Platform
Webhook configcallbackUrl, HMAC secret, retention windows, autoApprovePlans flag
Admin userFirst admin account for the tenant
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?