B2C 产品的权限模型通常很简单:一个用户,一组角色,一堆资源。B2B SaaS 不是这样。每个企业客户(租户)是一个独立的组织,有自己的员工、自己的角色体系、自己的审计要求;他们既不希望看到别的租户的数据,也不希望平台方随意查看自己的数据;同时还要能把某份文档分享给一家合作公司的某个人,或者邀请外部审计师只读访问三天。平台运营方还要有超级管理员能在客户报障时登录进去排查——而且必须留下审计痕迹,不能被客户起诉。
这些需求叠加在一起,权限模型就变成了一个多层嵌套的怪兽。本文从租户隔离模型讲起,给出租户内 RBAC + 租户间 ReBAC 的混合方案,讨论超级管理员、行级权限、字段掩码、租户自助管理等工程细节,最后对比 GitHub、Slack、Notion 的真实做法。
一、B2B SaaS 的权限三层结构
理解多租户权限,第一步是认清它不是一层而是三层。把三层混在一起设计,是 90% 的 B2B SaaS 权限事故的根源。
1.1 三层权限的职责
平台层(Platform Layer):SaaS 运营方自己的员工。包括:
- 超级管理员:创建租户、封禁租户、查看全局指标
- 支持账号:客户报障时登录租户进行排查,应当限时、审计
- 财务与合规账号:导出账单、响应 GDPR 请求
- 运维与 SRE:访问生产库(通常不经过应用层权限)
租户层(Tenant Layer):每个客户企业内部的员工。每个租户有:
- 组织管理员(org admin):管理本租户的成员、角色、计费
- 团队(team):租户内的部门或项目组
- 角色(role):内置角色(开发、查看者)+ 自定义角色(审计员、外部顾问)
资源层(Resource Layer):具体的业务数据。例如:
- 文档、项目、看板、仓库
- 数据库记录(客户表的某一行)
- 敏感字段(手机号、身份证号、银行账号)
1.2 三层混淆的典型灾难
- 平台管理员复用租户角色体系:超级管理员也是”某个租户下的 admin”,导致必须先加入一个租户才能生效,后果是所有租户都能看到这个”幽灵成员”,而且超管一旦被封禁,平台运维也崩溃。
- 跨层继承:把租户内的 team 权限直接写进资源表,跨租户分享时完全无法建模。
- 单一权限表:一张
user_permissions(user_id, permission)扫遍所有场景,于是permission = 'tenant-1001:project-7:write'这种字符串拼接出现,改一次规则全站炸。 - 缺失 tenant 维度:权限表缺少
tenant_id,一个租户内的角色名和另一个租户撞名,于是 A 租户的”admin”意外获得了 B 租户的查询权。
正确的做法是三层分开建模,每层有自己的表、自己的缓存、自己的审计流,然后通过显式的接入点连接。
1.3 本文采用的层次划分
| 层级 | 授权模型 | 主表 | 隔离手段 |
|---|---|---|---|
| 平台层 | 白名单 + 审批 | platform_admins、privilege_access_log |
独立身份域、独立登录入口 |
| 租户层 | RBAC(内置 + 自定义角色) | tenant_roles、user_tenant_memberships |
tenant_id + RLS |
| 资源层 | RBAC 粗粒度 + ReBAC 细粒度 | document_permissions、relation_tuples |
行级 + 列级 + 字段掩码 |
二、租户隔离模型与授权的关系
授权方案不能脱离数据隔离方案单独设计。你选择怎么把租户的数据放在数据库里,直接决定了你的权限检查代码长什么样。
2.1 三种主流隔离模型
Shared DB, Shared
Schema(共享库共享表):所有租户的数据混在同一张表,用
tenant_id 列区分。
- 优点:资源利用率最高、升级最快、运维最简单
- 缺点:每一条 SQL 都必须带
tenant_id,少一个就是跨租户数据泄露(OWASP API3:2023 BOLA/BOPLA) - 授权影响:强依赖 Row Level Security 或应用层强制过滤
Shared DB, Schema per Tenant(共享库独立 schema):一个 PostgreSQL 数据库,每个租户一个 schema。
- 优点:数据物理分离度更高,备份/迁移单租户容易
- 缺点:schema 数量上万后 pg_catalog 性能下降、DDL 变更要遍历所有 schema
- 授权影响:连接时
SET search_path TO tenant_xxx,RLS 仍然可用但更自然
DB per Tenant(一租户一库):每个租户一个独立的数据库实例或独立文件。
- 优点:最强隔离,符合某些行业(金融、医疗)合规要求
- 缺点:成本极高,小租户的利用率极低
- 授权影响:连接池按租户分配,应用层几乎不需要
tenant_id过滤,但需要路由层强保障
2.2 PostgreSQL RLS 示例
共享表模式下,RLS 是防止”忘记写 WHERE”最后一道防线。
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON documents
USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
CREATE POLICY document_access ON documents
USING (
EXISTS (
SELECT 1 FROM document_permissions dp
WHERE dp.document_id = documents.id
AND dp.user_id = current_setting('app.current_user_id')::bigint
AND dp.tenant_id = current_setting('app.current_tenant_id')::bigint
)
);应用层在建立连接(或每个事务开头)执行:
SET LOCAL app.current_tenant_id = '1001';
SET LOCAL app.current_user_id = '42';此后任何
SELECT * FROM documents(即使程序员忘了写
WHERE)也只会返回当前租户、当前用户有权查看的行。
2.3 RLS 的陷阱
- 超级用户绕过:PostgreSQL
超级用户和表属主默认绕过 RLS,需要
FORCE ROW LEVEL SECURITY才能对属主生效。 - 连接池复用:使用 pgBouncer transaction
pooling 时必须用
SET LOCAL(事务级),否则会话变量会串到别的租户请求。 - JOIN 性能:RLS 会把 policy
内的子查询下推到每次访问,对
tenant_id上的复合索引要求极高。 - UPDATE/DELETE 行为:默认 policy 只对
SELECT生效,写操作需要单独的WITH CHECK子句,否则可能写入别的租户的行。
正确示例:
CREATE POLICY tenant_rw ON documents
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id')::bigint)
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::bigint);2.4 选择建议
| 场景 | 建议隔离模型 |
|---|---|
| 早期产品、成千上万个小租户 | Shared DB + RLS |
| 需要租户级备份/恢复、合规审计强 | Schema per Tenant |
| 大客户、按租户独立扩容 | DB per Tenant(结合 Shared for long-tail) |
| 监管行业(金融核心、医疗 PHI) | DB per Tenant + 独立 KMS |
很多成熟 SaaS(如 Salesforce、Shopify)采用”默认共享 + 大客户独立”的混合方式。
三、租户内 RBAC:完整 Schema 设计
租户内部采用 RBAC
已经是业界共识。难点不在模型本身,而在于如何与
tenant_id
正交建模,避免角色、权限污染到其他租户。
3.1 基础 Schema
CREATE TABLE tenants (
id BIGSERIAL PRIMARY KEY,
slug VARCHAR(64) UNIQUE NOT NULL,
name VARCHAR(256) NOT NULL
);
CREATE TABLE tenant_roles (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
name VARCHAR(64) NOT NULL,
is_system BOOLEAN DEFAULT false,
parent_role_id BIGINT REFERENCES tenant_roles(id),
UNIQUE(tenant_id, name)
);
CREATE TABLE tenant_permissions (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
resource VARCHAR(128) NOT NULL,
action VARCHAR(32) NOT NULL,
scope VARCHAR(32) DEFAULT 'tenant',
UNIQUE(tenant_id, resource, action, scope)
);
CREATE TABLE tenant_role_permissions (
role_id BIGINT NOT NULL REFERENCES tenant_roles(id),
permission_id BIGINT NOT NULL REFERENCES tenant_permissions(id),
PRIMARY KEY(role_id, permission_id)
);
CREATE TABLE user_tenant_memberships (
user_id BIGINT NOT NULL REFERENCES users(id),
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
role_id BIGINT NOT NULL REFERENCES tenant_roles(id),
joined_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY(user_id, tenant_id)
);
CREATE INDEX idx_memberships_tenant_user ON user_tenant_memberships(tenant_id, user_id);
CREATE INDEX idx_roles_tenant ON tenant_roles(tenant_id);
CREATE INDEX idx_perms_tenant_resource ON tenant_permissions(tenant_id, resource, action);关键点:
- 每张表都带
tenant_id:不依赖 JOIN 链条推导租户归属。 UNIQUE(tenant_id, name):允许两个租户都有一个叫admin的角色。is_system:区分平台内置角色(不允许删除、只能扩展)和租户自定义角色。parent_role_id:角色继承,developer继承viewer的所有权限。
3.2 系统角色 vs 自定义角色
系统角色是平台为所有租户预置的,租户不能删除、不能修改基础权限集。常见的系统角色:
-- 新租户创建时自动 seed
INSERT INTO tenant_roles (tenant_id, name, is_system) VALUES
(:tenant, 'owner', true),
(:tenant, 'admin', true),
(:tenant, 'member', true),
(:tenant, 'viewer', true),
(:tenant, 'guest', true);自定义角色是租户 admin 在 UI 里自助创建的,例如”审计员”、“外部顾问”、“支付负责人”。必须有额外约束:
- 不能命名为系统角色
- 权限集是 admin 自己权限的子集(边界检查)
- 不能比创建者自己权限更高
- 不能被自动授予
billing等平台敏感权限
3.3 权限查询语义
一个典型的检查查询:“用户 X 在租户 Z 是否拥有对
documents 的 write 权限?”
WITH RECURSIVE role_tree AS (
SELECT r.id, r.parent_role_id
FROM tenant_roles r
JOIN user_tenant_memberships m
ON m.role_id = r.id
WHERE m.user_id = :user_id
AND m.tenant_id = :tenant_id
UNION
SELECT r.id, r.parent_role_id
FROM tenant_roles r
JOIN role_tree rt ON rt.parent_role_id = r.id
)
SELECT EXISTS (
SELECT 1
FROM role_tree rt
JOIN tenant_role_permissions rp ON rp.role_id = rt.id
JOIN tenant_permissions p ON p.id = rp.permission_id
WHERE p.tenant_id = :tenant_id
AND p.resource = 'documents'
AND p.action = 'write'
) AS allowed;这类查询每次请求都要跑,是性能热点,常见优化手段:
- 权限位图缓存:登录时将用户在该租户下的所有 (resource, action) 合并成一个 bitmap,放进 session 或 Redis。
- PostgreSQL materialized
view:
user_effective_permissions物化视图,角色变更时触发刷新。 - JWT claim:将权限列表塞进 token,代价是失效延迟和 token 膨胀(见《JWT、JWS、JWE、JWKS 一次讲透》对 token 设计与失效边界的讨论)。
3.4 角色继承的坑
parent_role_id
看起来很优雅,但容易被用错:
- 环路:必须在 INSERT/UPDATE
时做递归检查,否则
WITH RECURSIVE会无限循环。 - 深度爆炸:继承链超过 5 层就难以调试,建议硬限制深度。
- 权限减法不存在:RBAC 没有”我是 developer,但禁止访问 billing”这种能力,必须通过拆分新角色解决。
3.5 团队模型
许多租户希望按团队(team / department)管理权限,例如”工程团队可以访问所有代码仓库”。这时需要扩展:
CREATE TABLE tenant_teams (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
name VARCHAR(128) NOT NULL,
UNIQUE(tenant_id, name)
);
CREATE TABLE team_memberships (
team_id BIGINT NOT NULL REFERENCES tenant_teams(id),
user_id BIGINT NOT NULL REFERENCES users(id),
PRIMARY KEY(team_id, user_id)
);
CREATE TABLE team_role_grants (
team_id BIGINT NOT NULL REFERENCES tenant_teams(id),
role_id BIGINT NOT NULL REFERENCES tenant_roles(id),
scope_resource VARCHAR(128),
scope_resource_id BIGINT,
PRIMARY KEY(team_id, role_id, scope_resource, scope_resource_id)
);这样可以表达”工程团队在项目 proj-7 上拥有 developer 角色”。进一步的对象级关系建模留给 ReBAC(见第四节和《权限模型:RBAC/ABAC/ReBAC》)。
四、租户间 ReBAC:跨组织协作
4.1 跨租户场景
纯粹的租户内部 RBAC 解决不了这些需求:
- Notion 上把一个页面分享给另一家公司的某个人(对方可能在 Notion 上有自己的工作区,也可能没有)
- GitHub 上给另一个组织的成员 read 权限访问私有仓库
- Slack Connect:两家公司共享一个频道
- 外部审计师在 30 天内只读访问某个项目
这些需求的共同特点是:资源属于租户 A,但需要把某种访问关系授予租户 B 的某个实体。RBAC 难以表达这种关系,ReBAC(Relationship-based Access Control,Google Zanzibar 流派)非常适合。
4.2 关系元组表达
借鉴 Zanzibar/OpenFGA 的
object#relation@subject 语法:
document:doc123#viewer@user:alice
document:doc123#viewer@tenant:acme#member
document:doc123#viewer@user:bob@external
document:doc123#editor@group:audit-firm#member
前两条说明:doc123 的 viewer 关系,可以是具体用户
alice,也可以是租户 acme 的任意 member。第三条用
@external
标记外部协作者(未注册或来自其他租户的独立身份)。第四条允许一整个外部群组继承
editor 角色。
4.3 存储示例
CREATE TABLE relation_tuples (
id BIGSERIAL PRIMARY KEY,
object_type VARCHAR(64) NOT NULL,
object_id VARCHAR(128) NOT NULL,
relation VARCHAR(64) NOT NULL,
subject_type VARCHAR(64) NOT NULL,
subject_id VARCHAR(256) NOT NULL,
subject_relation VARCHAR(64),
tenant_id BIGINT,
created_by BIGINT,
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ,
UNIQUE(object_type, object_id, relation, subject_type, subject_id, subject_relation)
);
CREATE INDEX idx_tuples_obj ON relation_tuples(object_type, object_id, relation);
CREATE INDEX idx_tuples_subject ON relation_tuples(subject_type, subject_id);tenant_id
字段记录资源归属租户,便于审计和按租户清理;expires_at
支持限时共享。
4.4 外部协作者模式
当外部人员在平台上没有账号时,通常有三种做法:
- 邮箱邀请 + 占位账号:创建一个
external类型的占位账户,通过邮箱链接登录(magic link),一次性或限时。 - Guest Workspace:给外部用户一个受限租户(Slack Connect、Figma 的 Viewer 账号),主账号在外部租户,关系元组跨租户引用。
- 匿名链接 + 口令:生成带签名 token 的只读 URL,不创建账户,适合文档分享(Google Docs 的”任何人拥有链接”)。
三种方式的选择取决于协作强度:偶尔查看用链接,长期协作用 guest 账户,跨组织团队用 Connect 模式。
4.5 鉴权路径统一
在落地时,一个资源请求的判定顺序建议是:
func CheckAccess(ctx Context, user User, action string, obj Object) bool {
// 1. 平台超管:独立入口走独立判定(见第五节)
if ctx.PlatformAdmin != nil {
return platformAuthz(ctx.PlatformAdmin, action, obj)
}
// 2. 租户隔离:用户必须在资源所属租户或被显式跨租户授权
if user.TenantID != obj.TenantID {
if !hasCrossTenantGrant(user, action, obj) {
return false
}
}
// 3. 租户内 RBAC:查询 user 在 obj.TenantID 下的角色与权限
if hasRBACPermission(user, obj.TenantID, obj.Type, action) {
return true
}
// 4. 资源级 ReBAC:检查 relation_tuples
return hasRelation(user, action, obj)
}这个顺序避免了”RBAC 放行但跨租户数据泄露”的灾难:租户边界先于角色检查。
五、超级管理员与特权访问设计
5.1 为什么超管不能复用租户 RBAC
把平台超管当成”特殊租户里的 owner”是常见错误,后果包括:
- 超管账户会被租户 admin 看到,成为社工目标
- 超管权限泄露会导致所有租户被入侵
- 超管操作的审计日志写在租户库里,超管自己可以删
- 超管的身份验证强度会跟着租户 SLA 走(例如客户没开 MFA)
正确做法:平台侧的身份体系与租户体系完全分离,独立登录页、独立 IdP、独立审计库、独立 MFA 策略。
5.2 支持账号的”进入客户租户”流程
当客户报障需要内部员工登录进去时,应该遵循:
- 客户发起授权(工单里点”允许支持访问” 24 小时)或合同预授权
- 支持工程师申请
access_grant,写明工单号、访问原因、时长 - 另一位值班经理审批
- 审批通过后生成一次性 SSO 链接,短时 token
- 在租户里带独立标记(横幅显示”Support session in
progress”),所有操作实时写入
privilege_access_log - 到期自动撤销
5.3 审计日志 Schema
CREATE TABLE privilege_access_log (
id BIGSERIAL PRIMARY KEY,
accessor_id BIGINT NOT NULL,
accessor_email VARCHAR(255),
target_tenant_id BIGINT,
access_reason TEXT NOT NULL,
access_start TIMESTAMPTZ NOT NULL,
access_end TIMESTAMPTZ,
approved_by BIGINT,
actions_taken JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_priv_log_tenant ON privilege_access_log(target_tenant_id, access_start);
CREATE INDEX idx_priv_log_accessor ON privilege_access_log(accessor_id, access_start);actions_taken 以 JSON
列表记录每一次读写操作,便于客户导出自己的”支持访问日志”。GDPR
与 SOC2 都会审查这个表。
5.4 Break-Glass 账户
“Break-Glass”(打破玻璃)账户用于极端情况:所有正常流程都不可用、但必须立刻干预。典型场景:全站故障、合规强制撤下内容、配合执法。设计要点:
- 账户凭据分片(Shamir’s Secret Sharing),至少两人凑齐才能登录
- 使用后强制轮换凭据
- 登录后立刻触发 PagerDuty 全体告警和高管邮件
- 所有会话录屏(terminal session recording)
- 数据库里单独一张
break_glass_events表,不可被超管删除(WORM / append-only)
5.5 平台层权限决策点
支持账户的权限模型不能太细,否则工程师在紧急时刻会被权限卡死;又不能太粗,否则合规过不了关。常见妥协:
- 预定义几种支持等级(L1 只读、L2 读写元数据、L3 读写业务数据、L4 直连 DB)
- 每种等级绑定最长会话时长(L1 8 小时、L4 30 分钟)
- 敏感操作(重置客户密码、导出全表)二次审批
六、行级权限、列级权限与字段掩码
6.1 行级权限(Row-Level Security)
RLS 的两种落地:
数据库层 RLS:前文已展示 PostgreSQL POLICY。优点是无法绕过,缺点是调试困难、ORM 不感知。适合强合规场景。
应用层 RLS:每个查询显式加 WHERE:
func ListDocuments(ctx Context) ([]Document, error) {
tenantID := ctx.User.TenantID
userID := ctx.User.ID
return db.Query(`
SELECT d.* FROM documents d
WHERE d.tenant_id = $1
AND (
d.owner_id = $2
OR EXISTS (
SELECT 1 FROM document_permissions dp
WHERE dp.document_id = d.id AND dp.user_id = $2
)
)
`, tenantID, userID)
}优点:清晰、可控;缺点:任何一个新接口忘记加 WHERE
就是漏洞。折中做法:DAO 层封装强制注入
tenant_id,并用 linter 检查裸 SQL。
6.2 列级权限
PostgreSQL 支持列级 GRANT:
REVOKE SELECT ON customers FROM app_reader;
GRANT SELECT (id, name, email) ON customers TO app_reader;
GRANT SELECT (id, name, email, phone, ssn) ON customers TO app_admin;同一个表,不同角色能看到的列不一样。缺点是粒度停在 DB 角色层面,无法按业务用户动态变化。
另一种做法是视图:为每类业务用户建一个视图,只暴露允许的列;应用连接到视图而非原表。
6.3 字段掩码
字段掩码在应用层按权限动态修改返回值。典型场景是客服查看客户,但看不到完整电话号和身份证号:
func MaskSensitiveFields(user *User, record *CustomerRecord) *CustomerRecord {
result := *record
if !user.HasPermission("view_pii") {
result.PhoneNumber = maskPhone(record.PhoneNumber)
result.Email = maskEmail(record.Email)
result.SSN = "***-**-****"
}
if !user.HasPermission("view_financials") {
result.BankAccount = ""
result.CreditScore = 0
}
return &result
}
func maskPhone(p string) string {
if len(p) < 7 { return "****" }
return p[:3] + "****" + p[len(p)-4:]
}
func maskEmail(e string) string {
at := strings.Index(e, "@")
if at <= 1 { return "***" + e[at:] }
return e[:1] + "***" + e[at:]
}注意事项:
- 掩码必须在最后输出阶段做:否则日志、监控、缓存里会留下未掩码副本。
- 掩码不可逆但要可审计:“用户 X 在 T 时刻查看了未掩码数据”必须可审计。
- 对搜索要格外小心:允许按未掩码字段搜索 = 等价于允许查看,这点经常被忽略。
6.4 组合模式示例
一个真实的医疗 SaaS 场景:
- 行级:医生只能看到自己科室病人(RLS
policy on
department_id) - 列级:护士看不到财务字段(DB 角色 GRANT)
- 字段掩码:实习生看 SSN 时掩成
***-**-1234(应用层) - 审计:任何一次”取消掩码”操作进
phi_access_log
三层叠加,任何一环失效都还有其他两环兜底——这就是纵深防御。
七、租户自助权限管理
7.1 自助是 B2B 的刚需
大型租户的管理员不会打工单给 SaaS 运营方申请”给王五加个自定义角色”。他们要求:
- 自己创建、修改、删除角色
- 自己定义团队、批量授权
- 自己配置 SSO、SCIM 用户自动同步
- 自己导出权限审计报告
SaaS 要做的是给 tenant admin 一个受限的 admin UI,让他们在自己的租户内自由操作。
7.2 权限边界约束
tenant admin 也必须被管:
不能比自己权限更高:一个普通 admin
不能创建一个带 billing.manage_subscription
的自定义角色。
func CreateCustomRole(actor User, tenantID int64, roleName string, perms []string) error {
actorPerms := GetEffectivePermissions(actor, tenantID)
for _, p := range perms {
if !contains(actorPerms, p) {
return ErrPermissionEscalation
}
}
if isSystemRoleName(roleName) {
return ErrReservedRoleName
}
if len(perms) == 0 {
return ErrEmptyRole
}
return tx.CreateRole(tenantID, roleName, perms)
}不能授出自己没有的资源访问:admin 如果没有访问项目 X,就不能把别人加到项目 X 上。
不能删除自己的 owner:租户必须至少保留一个 owner,否则账户会卡死(类似 AWS root account 的最后一个 admin 不能删)。
不能修改系统角色的核心权限:is_system=true
的角色只能扩展附加权限,不能删减。
7.3 Delegated Administration
更复杂的租户希望把权限管理下放到部门:
- HR admin 只能管 HR 团队的成员
- Engineering admin 只能管工程团队的项目和仓库权限
- Security admin 能跨团队授予/撤销 MFA 豁免
这叫 Scoped Admin / Delegated Admin。实现方式是在
user_tenant_memberships 基础上加 scope:
CREATE TABLE delegated_admin_scopes (
user_id BIGINT NOT NULL REFERENCES users(id),
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
scope_type VARCHAR(32) NOT NULL,
scope_id BIGINT NOT NULL,
admin_role_id BIGINT NOT NULL REFERENCES tenant_roles(id),
PRIMARY KEY(user_id, tenant_id, scope_type, scope_id)
);scope_type 可以是
team、project、business_unit。鉴权时必须同时检查
admin 角色和 scope。
7.4 自助配置审计
所有租户自助操作都应该留痕,提供给租户 admin 自己查询:
CREATE TABLE tenant_audit_log (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
actor_id BIGINT NOT NULL,
action VARCHAR(64) NOT NULL,
target_type VARCHAR(64),
target_id VARCHAR(128),
diff JSONB,
ip INET,
user_agent TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_audit_tenant_time ON tenant_audit_log(tenant_id, created_at DESC);租户 admin 可以看到”张三在昨天 14:23 给李四加了 billing 角色”,满足 SOC2 审计要求。
八、真实案例速览
8.1 GitHub
GitHub 的权限模型是教科书级别的多层结构:
- Organization 角色:Owner(完全控制)、Member(基础成员)、Outside Collaborator(非成员但可访问特定仓库)
- Team:嵌套团队,团队可以整体被授予仓库权限
- Repository 角色:Admin、Maintain、Write、Triage、Read,五档细粒度
- Branch Protection & CODEOWNERS:资源级的策略叠加
继承路径:Team 的 repo 权限传递给成员,Team 嵌套时子 team 继承父 team 的权限。每一层都可以单独写审计日志。
8.2 Slack
Slack 的设计侧重”频道是资源,账号是协作者”:
- Workspace 角色:Primary Owner、Owner、Admin、Member
- Channel 权限:公有/私有/共享频道,频道内有 moderator、member 两级
- Guest:Single-Channel Guest(只能进一个频道)与 Multi-Channel Guest(可进多个),计费单独
- Slack Connect:跨工作区频道,每个工作区保留自己的成员控制,消息走联邦
8.3 Notion
Notion 的特色是资源树继承:
- Workspace 级:Owner、Member、Guest
- Page 级:Full Access、Can Edit、Can Comment、Can View
- 继承:子页面默认继承父页面权限,可被显式覆盖
- Share to web:一键生成公开 URL(带是否允许 index、是否允许 duplicate 等开关)
三者的共同点是:都把平台层(billing、workspace 创建)、租户层(成员管理)、资源层(具体项目/频道/页面)分得很清楚,并且都提供了 Guest 机制用于跨组织协作。
九、工程坑点
9.1 忘记 tenant_id = 跨租户数据泄露
这是 B2B SaaS 最常见也最危险的 bug,属于 OWASP API3:2023 BOLA/BOPLA 的典型变种。缓解手段:
- ORM
层统一注入:
WithTenant(ctx).Find(...) - DAO 基类强制校验
- 数据库层 RLS 兜底
- 集成测试用两个租户的假数据,断言 A 看不到 B 的
9.2 超管无审计的”上帝模式”
许多早期 SaaS
的超管登录后和普通账号走同一套代码,没有标记、没有额外日志。当客户发现自己的数据被平台员工看过时(这在
Twitter/Uber
都发生过),没有日志就等于默认有罪。解决方案见第五节的
privilege_access_log。
9.3 自定义角色越权
常见反模式:租户 admin 可以把任意权限组合到一个自定义角色里,包括平台内部权限或 billing 权限。必须在后端做白名单校验,不要相信前端下拉框。
9.4 N+1 权限检查
列表接口返回 100 条文档,每条都调一次
CheckAccess(user, doc),每次又查一次角色表和关系表。优化:
- 批量 API:
CheckAccessBatch(user, []objects)一次 DB round-trip - 结果缓存:同一个请求内同一个 (user, resource) 缓存
- 预计算:列表查询时直接在 SQL 里 JOIN 权限过滤
- 见《策略引擎落地》中 OPA/Cedar 的 partial evaluation
9.5 RLS 性能
tenant_id 如果没建索引,RLS policy
会引发全表扫。关键原则:
- 所有租户数据表的主索引前缀都应该是
tenant_id - 热点查询建
(tenant_id, <其他列>)复合索引 - 监控 PostgreSQL 的
pg_stat_statements,定位 RLS 导致的慢查询
9.6 角色变更的缓存失效
用户角色缓存进 Redis 或 JWT 后,当管理员撤销权限时,如果没有同步失效机制,被撤销的用户可以继续作恶数分钟。解决:
- Redis 缓存:撤销时立刻 DEL
- JWT:短 TTL(5–15 分钟)+ revocation list(基于 jti)
- 敏感操作(删除数据、导出数据)强制刷新权限而不是读缓存
9.7 SCIM 同步的静默覆盖
许多租户用 Okta/Azure AD 同步用户和组。SCIM 默认是全量覆盖模式,如果上游配错,可能一夜之间把所有 admin 降级为 member。防御:
- SCIM 变更走审计日志
- 发现”最后一个 owner 要被删”时拒绝并告警
- 提供 dry-run / preview 模式
9.8 跨租户共享的吊销
把文档共享给外部公司 X 后,如果没有完善的吊销界面(只能删除整个文档),管理员会忘记清理过期共享。建议:
- 所有跨租户共享都有
expires_at,UI 默认填 30/60/90 天 - 定期邮件汇报”谁分享了什么给外部”
- 离职员工创建的共享在账户禁用时一并撤销
十、选型建议
10.1 隔离模型 × 授权方案决策
| 客户规模 | 合规要求 | 建议隔离 | 建议授权 | 备注 |
|---|---|---|---|---|
| 长尾小客户(<50 人) | 一般 SaaS | Shared DB + RLS | RBAC + 少量 ReBAC | 最低成本,扩展最容易 |
| 中型客户(50–5000 人) | SOC2 | Shared DB + RLS | RBAC + ReBAC + 字段掩码 | 支持自定义角色与团队 |
| 大型企业(>5000 人) | SOC2 + ISO27001 | Schema per Tenant 或 DB per Tenant | RBAC + ReBAC + 列级权限 | 可能需要独立部署 |
| 金融核心 / 医疗 PHI | HIPAA / PCI-DSS | DB per Tenant + 独立 KMS | 全量审计 + 字段掩码 + break-glass | 往往要求本地化部署 |
10.2 什么时候引入策略引擎
- 规则数量少(<50)且不频繁变:硬编码 + 数据库表即可
- 规则数量多或跨团队定义:引入 OPA/Cedar,见《OPA、Cedar 与策略引擎》
- 资源关系网状且深度嵌套(文档嵌套、项目嵌套):引入 Zanzibar 类 ReBAC(OpenFGA/SpiceDB)
10.3 什么时候要走独立平台身份域
达成以下任一即应分离:
- 员工超过 20 人且有支持团队
- 准备进入 SOC2 Type II 审计
- 有合规客户要求”客户数据平台员工默认无法访问”
- 计划对接 Privileged Access Management(PAM,如 Teleport / HashiCorp Boundary)
10.4 与周边模块的关系
- 认证与 token:见《企业单点登录:OIDC
与现代 SSO》与《JWT、JWS、JWE、JWKS
一次讲透》,JWT 里放
tenant_id还是通过独立查询解析是永恒话题 - 权限模型本身的选择:见《RBAC、ABAC、ReBAC:权限模型怎么选》
- 策略评估引擎选型:见《OPA、Cedar 与策略引擎落地》
- 网关层执行点:见《API Gateway 与 BFF 边界安全》
- BOLA 等 API 安全问题:见《API 安全设计》
十一、参考资料
- Google Zanzibar 论文,Zanzibar: Google’s Consistent, Global Authorization System, USENIX ATC 2019
- PostgreSQL 官方文档:Row Security Policies
- OWASP API Security Top 10 (2023):API1 BOLA / API3 BOPLA
- GitHub Docs: Roles in an organization, Repository roles, Team permissions
- Slack: Types of roles in Slack, Slack Connect administration
- Notion: Permissions overview, Sharing & permissions
- AWS Well-Architected Framework: SaaS Tenant Isolation Strategies whitepaper
- Microsoft Azure Architecture Center: Multi-tenant SaaS solutions
- Auth0: B2B SaaS Authorization Patterns
- OpenFGA 与 SpiceDB 文档:跨租户 ReBAC 最佳实践
- SOC 2 Common Criteria CC6:Logical and Physical Access Controls
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【身份与访问控制工程】RBAC、ABAC、ReBAC:权限模型怎么选
从角色爆炸问题切入,深入对比 RBAC(NIST 四级模型)、ABAC(XACML)、ReBAC(Zanzibar 启发)三种权限模型的数据结构、SQL 建模、策略表达能力与工程取舍,给出混合模型实践与选型决策树。
【身份与访问控制工程】CIAM 架构:面向 B2B / B2C SaaS 的身份平台
系统梳理 CIAM(Customer Identity and Access Management)的场景差异、数据模型、隐私合规与工程坑点,覆盖 B2C 社交登录、B2B 企业 SSO/SCIM、B2B2C 组织模型,以及全球多区域部署与选型建议。
【身份与访问控制工程】OAuth 2.1 与 PKCE:现代授权主路径
从一次 SPA 安全事故出发,系统梳理 OAuth 2.1 相对 2.0 的收敛动作、PKCE 的密码学原理、授权码流程的完整参数细节,以及 DPoP、PAR、JAR、RAR 等现代扩展与常见攻击面
【身份与访问控制工程】Zanzibar 风格权限系统
深入解析 Google Zanzibar 论文(USENIX ATC 2019)的核心设计:Relation Tuple、Namespace、Userset Rewrite,一致性模型(Zookies)与开源实现 SpiceDB、OpenFGA、Ory Keto 的工程对比,以及适用与不适用场景。