多租户权限设计
要点
- 多租户系统需要在数据、功能、配置三个层面做隔离
- 数据隔离有三种模式:共享数据库、独立 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,
})
}总结
多租户权限设计需要在数据、功能、配置三个层面做隔离。共享数据库是最常见的选择。
这一节涉及到的几个实践:
- 隔离模式:共享数据库、独立 Schema、独立数据库
- 数据隔离:租户过滤、辅助函数、Drizzle 插件
- 租户角色:owner、admin、member、viewer
- 配额管理:不同计划的不同限制
- 租户配置:自定义配置、配置继承
- 安全考虑:防止越权、防止枚举、审计日志
多租户是 AI SaaS 的基础。数据隔离要严格,配额管理要灵活,租户管理员要有足够的控制权。
下一篇看 AI 项目额度与权限控制——模型配额、Token 限制、计费系统。