多租户权限设计

要点

  • 多租户系统需要在数据、功能、配置三个层面做隔离
  • 数据隔离有三种模式:共享数据库、独立 Schema、独立数据库
  • 租户级别的权限控制需要防止越权访问
  • AI 项目的多租户通常涉及配额、模型权限、自定义配置
  • 租户管理员应该能管理自己租户内的用户和权限
  • 中间件层统一注入租户上下文,避免在每个查询里手动过滤

1. 多租户模型

1.1 三种隔离模式

共享数据库,共享 Schema

┌─────────────────────────────────┐
│         Database (shared)       │
│  ┌───────────────────────────┐  │
│  │  Table: conversations     │  │
│  │  - tenant_id              │  │
│  │  - id                     │  │
│  │  - title                  │  │
│  └───────────────────────────┘  │
└─────────────────────────────────┘
 
租户 A 和租户 B 的数据在同一张表里,通过 tenant_id 区分

优点:简单、成本低 缺点:需要严格过滤,数据泄露风险高

共享数据库,独立 Schema

┌─────────────────────────────────┐
│         Database (shared)       │
│  ┌───────────────────────────┐  │
│  │  Schema: tenant_a         │  │
│  │  - conversations          │  │
│  ├───────────────────────────┤  │
│  │  Schema: tenant_b         │  │
│  │  - conversations          │  │
│  └───────────────────────────┘  │
└─────────────────────────────────┘
 
每个租户有自己的 Schema,表结构相同但数据隔离

优点:逻辑隔离,备份灵活 缺点:Schema 迁移复杂

独立数据库

┌───────────────┐  ┌───────────────┐
│  DB: tenant_a │  │  DB: tenant_b │
│  - conversations│  │  - conversations│
└───────────────┘  └───────────────┘
 
每个租户有独立的数据库

优点:完全隔离,性能独立 缺点:成本高,管理复杂

AI 项目通常选择第一种(共享数据库),成本低且足够。

1.2 租户上下文

每个请求都需要知道当前租户:

import { createMiddleware } from 'hono/factory'
 
type TenantVariables = {
  tenant: {
    id: string
    name: string
    plan: string
  }
}
 
const tenantMiddleware = createMiddleware<{ Variables: TenantVariables }>(
  async (c, next) => {
    const user = c.get('user')
 
    // 从用户信息获取租户
    const tenant = await db.query.tenants.findFirst({
      where: eq(tenants.id, user.tenantId),
    })
 
    if (!tenant) {
      return c.json({ error: 'Tenant not found' }, 404)
    }
 
    c.set('tenant', tenant)
 
    await next()
  }
)

2. 数据隔离

2.1 自动过滤

每个查询都要过滤租户:

// ❌ 容易忘记过滤
app.get('/conversations', async (c) => {
  const conversations = await db.select().from(conversations)
  // 返回所有租户的数据!
})
 
// ✅ 手动过滤
app.get('/conversations', async (c) => {
  const tenant = c.get('tenant')
 
  const conversations = await db
    .select()
    .from(conversations)
    .where(eq(conversations.tenantId, tenant.id))
})

2.2 辅助函数

封装租户过滤逻辑:

// 租户感知的查询构建器
function tenantQuery<T extends Record<string, any>>(
  table: T,
  tenantId: string
) {
  return db.select().from(table).where(eq(table.tenantId, tenantId))
}
 
// 使用
app.get('/conversations', async (c) => {
  const tenant = c.get('tenant')
 
  const conversations = await tenantQuery(conversations, tenant.id)
})

2.3 Drizzle 插件

Drizzle 可以通过中间件自动注入租户过滤:

import { drizzle } from 'drizzle-orm/node-postgres'
 
const db = drizzle(pool)
 
// 包装查询方法,自动过滤
function createTenantDb(tenantId: string) {
  return {
    select: () => {
      const originalSelect = db.select()
 
      return new Proxy(originalSelect, {
        get(target, prop) {
          if (prop === 'from') {
            return (table: any) => {
              return target
                .from(table)
                .where(eq(table.tenantId, tenantId))
            }
          }
          return target[prop as keyof typeof target]
        },
      })
    },
  }
}
 
// 使用
app.get('/conversations', async (c) => {
  const tenant = c.get('tenant')
  const tenantDb = createTenantDb(tenant.id)
 
  const conversations = await tenantDb.select().from(conversations)
})

3. 租户级权限

