t tenants. It also ensures that removing someone from a tenant only modifies the relationship record, leaving the core identity intact for future re-invitation or cross-tenant operations.
Step 2: Implement Stateful Lifecycle Management
Membership is not binary. Real organizations require explicit states to handle invitations, temporary blocks, and compliance holds. The state machine should be enforced at the database constraint level, not just in application logic.
enum AffiliationState {
INVITED = 'invited',
ACTIVE = 'active',
SUSPENDED = 'suspended',
REVOKED = 'revoked',
}
// State transition rules enforced via application middleware
const VALID_TRANSITIONS: Record<AffiliationState, AffiliationState[]> = {
[AffiliationState.INVITED]: [AffiliationState.ACTIVE, AffiliationState.REVOKED],
[AffiliationState.ACTIVE]: [AffiliationState.SUSPENDED, AffiliationState.REVOKED],
[AffiliationState.SUSPENDED]: [AffiliationState.ACTIVE, AffiliationState.REVOKED],
[AffiliationState.REVOKED]: [], // Terminal state
};
Why this choice: Explicit states prevent privilege escalation during pending invitations and preserve audit trails for revoked accounts. The SUSPENDED state is critical for compliance scenarios where immediate deletion would violate data retention policies, while REVOKED maintains historical context without granting active access.
Step 3: Scope Permissions via Role Bundles
Permissions should never be assigned directly to affiliations. Instead, they are grouped into named bundles scoped to either the platform or the tenant. This prevents permission sprawl and enables consistent policy enforcement.
interface AccessPolicy {
id: string;
tenantId: string | null; // null indicates platform-wide scope
name: string;
slug: string;
isSystem: boolean;
createdAt: Date;
}
interface PermissionMatrix {
policyId: string;
resource: string;
action: string;
scope: 'platform' | 'tenant';
}
// Example policy definition
const TEMPLATES = {
tenant_admin: {
slug: 'tenant_admin',
name: 'Tenant Administrator',
permissions: [
{ resource: 'billing', action: 'manage' },
{ resource: 'users', action: 'invite' },
{ resource: 'users', action: 'revoke' },
{ resource: 'settings', action: 'configure' },
],
},
analyst: {
slug: 'analyst',
name: 'Data Analyst',
permissions: [
{ resource: 'reports', action: 'view' },
{ resource: 'exports', action: 'generate' },
{ resource: 'dashboards', action: 'create' },
],
},
};
Why this choice: Scoping prevents cross-tenant permission leakage. A tenant-scoped policy can never grant platform-level privileges. The isSystem flag protects foundational policies from accidental deletion while allowing permission updates. Using slugs instead of raw IDs decouples application code from database implementation details.
Step 4: Materialize Invitations as First-Class Records
Invitations are not side effects; they are stateful data. Treating them as database records enables deduplication, expiration handling, audit logging, and safe cancellation.
async function provisionInvitation(
tenantId: string,
inviterPrincipalId: string,
targetEmail: string,
policySlug: string,
): Promise<TenantAffiliation> {
const targetPrincipal = await db.workspacePrincipal.findUnique({
where: { email: targetEmail },
});
const principalId = targetPrincipal?.id ?? (
await db.workspacePrincipal.create({
data: { email: targetEmail, authProvider: 'email' },
})
).id;
const policy = await db.accessPolicy.findFirst({
where: { slug: policySlug, tenantId },
});
if (!policy) throw new Error('Invalid policy for tenant');
const affiliation = await db.tenantAffiliation.create({
data: {
principalId,
tenantId,
policyId: policy.id,
status: AffiliationState.INVITED,
invitedBy: inviterPrincipalId,
invitedAt: new Date(),
},
});
await notificationService.sendInvitation(targetEmail, affiliation.id);
return affiliation;
}
Why this choice: Creating the affiliation record immediately upon invitation ensures the system can track pending access, prevent duplicate invites, and maintain a complete audit trail. The invitation lifecycle becomes queryable, cancellable, and idempotent.
Step 5: Resolve Permissions at Runtime
Permission resolution must account for affiliation state, policy scope, and additive overrides. The resolution algorithm should be deterministic and cacheable.
async function resolveEffectivePermissions(
principalId: string,
tenantId: string,
): Promise<Set<string>> {
const affiliation = await db.tenantAffiliation.findFirst({
where: { principalId, tenantId, status: AffiliationState.ACTIVE },
include: { policy: true },
});
if (!affiliation) return new Set();
const basePermissions = await db.permissionMatrix.findMany({
where: { policyId: affiliation.policyId },
});
const effective = new Set(
basePermissions.map(p => `${p.resource}:${p.action}`)
);
// Additive overrides stored on the affiliation record
if (affiliation.metadata?.customPermissions) {
for (const override of affiliation.metadata.customPermissions as string[]) {
const [resource, action] = override.split(':');
const scopeAllowed = await validateScopePermission(tenantId, resource, action);
if (scopeAllowed) effective.add(override);
}
}
return effective;
}
Why this choice: Additive overrides prevent role explosion while maintaining security boundaries. The scope validation step ensures that tenant-level affiliations cannot accidentally inherit platform-level privileges. Returning a Set enables O(1) permission checks during request processing.
Pitfall Guide
1. Embedding Role Columns on the User Table
Explanation: Storing role or tenantId directly on the identity table couples authentication to authorization. It prevents multi-tenant reuse and forces destructive updates during offboarding.
Fix: Always model affiliation as a separate entity with explicit foreign keys to both the principal and tenant tables.
2. Hard-Deleting Membership Records on Exit
Explanation: Deleting affiliation records erases audit trails and breaks compliance requirements. It also prevents seamless re-invitation without recreating identity data.
Fix: Transition to a REVOKED or SUSPENDED state. Implement soft-delete patterns with retention policies aligned to legal requirements.
3. Allowing Cross-Scope Permission Inheritance
Explanation: Granting platform-level permissions to tenant-scoped affiliations creates privilege escalation vectors. Tenants can inadvertently access global configuration or other tenants' data.
Fix: Enforce scope boundaries at the policy definition level. Validate every permission grant against the affiliation's tenant context before resolution.
4. Ignoring Invitation Expiration & Deduplication
Explanation: Open-ended invitations accumulate stale records, increase attack surface, and confuse users with duplicate emails.
Fix: Add expiresAt timestamps to invitation records. Implement idempotency checks that return existing pending affiliations instead of creating duplicates.
5. Bypassing the State Machine for Direct Updates
Explanation: Allowing arbitrary status changes (e.g., jumping from INVITED to SUSPENDED) breaks audit consistency and creates undefined permission states.
Fix: Centralize state transitions behind a service layer that validates against a predefined transition matrix. Reject invalid jumps with explicit error codes.
6. Caching Permissions Without Invalidation Strategy
Explanation: Caching resolved permissions improves performance but causes stale access when policies update or affiliations change state.
Fix: Tag cached permission sets with policy version and affiliation state hashes. Invalidate on policy updates, status transitions, or explicit admin revocation events.
7. Over-Engineering Custom Roles Too Early
Explanation: Building dynamic role builders before establishing baseline policies leads to permission sprawl and unmanageable access matrices.
Fix: Start with 3-5 predefined tenant-scoped policies. Introduce additive overrides only when specific use cases cannot be covered. Defer custom role creation until organizational complexity demands it.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single-tenant SaaS with <50 users | Flat user-role schema | Simplicity outweighs audit requirements | Low |
| Multi-tenant B2B platform | Relational affiliation model | Compliance, delegation, and lifecycle needs | Medium |
| Enterprise with strict SOC 2/GDPR | Stateful lifecycle + soft deletes | Audit trails and data retention mandates | Medium-High |
| Rapid prototype / MVP | Predefined policies only | Reduces configuration overhead during validation | Low |
| Mature platform with complex org charts | Additive overrides + custom policies | Handles edge cases without role explosion | Medium |
Configuration Template
// prisma/schema.prisma
model WorkspacePrincipal {
id String @id @default(uuid())
email String @unique
authProvider String
createdAt DateTime @default(now())
affiliations TenantAffiliation[]
}
model TenantAffiliation {
id String @id @default(uuid())
principalId String
tenantId String
policyId String
status String @default("invited")
invitedBy String
invitedAt DateTime @default(now())
activatedAt DateTime?
deactivatedAt DateTime?
metadata Json?
principal WorkspacePrincipal @relation(fields: [principalId], references: [id])
policy AccessPolicy @relation(fields: [policyId], references: [id])
@@unique([principalId, tenantId])
@@index([status])
}
model AccessPolicy {
id String @id @default(uuid())
tenantId String?
name String
slug String
isSystem Boolean @default(false)
createdAt DateTime @default(now())
permissions PermissionMatrix[]
}
model PermissionMatrix {
id String @id @default(uuid())
policyId String
resource String
action String
scope String
policy AccessPolicy @relation(fields: [policyId], references: [id])
@@unique([policyId, resource, action])
}
Quick Start Guide
- Initialize the schema: Run
prisma db push to create the identity, affiliation, policy, and permission tables. Seed default tenant-scoped policies using the configuration template.
- Implement the invitation service: Create a function that checks for existing principals, generates a pending affiliation record, and dispatches an email with a secure token. Add expiration logic (e.g., 7 days).
- Build the acceptance handler: Validate the invitation token, verify the affiliation exists in
INVITED state, transition to ACTIVE, stamp activatedAt, and clear the token.
- Wire permission resolution: Create a middleware or guard that calls
resolveEffectivePermissions on authenticated requests. Cache results with policy version tags and invalidate on state changes.
- Add audit logging: Subscribe to affiliation state transitions and policy updates. Write immutable events to an audit table or external logging service with principal ID, tenant ID, action, and timestamp.