土法炼钢兴趣小组的算法知识备份

【身份与访问控制工程】B2B SaaS 多租户权限设计

文章导航

分类入口
architecturesecurity
标签入口
#multi-tenant#authorization#RBAC#SaaS#B2B#row-level-security

目录

B2C 产品的权限模型通常很简单:一个用户,一组角色,一堆资源。B2B SaaS 不是这样。每个企业客户(租户)是一个独立的组织,有自己的员工、自己的角色体系、自己的审计要求;他们既不希望看到别的租户的数据,也不希望平台方随意查看自己的数据;同时还要能把某份文档分享给一家合作公司的某个人,或者邀请外部审计师只读访问三天。平台运营方还要有超级管理员能在客户报障时登录进去排查——而且必须留下审计痕迹,不能被客户起诉。

这些需求叠加在一起,权限模型就变成了一个多层嵌套的怪兽。本文从租户隔离模型讲起,给出租户内 RBAC + 租户间 ReBAC 的混合方案,讨论超级管理员、行级权限、字段掩码、租户自助管理等工程细节,最后对比 GitHub、Slack、Notion 的真实做法。

租户隔离模型

一、B2B SaaS 的权限三层结构

理解多租户权限,第一步是认清它不是一层而是三层。把三层混在一起设计,是 90% 的 B2B SaaS 权限事故的根源。

1.1 三层权限的职责

平台层(Platform Layer):SaaS 运营方自己的员工。包括:

租户层(Tenant Layer):每个客户企业内部的员工。每个租户有:

资源层(Resource Layer):具体的业务数据。例如:

1.2 三层混淆的典型灾难

正确的做法是三层分开建模,每层有自己的表、自己的缓存、自己的审计流,然后通过显式的接入点连接。

1.3 本文采用的层次划分

层级 授权模型 主表 隔离手段
平台层 白名单 + 审批 platform_adminsprivilege_access_log 独立身份域、独立登录入口
租户层 RBAC(内置 + 自定义角色) tenant_rolesuser_tenant_memberships tenant_id + RLS
资源层 RBAC 粗粒度 + ReBAC 细粒度 document_permissionsrelation_tuples 行级 + 列级 + 字段掩码

二、租户隔离模型与授权的关系

授权方案不能脱离数据隔离方案单独设计。你选择怎么把租户的数据放在数据库里,直接决定了你的权限检查代码长什么样。

2.1 三种主流隔离模型

Shared DB, Shared Schema(共享库共享表):所有租户的数据混在同一张表,用 tenant_id 列区分。

Shared DB, Schema per Tenant(共享库独立 schema):一个 PostgreSQL 数据库,每个租户一个 schema。

DB per Tenant(一租户一库):每个租户一个独立的数据库实例或独立文件。

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 的陷阱

正确示例:

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

关键点:

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 里自助创建的,例如”审计员”、“外部顾问”、“支付负责人”。必须有额外约束:

3.3 权限查询语义

一个典型的检查查询:“用户 X 在租户 Z 是否拥有对 documentswrite 权限?”

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;

这类查询每次请求都要跑,是性能热点,常见优化手段:

3.4 角色继承的坑

parent_role_id 看起来很优雅,但容易被用错:

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 解决不了这些需求:

这些需求的共同特点是:资源属于租户 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 外部协作者模式

当外部人员在平台上没有账号时,通常有三种做法:

  1. 邮箱邀请 + 占位账号:创建一个 external 类型的占位账户,通过邮箱链接登录(magic link),一次性或限时。
  2. Guest Workspace:给外部用户一个受限租户(Slack Connect、Figma 的 Viewer 账号),主账号在外部租户,关系元组跨租户引用。
  3. 匿名链接 + 口令:生成带签名 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”是常见错误,后果包括:

正确做法:平台侧的身份体系与租户体系完全分离,独立登录页、独立 IdP、独立审计库、独立 MFA 策略。

5.2 支持账号的”进入客户租户”流程