3.1 租户角色

每个租户可以有自己的角色系统:

// 用户表
export const users = pgTable('users', {
  id: uuid('id').primaryKey().defaultRandom(),
  email: text('email').notNull(),
  name: text('name').notNull(),
})
 
// 租户表
export const tenants = pgTable('tenants', {
  id: uuid('id').primaryKey().defaultRandom(),
  name: text('name').notNull(),
  plan: text('plan').notNull(),  // free, pro, enterprise
})
 
// 用户-租户关联表
export const userTenants = pgTable('user_tenants', {
  id: uuid('id').primaryKey().defaultRandom(),
  userId: uuid('user_id').notNull().references(() => users.id),
  tenantId: uuid('tenant_id').notNull().references(() => tenants.id),
  role: text('role').notNull(),  // owner, admin, member, viewer
  createdAt: timestamp('created_at').defaultNow().notNull(),
})

3.2 租户管理员

租户管理员可以管理自己租户内的用户:

// 租户管理员邀请用户
app.post('/tenants/:id/invite', async (c) => {
  const tenantId = c.req.param('id')
  const currentUser = c.get('user')
 
  // 检查是否是租户管理员
  const membership = await db.query.userTenants.findFirst({
    where: and(
      eq(userTenants.userId, currentUser.id),
      eq(userTenants.tenantId, tenantId)
    ),
  })
 
  if (!membership || !['owner', 'admin'].includes(membership.role)) {
    return c.json({ error: 'Forbidden' }, 403)
  }
 
  const { email, role } = await c.req.json()
 
  // 创建邀请
  await db.insert(invitations).values({
    email,
    tenantId,
    role,
    invitedBy: currentUser.id,
  })
 
  // 发送邀请邮件
  await sendInvitationEmail(email, tenantId)
 
  return c.json({ message: 'Invitation sent' })
})

3.3 租户内权限

function defineAbilitiesFor(user: User, membership: Membership) {
  const { can, cannot, build } = new AbilityBuilder(createMongoAbility)
 
  // 所有成员可以读对话
  can('read', 'Conversation', { tenantId: membership.tenantId })
 
  // 成员可以创建对话
  if (['owner', 'admin', 'member'].includes(membership.role)) {
    can('create', 'Conversation', { tenantId: membership.tenantId })
  }
 
  // 管理员可以管理设置
  if (['owner', 'admin'].includes(membership.role)) {
    can('manage', 'Settings', { tenantId: membership.tenantId })
    can('manage', 'UserTenant', { tenantId: membership.tenantId })
  }
 
  // 只有 owner 可以删除租户
  if (membership.role === 'owner') {
    can('delete', 'Tenant', { id: membership.tenantId })
  }
 
  return build()
}

4. 配额管理

4.1 租户配额

不同计划有不同的配额:

const QUOTA_LIMITS = {
  free: {
    maxUsers: 3,
    maxConversations: 100,
    maxMessages: 1000,
    models: ['gpt-3.5-turbo'],
  },
  pro: {
    maxUsers: 10,
    maxConversations: 10000,
    maxMessages: 100000,
    models: ['gpt-3.5-turbo', 'gpt-4'],
  },
  enterprise: {
    maxUsers: -1,  // 无限制
    maxConversations: -1,
    maxMessages: -1,
    models: ['gpt-3.5-turbo', 'gpt-4', 'claude-3-opus'],
  },
}
 
// 检查配额
async function checkQuota(tenantId: string, resource: string) {
  const tenant = await db.query.tenants.findFirst({
    where: eq(tenants.id, tenantId),
  })
 
  const limits = QUOTA_LIMITS[tenant.plan as keyof typeof QUOTA_LIMITS]
 
  if (limits.maxUsers === -1) return true  // 无限制
 
  const count = await getUsageCount(tenantId, resource)
 
  return count < limits[`max${resource.charAt(0).toUpperCase() + resource.slice(1)}`]
}

4.2 配额检查

// 创建对话时检查配额
app.post('/conversations', async (c) => {
  const tenant = c.get('tenant')
 
  // 检查对话配额
  const canCreate = await checkQuota(tenant.id, 'conversations')
 
  if (!canCreate) {
    return c.json({
      error: 'Quota exceeded',
      upgradeUrl: '/billing/upgrade',
    }, 402)
  }
 
  // 创建对话
  const [conversation] = await db
    .insert(conversations)
    .values({
      tenantId: tenant.id,
      title: 'New Conversation',
    })
    .returning()
 
  return c.json(conversation, 201)
})

