thelobbi/claude

multi-tenant

Multi-tenant architecture patterns including org_id claim management, JWT token structure with organization context, database isolation strategies for MongoDB…

First seen Feb 27, 2026

Installation

$ npx skills add thelobbi/claude --skill multi-tenant

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from thelobbi/claude · top by installs.

npx skills add thelobbi/claude

Browse all from thelobbi/claude

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Also listed on

Alternate registries and mirrors of this skill.

Repository health

Stars 21
License LICENSE
Default branch main
Open issues 5
Status Active

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 19,513 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 32 installs

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, orgid, organization, isolation, tenant-aware, row-level-security, rls, organizationid

Use this skill when:

  • Implementing tenant-scoped database queries
  • Adding organization context to API endpoints
  • Enforcing tenant isolation in repositories
  • Working with tenant middleware or context propagation
  • Designing multi-tenant data models

Allowed Tools

  • prisma (ORM and migrations)
  • postgresql (database operations)
  • express (middleware and routing)
  • typescript (type safety)
  • jest (testing tenant isolation)

Instructions

Core Principles

  1. Tenant Context Propagation

- Every request must carry tenant context (orgid) - Middleware extracts orgid from JWT or headers - Context flows through service → repository layers - Never allow cross-tenant data leakage

  1. Database Isolation Patterns

- Use row-level filtering with org_id in WHERE clauses - Prisma models must include organizationId field - Composite unique constraints: [organizationId, businessKey] - Never expose global IDs without tenant checks

  1. Tenant-Aware Repository Pattern

- All repository methods accept TenantContext - Automatic org_id filtering in base repository - Explicit tenant validation in create/update operations - Audit logging includes tenant information

Architecture Layers

Request → AuthMiddleware → TenantMiddleware → Controller → Service → Repository → Database
           (extract JWT)    (set context)      (validate)   (logic)   (filter)    (RLS)

Implementation Checklist

  • Add organizationId to all tenant-scoped Prisma models
  • Implement tenant context middleware
  • Create BaseTenantRepository with auto-filtering
  • Add tenant validation to all CRUD operations
  • Implement tenant-aware error handling
  • Add tenant isolation tests
  • Document tenant security boundaries

Code Examples

1. Prisma Schema Pattern

// 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")
}

2. Tenant Context Types

// 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';
  }
}

3. Tenant Context Middleware

// 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();
};

4. Base Tenant Repository

// 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 }
    });
  }
}

5. Concrete Repository Implementation

// 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 }
    });
  }
}

6. Service Layer with Tenant Context

// 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);
  }
}

7. Controller with Tenant Context

// 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);
    }
  }
}

8. Router with Tenant Middleware

// 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;
};

9. Testing Tenant Isolation

// 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();
    });
  });
});

Best Practices

  1. Always validate tenant context - Never trust org_id from client
  2. Use composite unique constraints - [organizationId, businessKey]
  3. Audit tenant operations - Log org_id in all mutations
  4. Test isolation rigorously - Automated tests for cross-tenant leakage
  5. Index efficiently - Include organizationId in all query indexes
  6. Handle errors gracefully - Don't expose cross-tenant data in errors
  7. Document tenant boundaries - Clear API documentation on scoping

Common Pitfalls

  • ❌ Forgetting to add organizationId to WHERE clause
  • ❌ Using findUnique with just ID (bypasses tenant filter)
  • ❌ Exposing global IDs without validation
  • ❌ Accepting org_id from request body
  • ❌ Missing indexes on [organizationId, ...]
  • ❌ Not testing cross-tenant access scenarios

Migration Checklist

When adding multi-tenancy to existing models:

  1. Add organizationId String @map("organization_id") field
  2. Add relation to Organization model
  3. Update all unique constraints to include organizationId
  4. Create indexes: @@index([organizationId, otherField])
  5. Update all queries to filter by organizationId
  6. Add tenant context to all repository methods
  7. Write tenant isolation tests
  8. Run migration: npx prisma migrate dev

Related Skills

  • authentication - JWT token structure and claims
  • rest-api - API design with tenant scoping
  • database - Prisma schema design and indexing
  • testing - Integration tests for tenant isolation