AgentSkillsCN

multi-tenancy-patterns

在为B2B SaaS应用实施多租户架构时加载此模块。适用于设计租户隔离机制、行级安全策略、组织切换功能,或基于角色的访问控制方案。

SKILL.md
--- frontmatter
name: multi-tenancy-patterns
description: Load when implementing multi-tenancy for B2B SaaS applications. Applies when designing tenant isolation, Row-Level Security, organization switching, or role-based access control.

Isolation Strategies

Strategy Comparison

StrategyIsolationCostComplexityUse Case
Shared Tables (Row-Level)LogicalLowMediumMost B2B SaaS
Separate SchemasStrongerMediumHighRegulated industries
Separate DatabasesStrongestHighVery HighEnterprise, compliance

Shared Tables with Tenant ID

sql
-- Every tenant-scoped table has tenant_id
CREATE TABLE projects (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  name VARCHAR(255) NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- ALWAYS filter by tenant_id
CREATE INDEX idx_projects_tenant ON projects(tenant_id);

PostgreSQL Row-Level Security (RLS)

Enable RLS on Tenant Tables

sql
-- Enable RLS
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects FORCE ROW LEVEL SECURITY;

-- Policy: Users can only see their tenant's data
CREATE POLICY tenant_isolation ON projects
  USING (tenant_id = current_setting('app.current_tenant')::uuid);

-- Policy for insert (ensure new rows have correct tenant)
CREATE POLICY tenant_insert ON projects
  FOR INSERT
  WITH CHECK (tenant_id = current_setting('app.current_tenant')::uuid);

Set Tenant Context Per Request

typescript
// Middleware to set tenant context
async function tenantMiddleware(req, res, next) {
  const tenantId = req.user.tenantId;
  
  // Set PostgreSQL session variable
  await db.query(`SET app.current_tenant = '${tenantId}'`);
  
  next();
}

RLS Performance Optimization

sql
-- Use SECURITY DEFINER functions for complex lookups
CREATE FUNCTION get_user_tenant_id() RETURNS uuid AS $$
  SELECT tenant_id FROM users WHERE id = current_setting('app.current_user')::uuid
$$ LANGUAGE sql SECURITY DEFINER STABLE;

CREATE POLICY tenant_isolation ON projects
  USING (tenant_id = get_user_tenant_id());

Organization Model

Database Schema

sql
CREATE TABLE tenants (
  id UUID PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  slug VARCHAR(63) UNIQUE NOT NULL,  -- For subdomain/URL
  plan VARCHAR(50) DEFAULT 'free',
  settings JSONB DEFAULT '{}',
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE memberships (
  id UUID PRIMARY KEY,
  user_id UUID REFERENCES users(id),
  tenant_id UUID REFERENCES tenants(id),
  role VARCHAR(50) NOT NULL DEFAULT 'member',
  created_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(user_id, tenant_id)
);

-- Users can belong to multiple tenants
CREATE INDEX idx_memberships_user ON memberships(user_id);
CREATE INDEX idx_memberships_tenant ON memberships(tenant_id);

Role-Based Access

typescript
const roles = {
  owner: ['*'],  // All permissions
  admin: ['members:read', 'members:write', 'settings:read', 'settings:write', 'projects:*'],
  member: ['projects:read', 'projects:write'],
  viewer: ['projects:read'],
};

function hasPermission(membership: Membership, permission: string): boolean {
  const rolePerms = roles[membership.role];
  
  return rolePerms.some(perm => {
    if (perm === '*') return true;
    if (perm.endsWith(':*')) {
      return permission.startsWith(perm.replace(':*', ':'));
    }
    return perm === permission;
  });
}

Organization Switching

Frontend Context

typescript
// React context for current tenant
const TenantContext = createContext<Tenant | null>(null);

function TenantProvider({ children }) {
  const [tenant, setTenant] = useState<Tenant | null>(null);
  
  // Load from URL slug or stored preference
  useEffect(() => {
    const slug = window.location.pathname.split('/')[1];
    loadTenant(slug).then(setTenant);
  }, []);
  
  return (
    <TenantContext.Provider value={tenant}>
      {children}
    </TenantContext.Provider>
  );
}

API Tenant Resolution

typescript
// Resolve tenant from subdomain, path, or header
function resolveTenant(req: Request): string | null {
  // Option 1: Subdomain (acme.yourapp.com)
  const subdomain = req.hostname.split('.')[0];
  if (subdomain !== 'www' && subdomain !== 'app') {
    return subdomain;
  }
  
  // Option 2: Path prefix (/orgs/acme/...)
  const pathMatch = req.path.match(/^\/orgs\/([^/]+)/);
  if (pathMatch) return pathMatch[1];
  
  // Option 3: Header
  return req.headers['x-tenant-id'] as string | null;
}

Invitation Flow

Invite Model

sql
CREATE TABLE invitations (
  id UUID PRIMARY KEY,
  tenant_id UUID REFERENCES tenants(id),
  email VARCHAR(255) NOT NULL,
  role VARCHAR(50) NOT NULL DEFAULT 'member',
  token VARCHAR(255) UNIQUE NOT NULL,
  expires_at TIMESTAMPTZ NOT NULL,
  accepted_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

Invitation Flow

typescript
async function inviteMember(tenantId: string, email: string, role: string) {
  const token = generateSecureToken();
  
  await db.invitations.create({
    data: {
      tenant_id: tenantId,
      email,
      role,
      token,
      expires_at: addDays(new Date(), 7),
    },
  });
  
  await sendEmail('invitation', email, {
    inviteUrl: `https://app.yourapp.com/invite/${token}`,
  });
}

async function acceptInvitation(token: string, userId: string) {
  const invite = await db.invitations.findUnique({ where: { token } });
  
  if (!invite || invite.expires_at < new Date()) {
    throw new Error('Invalid or expired invitation');
  }
  
  await db.memberships.create({
    data: {
      user_id: userId,
      tenant_id: invite.tenant_id,
      role: invite.role,
    },
  });
  
  await db.invitations.update({
    where: { id: invite.id },
    data: { accepted_at: new Date() },
  });
}

Cross-Tenant Data

Shared vs. Tenant-Specific Tables

code
Shared (no tenant_id):
- users (shared identity across tenants)
- plans (pricing tiers)
- features (feature flags)

Tenant-Scoped (has tenant_id):
- projects
- documents
- team_settings
- audit_logs

User Identity vs. Membership

typescript
// User exists once, belongs to many tenants
interface User {
  id: string;
  email: string;
  name: string;
}

// Membership ties user to tenant with role
interface Membership {
  userId: string;
  tenantId: string;
  role: 'owner' | 'admin' | 'member' | 'viewer';
}

// Current context includes both
interface AuthContext {
  user: User;
  membership: Membership;
  tenant: Tenant;
}

Common Gotchas

Forgetting Tenant Filter

Always include tenant_id in queries. RLS helps but isn't a substitute for good code.

Cross-Tenant Data Leaks

Audit all queries that join tenant-scoped tables. Use RLS as defense-in-depth.

Tenant Slug Collisions

Reserve common slugs: www, app, api, admin, support.

Expensive Tenant-Specific Indexes

Full-table indexes hurt all tenants. Consider partial indexes for large tenants.

Deletion Cascades

When deleting a tenant, cascade to all related data. Use soft deletes for recovery.


Quick Reference

TaskPattern
Tenant isolationRLS + tenant_id column
Set tenant contextSET app.current_tenant = 'uuid'
Role checkroles[membership.role].includes(permission)
Org switchingURL path/subdomain + context provider
User → TenantsJoin through memberships table
InvitationsToken-based, 7-day expiry

References