4.3 使用量统计

// 统计租户使用量
async function getTenantUsage(tenantId: string) {
  const [usage] = await db
    .select({
      conversationCount: sql<number>`count(distinct ${conversations.id})`,
      messageCount: sql<number>`count(${messages.id})`,
      userCount: sql<number>`count(distinct ${userTenants.userId})`,
    })
    .from(tenants)
    .leftJoin(conversations, eq(conversations.tenantId, tenants.id))
    .leftJoin(messages, eq(messages.conversationId, conversations.id))
    .leftJoin(userTenants, eq(userTenants.tenantId, tenants.id))
    .where(eq(tenants.id, tenantId))
 
  return usage
}

5. 租户配置

5.1 自定义配置

每个租户可以有自己的配置:

export const tenantConfigs = pgTable('tenant_configs', {
  tenantId: uuid('tenant_id').primaryKey().references(() => tenants.id),
  defaultModel: text('default_model').default('gpt-3.5-turbo').notNull(),
  systemPrompt: text('system_prompt'),
  features: jsonb('features').default({
    fileUpload: false,
    webSearch: false,
    codeInterpreter: false,
  }).notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
})

5.2 配置继承

// 默认配置
const DEFAULT_CONFIG = {
  defaultModel: 'gpt-3.5-turbo',
  features: {
    fileUpload: false,
    webSearch: false,
  },
}
 
// 获取租户配置(合并默认配置)
async function getTenantConfig(tenantId: string) {
  const config = await db.query.tenantConfigs.findFirst({
    where: eq(tenantConfigs.tenantId, tenantId),
  })
 
  return {
    ...DEFAULT_CONFIG,
    ...config,
    features: {
      ...DEFAULT_CONFIG.features,
      ...config?.features,
    },
  }
}

6. 安全考虑

6.1 防止越权

// ❌ 危险:没有检查租户
app.get('/conversations/:id', async (c) => {
  const id = c.req.param('id')
  const conversation = await db.query.conversations.findFirst({
    where: eq(conversations.id, id),
  })
  return c.json(conversation)
})
 
// ✅ 安全:检查租户
app.get('/conversations/:id', async (c) => {
  const id = c.req.param('id')
  const tenant = c.get('tenant')
 
  const conversation = await db.query.conversations.findFirst({
    where: and(
      eq(conversations.id, id),
      eq(conversations.tenantId, tenant.id)
    ),
  })
 
  if (!conversation) {
    return c.json({ error: 'Not found' }, 404)
  }
 
  return c.json(conversation)
})

6.2 防止枚举

// ❌ 泄露是否存在
if (!conversation) {
  return c.json({ error: 'Conversation not found' }, 404)
}
 
// ✅ 统一返回
if (!conversation) {
  return c.json({ error: 'Not found' }, 404)
}

6.3 审计日志

记录租户内的重要操作:

export const auditLogs = pgTable('audit_logs', {
  id: uuid('id').primaryKey().defaultRandom(),
  tenantId: uuid('tenant_id').notNull(),
  userId: uuid('user_id').notNull(),
  action: text('action').notNull(),  // user.invite, user.remove, settings.update
  resourceType: text('resource_type').notNull(),
  resourceId: text('resource_id'),
  metadata: jsonb('metadata'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
})
 
// 记录审计日志
async function logAudit(
  tenantId: string,
  userId: string,
  action: string,
  resourceType: string,
  resourceId?: string,
  metadata?: any
) {
  await db.insert(auditLogs).values({
    tenantId,
    userId,
    action,
    resourceType,
    resourceId,
    metadata,
  })
}

总结

多租户权限设计需要在数据、功能、配置三个层面做隔离。共享数据库是最常见的选择。

这一节涉及到的几个实践:

  1. 隔离模式:共享数据库、独立 Schema、独立数据库
  2. 数据隔离:租户过滤、辅助函数、Drizzle 插件
  3. 租户角色:owner、admin、member、viewer
  4. 配额管理:不同计划的不同限制
  5. 租户配置:自定义配置、配置继承
  6. 安全考虑:防止越权、防止枚举、审计日志

多租户是 AI SaaS 的基础。数据隔离要严格,配额管理要灵活,租户管理员要有足够的控制权。

下一篇看 AI 项目额度与权限控制——模型配额、Token 限制、计费系统。