当客户报障需要内部员工登录进去时,应该遵循:

  1. 客户发起授权(工单里点”允许支持访问” 24 小时)或合同预授权
  2. 支持工程师申请 access_grant,写明工单号、访问原因、时长
  3. 另一位值班经理审批
  4. 审批通过后生成一次性 SSO 链接,短时 token
  5. 在租户里带独立标记(横幅显示”Support session in progress”),所有操作实时写入 privilege_access_log
  6. 到期自动撤销

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”(打破玻璃)账户用于极端情况:所有正常流程都不可用、但必须立刻干预。典型场景:全站故障、合规强制撤下内容、配合执法。设计要点:

5.5 平台层权限决策点

支持账户的权限模型不能太细,否则工程师在紧急时刻会被权限卡死;又不能太粗,否则合规过不了关。常见妥协:

六、行级权限、列级权限与字段掩码

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:]
}

注意事项:

6.4 组合模式示例

一个真实的医疗 SaaS 场景:

三层叠加,任何一环失效都还有其他两环兜底——这就是纵深防御。

七、租户自助权限管理

7.1 自助是 B2B 的刚需

大型租户的管理员不会打工单给 SaaS 运营方申请”给王五加个自定义角色”。他们要求:

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

更复杂的租户希望把权限管理下放到部门:

这叫 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 可以是 teamprojectbusiness_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 的权限模型是教科书级别的多层结构:

继承路径:Team 的 repo 权限传递给成员,Team 嵌套时子 team 继承父 team 的权限。每一层都可以单独写审计日志。

8.2 Slack

Slack 的设计侧重”频道是资源,账号是协作者”:

8.3 Notion

Notion 的特色是资源树继承

三者的共同点是:都把平台层(billing、workspace 创建)、租户层(成员管理)、资源层(具体项目/频道/页面)分得很清楚,并且都提供了 Guest 机制用于跨组织协作。

九、工程坑点

9.1 忘记 tenant_id = 跨租户数据泄露

这是 B2B SaaS 最常见也最危险的 bug,属于 OWASP API3:2023 BOLA/BOPLA 的典型变种。缓解手段:

9.2 超管无审计的”上帝模式”

许多早期 SaaS 的超管登录后和普通账号走同一套代码,没有标记、没有额外日志。当客户发现自己的数据被平台员工看过时(这在 Twitter/Uber 都发生过),没有日志就等于默认有罪。解决方案见第五节的 privilege_access_log

9.3 自定义角色越权

常见反模式:租户 admin 可以把任意权限组合到一个自定义角色里,包括平台内部权限或 billing 权限。必须在后端做白名单校验,不要相信前端下拉框。

9.4 N+1 权限检查

列表接口返回 100 条文档,每条都调一次 CheckAccess(user, doc),每次又查一次角色表和关系表。优化:

9.5 RLS 性能

tenant_id 如果没建索引,RLS policy 会引发全表扫。关键原则:

9.6 角色变更的缓存失效

用户角色缓存进 Redis 或 JWT 后,当管理员撤销权限时,如果没有同步失效机制,被撤销的用户可以继续作恶数分钟。解决:

9.7 SCIM 同步的静默覆盖

许多租户用 Okta/Azure AD 同步用户和组。SCIM 默认是全量覆盖模式,如果上游配错,可能一夜之间把所有 admin 降级为 member。防御:

9.8 跨租户共享的吊销

把文档共享给外部公司 X 后,如果没有完善的吊销界面(只能删除整个文档),管理员会忘记清理过期共享。建议:

十、选型建议

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 什么时候引入策略引擎

10.3 什么时候要走独立平台身份域

达成以下任一即应分离:

10.4 与周边模块的关系

十一、参考资料


上一篇OPA、Cedar 与策略引擎落地

下一篇API Gateway、BFF 与边界认证授权

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-21 · architecture / security

【身份与访问控制工程】Zanzibar 风格权限系统

深入解析 Google Zanzibar 论文(USENIX ATC 2019)的核心设计:Relation Tuple、Namespace、Userset Rewrite,一致性模型(Zookies)与开源实现 SpiceDB、OpenFGA、Ory Keto 的工程对比,以及适用与不适用场景。


By .