.claude/skills/multi-tenant/SKILL.md
# Multi-Tenant Architecture Multi-tenant architecture skill for Lobbi member management system. Activates when working with tenant isolation, organization context, or row-level security patterns. **Triggers:** multi-tenant, tenant, org_id, organization, isolation, tenant-aware, row-level-security, rls, organization_id **Use this skill when:** - Implementing tenant-scoped database queries - Adding organization context to API endpoints - Enforcing tenant isolation in repositories - Working with
npx skillsauth add markus41/claude .claude/skills/multi-tenantInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Multi-tenant architecture skill for Lobbi member management system. Activates when working with tenant isolation, organization context, or row-level security patterns.
Triggers: multi-tenant, tenant, org_id, organization, isolation, tenant-aware, row-level-security, rls, organization_id
Use this skill when:
Tenant Context Propagation
Database Isolation Patterns
Tenant-Aware Repository Pattern
Request → AuthMiddleware → TenantMiddleware → Controller → Service → Repository → Database
(extract JWT) (set context) (validate) (logic) (filter) (RLS)
// backend/prisma/schema.prisma
model Member {
id String @id @default(cuid())
organizationId String @map("organization_id")
email String
firstName String @map("first_name")
lastName String @map("last_name")
status MemberStatus @default(ACTIVE)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
organization Organization @relation(fields: [organizationId], references: [id])
memberships Membership[]
// Composite unique constraint: email unique per tenant
@@unique([organizationId, email])
@@index([organizationId, status])
@@index([organizationId, email])
@@map("members")
}
model Membership {
id String @id @default(cuid())
organizationId String @map("organization_id")
memberId String @map("member_id")
tierId String @map("tier_id")
status MembershipStatus @default(PENDING)
startDate DateTime @map("start_date")
endDate DateTime? @map("end_date")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
organization Organization @relation(fields: [organizationId], references: [id])
member Member @relation(fields: [memberId], references: [id])
tier MembershipTier @relation(fields: [tierId], references: [id])
@@unique([organizationId, memberId, tierId])
@@index([organizationId, status])
@@map("memberships")
}
// backend/src/types/tenant.types.ts
export interface TenantContext {
organizationId: string;
userId?: string;
permissions?: string[];
}
export interface TenantRequest extends Request {
tenant: TenantContext;
}
export class TenantError extends Error {
constructor(message: string, public organizationId?: string) {
super(message);
this.name = 'TenantError';
}
}
// backend/src/middleware/tenant.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { TenantRequest, TenantContext } from '../types/tenant.types';
import { UnauthorizedException } from '../exceptions';
export const tenantMiddleware = (
req: Request,
res: Response,
next: NextFunction
) => {
const tenantReq = req as TenantRequest;
// Extract org_id from JWT claims (set by auth middleware)
const organizationId = tenantReq.user?.organizationId;
if (!organizationId) {
throw new UnauthorizedException('Organization context required');
}
// Set tenant context
tenantReq.tenant = {
organizationId,
userId: tenantReq.user?.id,
permissions: tenantReq.user?.permissions || []
};
next();
};
// Optional: Override org_id from header (admin/support use only)
export const tenantOverrideMiddleware = (
req: Request,
res: Response,
next: NextFunction
) => {
const tenantReq = req as TenantRequest;
const overrideOrgId = req.headers['x-organization-id'] as string;
// Only allow override if user has admin role
if (overrideOrgId && tenantReq.user?.role === 'SUPER_ADMIN') {
tenantReq.tenant.organizationId = overrideOrgId;
}
next();
};
// backend/src/repositories/base-tenant.repository.ts
import { PrismaClient } from '@prisma/client';
import { TenantContext } from '../types/tenant.types';
export abstract class BaseTenantRepository<T> {
constructor(protected prisma: PrismaClient) {}
/**
* Find by ID with automatic tenant filtering
*/
protected async findByIdWithTenant(
model: any,
id: string,
tenant: TenantContext
): Promise<T | null> {
return model.findFirst({
where: {
id,
organizationId: tenant.organizationId
}
});
}
/**
* Find many with automatic tenant filtering
*/
protected async findManyWithTenant(
model: any,
where: any,
tenant: TenantContext
): Promise<T[]> {
return model.findMany({
where: {
...where,
organizationId: tenant.organizationId
}
});
}
/**
* Create with automatic tenant context
*/
protected async createWithTenant(
model: any,
data: any,
tenant: TenantContext
): Promise<T> {
return model.create({
data: {
...data,
organizationId: tenant.organizationId
}
});
}
/**
* Update with tenant validation
*/
protected async updateWithTenant(
model: any,
id: string,
data: any,
tenant: TenantContext
): Promise<T> {
// First verify record belongs to tenant
const existing = await this.findByIdWithTenant(model, id, tenant);
if (!existing) {
throw new Error(`Record ${id} not found for organization ${tenant.organizationId}`);
}
return model.update({
where: { id },
data
});
}
/**
* Delete with tenant validation
*/
protected async deleteWithTenant(
model: any,
id: string,
tenant: TenantContext
): Promise<T> {
// First verify record belongs to tenant
const existing = await this.findByIdWithTenant(model, id, tenant);
if (!existing) {
throw new Error(`Record ${id} not found for organization ${tenant.organizationId}`);
}
return model.delete({
where: { id }
});
}
}
// backend/src/repositories/member.repository.ts
import { PrismaClient, Member } from '@prisma/client';
import { BaseTenantRepository } from './base-tenant.repository';
import { TenantContext } from '../types/tenant.types';
export interface CreateMemberDto {
email: string;
firstName: string;
lastName: string;
phoneNumber?: string;
status?: 'ACTIVE' | 'INACTIVE';
}
export interface UpdateMemberDto {
email?: string;
firstName?: string;
lastName?: string;
phoneNumber?: string;
status?: 'ACTIVE' | 'INACTIVE';
}
export class MemberRepository extends BaseTenantRepository<Member> {
constructor(prisma: PrismaClient) {
super(prisma);
}
async findById(id: string, tenant: TenantContext): Promise<Member | null> {
return this.findByIdWithTenant(this.prisma.member, id, tenant);
}
async findByEmail(email: string, tenant: TenantContext): Promise<Member | null> {
return this.prisma.member.findUnique({
where: {
organizationId_email: {
organizationId: tenant.organizationId,
email
}
}
});
}
async findAll(tenant: TenantContext, filters?: {
status?: string;
search?: string;
}): Promise<Member[]> {
const where: any = {
organizationId: tenant.organizationId
};
if (filters?.status) {
where.status = filters.status;
}
if (filters?.search) {
where.OR = [
{ email: { contains: filters.search, mode: 'insensitive' } },
{ firstName: { contains: filters.search, mode: 'insensitive' } },
{ lastName: { contains: filters.search, mode: 'insensitive' } }
];
}
return this.prisma.member.findMany({ where });
}
async create(data: CreateMemberDto, tenant: TenantContext): Promise<Member> {
// Check for duplicate email within tenant
const existing = await this.findByEmail(data.email, tenant);
if (existing) {
throw new Error(`Member with email ${data.email} already exists`);
}
return this.createWithTenant(this.prisma.member, data, tenant);
}
async update(
id: string,
data: UpdateMemberDto,
tenant: TenantContext
): Promise<Member> {
// If updating email, check for duplicates
if (data.email) {
const existing = await this.findByEmail(data.email, tenant);
if (existing && existing.id !== id) {
throw new Error(`Member with email ${data.email} already exists`);
}
}
return this.updateWithTenant(this.prisma.member, id, data, tenant);
}
async delete(id: string, tenant: TenantContext): Promise<Member> {
return this.deleteWithTenant(this.prisma.member, id, tenant);
}
async count(tenant: TenantContext): Promise<number> {
return this.prisma.member.count({
where: { organizationId: tenant.organizationId }
});
}
}
// backend/src/services/member.service.ts
import { MemberRepository, CreateMemberDto, UpdateMemberDto } from '../repositories/member.repository';
import { TenantContext } from '../types/tenant.types';
import { Member } from '@prisma/client';
export class MemberService {
constructor(private memberRepository: MemberRepository) {}
async getMember(id: string, tenant: TenantContext): Promise<Member | null> {
return this.memberRepository.findById(id, tenant);
}
async getMembers(tenant: TenantContext, filters?: {
status?: string;
search?: string;
}): Promise<Member[]> {
return this.memberRepository.findAll(tenant, filters);
}
async createMember(data: CreateMemberDto, tenant: TenantContext): Promise<Member> {
// Business logic validation
if (!this.isValidEmail(data.email)) {
throw new Error('Invalid email format');
}
return this.memberRepository.create(data, tenant);
}
async updateMember(
id: string,
data: UpdateMemberDto,
tenant: TenantContext
): Promise<Member> {
if (data.email && !this.isValidEmail(data.email)) {
throw new Error('Invalid email format');
}
return this.memberRepository.update(id, data, tenant);
}
async deleteMember(id: string, tenant: TenantContext): Promise<void> {
await this.memberRepository.delete(id, tenant);
}
private isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
}
// backend/src/controllers/member.controller.ts
import { Response, NextFunction } from 'express';
import { TenantRequest } from '../types/tenant.types';
import { MemberService } from '../services/member.service';
export class MemberController {
constructor(private memberService: MemberService) {}
async getMembers(req: TenantRequest, res: Response, next: NextFunction) {
try {
const { status, search } = req.query;
const members = await this.memberService.getMembers(
req.tenant,
{
status: status as string,
search: search as string
}
);
res.json({ data: members });
} catch (error) {
next(error);
}
}
async getMember(req: TenantRequest, res: Response, next: NextFunction) {
try {
const { id } = req.params;
const member = await this.memberService.getMember(id, req.tenant);
if (!member) {
return res.status(404).json({ error: 'Member not found' });
}
res.json({ data: member });
} catch (error) {
next(error);
}
}
async createMember(req: TenantRequest, res: Response, next: NextFunction) {
try {
const member = await this.memberService.createMember(
req.body,
req.tenant
);
res.status(201).json({ data: member });
} catch (error) {
next(error);
}
}
async updateMember(req: TenantRequest, res: Response, next: NextFunction) {
try {
const { id } = req.params;
const member = await this.memberService.updateMember(
id,
req.body,
req.tenant
);
res.json({ data: member });
} catch (error) {
next(error);
}
}
async deleteMember(req: TenantRequest, res: Response, next: NextFunction) {
try {
const { id } = req.params;
await this.memberService.deleteMember(id, req.tenant);
res.status(204).send();
} catch (error) {
next(error);
}
}
}
// backend/src/routes/member.routes.ts
import { Router } from 'express';
import { MemberController } from '../controllers/member.controller';
import { authMiddleware } from '../middleware/auth.middleware';
import { tenantMiddleware } from '../middleware/tenant.middleware';
export const createMemberRouter = (memberController: MemberController): Router => {
const router = Router();
// All routes require authentication and tenant context
router.use(authMiddleware);
router.use(tenantMiddleware);
router.get('/', memberController.getMembers.bind(memberController));
router.get('/:id', memberController.getMember.bind(memberController));
router.post('/', memberController.createMember.bind(memberController));
router.put('/:id', memberController.updateMember.bind(memberController));
router.delete('/:id', memberController.deleteMember.bind(memberController));
return router;
};
// backend/src/repositories/__tests__/member.repository.test.ts
import { PrismaClient } from '@prisma/client';
import { MemberRepository } from '../member.repository';
import { TenantContext } from '../../types/tenant.types';
describe('MemberRepository - Tenant Isolation', () => {
let prisma: PrismaClient;
let repository: MemberRepository;
let tenant1: TenantContext;
let tenant2: TenantContext;
beforeAll(async () => {
prisma = new PrismaClient();
repository = new MemberRepository(prisma);
tenant1 = { organizationId: 'org-1' };
tenant2 = { organizationId: 'org-2' };
});
afterAll(async () => {
await prisma.$disconnect();
});
describe('Tenant Isolation', () => {
it('should isolate members by tenant', async () => {
// Create member for tenant 1
const member1 = await repository.create(
{ email: '[email protected]', firstName: 'John', lastName: 'Doe' },
tenant1
);
// Create member for tenant 2
const member2 = await repository.create(
{ email: '[email protected]', firstName: 'Jane', lastName: 'Doe' },
tenant2
);
// Verify tenant 1 can only see their members
const tenant1Members = await repository.findAll(tenant1);
expect(tenant1Members).toHaveLength(1);
expect(tenant1Members[0].id).toBe(member1.id);
// Verify tenant 2 can only see their members
const tenant2Members = await repository.findAll(tenant2);
expect(tenant2Members).toHaveLength(1);
expect(tenant2Members[0].id).toBe(member2.id);
});
it('should prevent cross-tenant access by ID', async () => {
const member = await repository.create(
{ email: '[email protected]', firstName: 'Test', lastName: 'User' },
tenant1
);
// Tenant 2 should not be able to access tenant 1's member
const result = await repository.findById(member.id, tenant2);
expect(result).toBeNull();
});
it('should allow duplicate emails across tenants', async () => {
const email = '[email protected]';
// Create member with same email in different tenants
const member1 = await repository.create(
{ email, firstName: 'User1', lastName: 'Tenant1' },
tenant1
);
const member2 = await repository.create(
{ email, firstName: 'User2', lastName: 'Tenant2' },
tenant2
);
expect(member1.email).toBe(email);
expect(member2.email).toBe(email);
expect(member1.id).not.toBe(member2.id);
});
it('should prevent duplicate emails within same tenant', async () => {
const email = '[email protected]';
await repository.create(
{ email, firstName: 'First', lastName: 'User' },
tenant1
);
// Attempt to create duplicate
await expect(
repository.create(
{ email, firstName: 'Second', lastName: 'User' },
tenant1
)
).rejects.toThrow();
});
});
});
When adding multi-tenancy to existing models:
organizationId String @map("organization_id") field@@index([organizationId, otherField])npx prisma migrate devdevelopment
Enhanced plan-authoring skill with Pre-Writing context gathering, task metadata, non-TDD templates, Red Flags, telemetry, and an automated plan linter. Use when you have a spec or requirements for a multi-step task, before touching code.
tools
Documentation intelligence engine with graph-based API docs, algorithm library, and drift detection
tools
Ultraplan cloud planning — kick off a plan in the cloud from your terminal, review and revise in the browser, then execute remotely or send back to CLI
tools
--- name: mcp description: Configure MCP servers for Claude Code — stdio vs HTTP, authentication, Tools/Resources/Prompts distinction, channels (CI webhook, mobile relay, Discord bridge, fakechat), and cost of always-loaded tools. Use this skill whenever adding an MCP server, debugging connection issues, choosing between MCP Tools vs Prompts vs Resources, installing channel servers, or managing .mcp.json. Triggers on: "MCP server", "mcp config", "add Obsidian MCP", "install context7", "channels"