返回部落格
技術乾貨

FastGPT 权限系统设计与演进

引言

在企业级多租户与团队协作场景下,FastGPT 除了提供 AI Agent 编排、可视化工作流与知识库检索等业务能力外,核心资产(应用、知识库、技能等)还必须具备严密的多人协同与权限管控机制。

随着用户规模扩大、组织架构复杂化以及资源树层级的加深,权限系统在工程上面临越来越严峻的性能瓶颈:既要保证多租户团队间的严格隔离,又要在极度不对称的高频读取下维持毫秒级响应,尤其是解决“列出当前用户可见全部资源”这一高频反向查询。

笔者负责了 FastGPT 权限系统的整体架构构建与数次关键重构,本文将从核心抽象、技术选型演进、索引与位运算优化、反向查询攻关,以及与主流竞品的横向对比五个维度,复盘这套权限系统的设计决策与演进过程。


1. 第一性原理:实体与关系的抽象

权限控制无论业务表象如何复杂,底层都可以抽象为如下的三元组:

(Subject,Resource,Action)(Subject, Resource, Action)

在 FastGPT 的具体业务语境下,三元组对应为:

(协作者,资源,权限值)(\text{协作者}, \text{资源}, \text{权限值})

该模型包含两个隐含约束:

  1. 租户绝对隔离:主体与资源必须归属于同一个团队(teamId),跨团队的权限三元组在业务与物理层面上均不合法。

  2. 记录唯一性:同一主体对同一资源只允许存在单条权限记录。

在 FastGPT 的多租户体系中,主体(Subject)的直接操作者是团队成员。但在多层级协作演进中,主体逐步扩展为三类:成员(tmbId)、群组(groupId)和组织架构节点(orgId)。

资源(Resource)涵盖应用(app)、知识库(dataset)、模型(model)和 Agent 技能(agentSkill),由 resourceType 标识(取值为 teamappdatasetmodelagentSkill)。这五类资源的底层形态存在本质分歧:

  • 树状继承型资源appdataset 具备 parentId,在文件夹层级中存在沿树继承关系;

  • 非稳定标量资源model 缺乏稳定的数据库 ObjectId,只能依赖全局配置中的 resourceName 兜底定位;

  • 平面独立型资源agentSkill 独立存在,无需层级推导。

为了在一张表中抹平这种形态差异,底层 ACL 表必须同时保留 resourceIdresourceName 两个可选的资源定位标识。


2. 技术选型:混合权限模型

业界权限管理常见有以下几种范式:

  • ACL(Access Control List):直接维护主体到资源的访问控制列表,直观且便于单点命中,但缺乏抽象分层。

  • RBAC(Role-Based Access Control):通过“主体 \to 角色 \to 权限”两级映射解耦,鉴权只需比对角色,但多对多关联会导致多表连接开销。

  • ABAC(Attribute-Based Access Control):基于主体、资源、环境等属性进行动态策略求值,表达力最强,但策略解析与运行时求值性能损耗极高。

  • ReBAC(Relationship-Based Access Control):以 Google Zanzibar 为代表,基于全局关系图计算可达性,擅长表达深层级与组织派生权限。

需要指出,ReBAC 强大的图表达能力依赖于专用的分布式元组存储与图遍历引擎(如 SpiceDB、OpenFGA),其代价是极高的工程维护成本与图一致性管理开销。FastGPT 单团队内部的关系图仅有一棵以 parentId 为边的单向资源树,引入通用图引擎属于明显的架构过度设计。

权衡读写负载与工程复杂度后,FastGPT 选择了基于 ACL 存储、内嵌 RBAC 角色、融合属性过滤的混合权限模型

  • 存储层(ACL):直接以单条记录固化“主体 \to 资源”的绑定关系;

  • 语义层(RBAC):字段内存储的不是零散的布尔开关,而是由多个权限位按位或组合而成的 role 值;鉴权时通过静态常量映射 RolePerMap 将角色展开为位掩码,通过位运算判定;

  • 属性隔离(ABAC)teamIdresourceType 作为天然属性,在数据库层提供物理分区与强制切分。

该设计完全避免了传统 RBAC 的多表 JOIN 操作,同时省去了复杂的策略解释器。

ACL 数据表字段设计

  • Subject(主体)

    • 主体标识(三选一):tmbId(团队成员 ID)、groupId(用户群组 ID)、orgId(组织 ID)。

    • teamId:租户团队 ID,用于物理隔离。

  • Resource(资源)

    • resourceType:资源类型枚举。

    • resourceId:资源的 Mongo ObjectId

    • resourceName:针对无 ObjectId 资源的字符标识。

  • Action(权限)

    • permissionnumber 类型,基于二进制位存储复合角色。

该表有三个关键设计约束:

  1. 类型层面的排他性校验:通过 TypeScript 工具类型保证主体三选一的单一性,配合 Mongoose 的 pre('save') 钩子防御非法写入:
export type CollaboratorIdType = RequireOnlyOne<{
  tmbId: string;
  groupId: string;
  orgId: string;
}>;
  1. 存角色、查权限permission 存储紧凑的 role 编码,读取鉴权时经由 RolePerMap 解构为权限位。这种隔离确保新增细粒度权限位时无需批量刷改存量数据的 role

  2. 双轨资源索引resourceIdresourceName 平行并存,分别维护独立的索引拓扑,杜绝混合字段引起的类型歧义。

系统整体架构的核心原则非常明确:ACL 表的读写比高度不对称(读远大于写),设计重心完全倒向读优化。系统可以容忍写路径上的适度放大,但必须保证读鉴权与反向查询能够通过索引直接命中。


3. 架构演进:从单表 ACL 到 ReBAC 再到物化 ACL

阶段 1: 简单 ACL 表 (点对点直存)


阶段 2: 群组与组织架构支持 (三主体 + 组织继承推导)


阶段 3: ReBAC 动态继承 (读时沿 parentId 递归合并,遭遇反向查询瓶颈)


阶段 4: 物化 ACL 快照 (写时 BFS 传播物化,读路径重回 O(1) 索引命中)

阶段 1:简单单表 ACL

在最初阶段,系统仅有团队层级的粗粒度划分,对应 PR #1522(2024-05-17)与正式落地应用权限的 PR #1687(2024-06-04,+2290 / −1090)。

此时集合名为 resource_permission(单数),单条记录包含五个核心字段:

resourceTypeteamIdtmbIdresourceIdpermission

权限位借鉴 Linux 模式,采用位累加设计:

export const PermissionList = {
  read: 0b100,
  write: 0b110, // 写必然包含读
  manage: 0b111 // 管理必然包含写和读
};

判定逻辑基于按位与运算:(value & target) === target。资源拥有者(Owner)被分配全 1 掩码(~0 >>> 0,即 0xFFFFFFFF),天然通过所有位校验。

索引仅维护 (resourceType, teamId, tmbId, resourceId) 的复合唯一索引。

阶段 1 解决了权限的基础分配,但随着团队人数增加,暴露出三个致命缺陷:

  1. 点对点授权膨胀:50 人的团队共享应用必须显式插入 50 条记录,新入职员工需逐个资源重写 ACL。

  2. 无团队默认权限载体:缺乏表达“全员默认可读”的实体概念。

  3. 资源孤岛化:目录与子应用相互独立,父级文件夹权限无法沿树形层级流动。

阶段 2:引入群组与组织架构

阶段 2 扩充了主体维度,对应 PR #2864(2024-10-09,+2663 / −744)引入成员组、PR #2993 补充组角色,以及 PR #3565(2025-01-11)引入组织架构树。

集合更名为 resource_permissions(复数),主体扩展为三选一字段:

tmbId:   { type: Schema.Types.ObjectId, ref: TeamMemberCollectionName },
groupId: { type: Schema.Types.ObjectId, ref: MemberGroupCollectionName },
orgId:   { type: Schema.Types.ObjectId, ref: OrgCollectionName }

在此阶段,系统首次引入了轻量关系推导:getOrgIdSetWithParentByTmbId 函数在鉴权时递归拉取成员所属部门及其全部上级组织,实现部门权限向子部门的自动下发。

getTmbPermission 确立了鉴权求值的严格顺序:

  1. 个人 ACL 绝对优先:优先查找 tmbId 记录。一旦存在(哪怕值为 0 显式剥夺权限),立即返回,严禁被上层群组或组织权限覆盖。

  2. 群组与组织权限按位合并:若无个人记录,则并行拉取所属全部 group 及祖先 org 的 ACL 记录,使用 sumPer 进行按位并集(Bitwise OR)运算。

此外,系统通过内置的 teamDefaultGroup 承载团队级默认权限,一条记录即可全员生效。其代价是读放大初显:单次鉴权需要组合解析组织树并执行多次数据库读取。

阶段 3:ReBAC 动态继承

对应 PR #2151(2024-07-25,+480 / −198),知识库引入 inheritPermission 开关与 parentId 字段,构建了单租户内的资源树。

向前兼容策略通过 shouldInheritResourcePermission 将空值(undefined)默认解析为 true

export const shouldInheritResourcePermission = (inheritPermission?: boolean) =>
  inheritPermission !== false;

该阶段遵循典型的 ReBAC 动态推导思路——权限不写入子资源,鉴权时动态沿父级链递归求值(见原 app/auth.ts):

const [folderPer = NullRoleVal, myPer = NullRoleVal] = await Promise.all([
  app.inheritPermission && app.parentId
    ? getTmbPermission({ resourceId: app.parentId, ... })
    : NullRoleVal,
  getTmbPermission({ resourceId: app._id, ... })
]);

优点:父目录变更权限时,整棵子树实时生效,零写放大。

弊端

  1. 列表读放大失控:列表页分页加载 NN 个应用,需要触发 NN 次自身查询加 NN 次父链查询,无法实施批量索引优化。

  2. 推导断链:当父级资源被删除或权限清空时,子资源无法溯源哪些权限是继承所得、哪些是自身赋予,只能依赖脆弱的 checkRoleUpdateConflict 启发式推断。

  3. 无法表达独立剥离(Detach):继承与自身权限在内存中混为一谈,业务层无法精准实现“取消继承并保留当前权限快照”。

阶段 4:回归物化 ACL

PR #7560(2026-08-27,+5714 / −957)执行了关键的架构重构:refactor(permission): materialize resource permissions——权限物化

该方案将动态继承的关系求值计算前置到写路径。应用、知识库与 Agent 技能在 resource_permissions 中持久化存储各自完整的有效权限快照(包含沿树继承得到的结果)。读鉴权重新缩减为单次索引查询。

写路径由 syncResourceTreePermissions 执行树状传播算法:

  1. 增量协作者分析:提取父级变更前后的快照差异,仅收集权限发生真实变更的 affectedCollaborators,排除无效扩散。

  2. 广度优先(BFS Frontier)按层遍历:仅迭代继承子树的直接子节点,避免大租户下全量资源载入内存引发 OOM。

  3. 位运算剥离与重新组合

// Owner 是资源自身角色,即使同一协作者曾从旧父级继承 manage,也要完整保留。
const childExtra =
  child?.permission === OwnerRoleVal
    ? OwnerRoleVal
    : child
      ? (child.permission & ~oldParent) >>> 0
      : 0;
const permission = sumPer(newParent, childExtra) ?? 0;

通过 child.permission & ~oldParent 精确剔除来自旧父级的过期权限位,同时保留子资源局部单独分配的权限位,最后与 newParent 权限位取或。

  1. 所有权降级:向下传播时,父级 Owner 在子资源上严格降级为 manage,维持每个独立资源唯一明确的 Owner:
export const toInheritedCollaborators = (collaborators: CollaboratorItemType[]) =>
  collaborators.map((collaborator) => ({
    ...toPermissionCollaborator(collaborator),
    permission: collaborator.permission === OwnerRoleVal ? ManageRoleVal : collaborator.permission
  }));
  1. 差异原子提交:计算每个子节点的增量差异(insert / update / delete),使用 bulkWrite 批量提交。

配套的代码架构拆解为三层规范:

  • repository:专注底层数据读写与索引适配;

  • policy:封装权限位运算、继承推导与权限矩阵计算;

  • service:编排业务上下文与事务流程。

阶段 4 接受了一定的写放大代价,并配套开发了 V4.16.2 迁移接口 /api/admin/4162/initPermission(具备 dry-run、悬空数据清理与幂等执行能力)。在 2025-09-25 的 PR #5703 引入 model 资源及 resourceName 标识后,配合新加入的 agentSkill,五种资源类型彻底统合进单一物化表中。


4. 查询优化:复合索引与位运算谓词

索引拓扑

针对海量数据的高并发访问,系统在 Mongoose Schema 上定义了 15 个复合索引,统一以 (resourceType, teamId) 作为物理分区前缀:

  1. 正向唯一索引(6 条):针对 resourceIdresourceName,分别对 tmbIdgroupIdorgId 建立 3 组唯一复合索引。

  2. 反向查询前缀索引(6 条):将主体字段置于资源字段之前,分别建立 (..., tmbId, resourceId)(..., groupId, resourceId) 等索引。

  3. 资源全量检索辅助索引(3 条):用于级联删除或按资源清空所有协作者等场景。

三大设计要点:

  • 稀疏字段唯一性保障(partialFilterExpression

由于主体三选一,文档中另外两个主体字段必然不存在。普通唯一索引会将其视为 null 导致冲突。必须显式限定索引仅覆盖字段存在的文档:

defineIndex(ResourcePermissionSchema, {
  key: { resourceType: 1, teamId: 1, resourceId: 1, tmbId: 1 },
  options: {
    unique: true,
    partialFilterExpression: {
      tmbId: { $exists: true },
      resourceId: { $exists: true }
    }
  }
});
  • 双轨资源独立建索resourceIdresourceName 严格平行,各自拥有独立的一组正向与反向索引,避免由于类型多态破坏索引效率。

  • 前缀调优支撑反向覆盖:反向索引特意调整为 (resourceType, teamId, tmbId, resourceId),主体字段前置,列表分页查询能直接利用索引前缀完成过滤,避免回表排序。

数据库层位运算谓词

为避免应用层全量拉取数据做权限位比对,查询通过位运算操作符下推至数据库执行。

由于表中存储的是 role 值而非散装权限位,查询前需通过 getRoleMasks 将目标权限位反解为包含该权限的所有可用 role 的复合掩码:

const getRoleMasks = () => {
  const permissionBits = getPermissionBits();
  return permissionBits.map((permissionBit) =>
    Array.from(rolePerMap.entries()).reduce(
      (mask, [role, rolePermission]) =>
        (rolePermission & permissionBit) === permissionBit ? mask | role : mask,
      0
    )
  );
};

随后选用最优操作符:

const getRolePermissionFilter = (roleMask: number) => {
  const isSingleRole = (roleMask & (roleMask - 1)) === 0;
  return isSingleRole ? { $bitsAllSet: roleMask } : { $bitsAnySet: roleMask };
};
  • 若计算出的掩码仅有一位为 1(经典位技巧 (m & (m - 1)) === 0),使用 $bitsAllSet

  • 若存在多个可能匹配的角色位,使用 $bitsAnySet

最终配合 distinct 仅抓取资源 ID 数组:

const resourceKeys = await MongoResourcePermission.distinct(resourceKey, {
  ...baseQuery,
  $or: collaboratorFilters,
  permission: { $bitsAnySet: roleMask }
});

一次网络 I/O 即可返回当前主体具有访问权限的资源标识列表,可直接拼装入主业务查询的 $in 条件中。


5. 核心攻关:反向查询难题

在权限系统中,正向查询(“某资源有哪些协作者”)天然契合数据组织;而**反向查询(“当前用户在当前团队能看到哪些应用 / 知识库”)**则是列表拉取、全局检索、资源级联引用的核心高频入口。

动态 ReBAC 在反向查询下的复杂度坍塌

在阶段 3 的动态推导模式下,用户 MM 能否访问资源 RR,是一个动态依赖于树形路径上所有祖先节点 ACL 的未物化计算值。

列出成员 MM 可见的所有资源列表需要执行:

  1. 内存解析 MM 的全部群组以及组织树祖先集合;

  2. 扫描该团队下全部应用;

  3. 对每个应用沿 parentId 向上回溯至根节点,计算最终权限位;

  4. 过滤输出有效节点。

时间复杂度高达 O(资源数×树深)\mathcal{O}(\text{资源数} \times \text{树深})。由于数据库中不存在“最终可见性”这个字段,前两步完全无法建立有效索引,在包含数千个资源的企业级大团队中,列表接口极易出现数十秒甚至超时崩溃。

物化后的单表常数级反查

阶段 4 完成物化后,可见性成为直接存储在目标资源上的静态快照。反向查询转化为单表索引命中:

db.resource_permissions.distinct('resourceId', {
  teamId,
  resourceType: 'app',
  resourceId: { $exists: true },
  $or: [
    { tmbId },
    { groupId: { $in: groupIds } },
    { orgId: { $in: orgIds } }
  ],
  permission: { $bitsAnySet: roleMask }
});

原本复杂的递归树图遍历被压平为带有位运算过滤的单表索引检索。但在落地该查询时,必须处理三项边界逻辑:

细节一:OR 与 AND 的求值差异

  • OR 语义(具备任意权限即可,如读或写):将各权限位对应的角色掩码按位或合并后,单次查询结合 $bitsAnySet 即可返回。

  • AND 语义(必须同时具备全部指定权限):不能简单合并掩码(合并会导致条件变宽松)。必须对每个权限位独立发起查询,并在内存中进行集合求交:

const permissionSets = await Promise.all(
  roleMasks.map((roleMask) =>
    findResourceKeys({
      collaborators,
      permissionFilter: { permission: getRolePermissionFilter(roleMask) }
    })
  )
);
return intersectSets(permissionSets);

这是因为各权限位由不同的 role 覆盖,数据库底层的位运算符无法单次表达这种存在量词的复合交叉。

细节二:个人优先级的差集还原

业务规则明确规定:个人的显式 ACL 优先级高于群组和组织。若某用户被直接赋予某应用 0 权限(显式禁止),即使其所属群组拥有该应用读权限,该应用也不能被查出。

正向查单点鉴权容易短路跳出,但在批量反向查询中,必须使用集合差集(Difference)严格还原此语义:

const [personalResourceKeys, personalMatchedResourceKeys, groupAndOrgMatchedResourceKeys] =
  await Promise.all([
    findResourceKeys({ collaborators: personalCollaborators }),
    findMatchedResourceKeys(personalCollaborators),
    findMatchedResourceKeys(groupAndOrgCollaborators)
  ]);
 
return Array.from(
  new Set([
    ...personalMatchedResourceKeys,
    ...differenceSets(groupAndOrgMatchedResourceKeys, personalResourceKeys)
  ])
);

第一个查询检索出该用户存在直接个人记录的全部资源集合(不带权限过滤,即使权限为 0)。在合并群组与组织命中的资源时,显式剔除该集合,确保个人配置的排他性生效。

细节三:Owner 特殊全位掩码的防御拦截

Owner 权限值为 0xFFFFFFFF。如果将其直接传入 $bitsAnySet,全 1 掩码会匹配所有存在任意权限位的文档。因此,入口处实施了防御性硬拦截:

if (permission === OwnerPermissionVal) {
  throw new Error('Owner permission must be checked through owner authorization');
}

所有针对 Owner 的鉴权必须直连资源实体的 owner 关联比对,杜绝将其作为位掩码带入反向查询。


6. 横向对比与架构取舍

权限系统的本质是业务场景、读写负载与系统复杂度的权衡,不同系统在设计倾向上存在显著分野,FastGPT 的权限模型并不是唯一解。把它和几个主流方案放在一起对比,能更清楚地看出它做了哪些取舍。

方案对比矩阵

系统核心模型组织与继承存储与计算开销适用场景
FastGPT物化 ACL + 紧凑位运算支持组织树与群组,写时 BFS 物化继承读路径 O(1)\mathcal{O}(1) 索引单查;牺牲写性能承担物化放大私有化多租户、单团队内深层级资源协作、高频列表读取
Dify细粒度权限点 RBAC + 白名单扁平主体,无层级继承;企业版通过 RPC 托管角色权限点枚举判定清晰,但资源协作粒度较为扁平侧重工作流编排与应用级粗粒度共享,内部层级较浅
RAGFlow二值租户可见性判定无层级,无用户组极简判定,几乎无存储开销;无细粒度表现力单租户独立使用或轻量共享,团队内无需复杂授权
Google Zanzibar关系图 ReBAC (SpiceDB 等)关系图求值,天然支持任意维度图继承需维护独立分布式存储、图索引及缓存,架构极为庞大超大规模公有云、跨系统多维度实体复杂关联

Dify:权限点 RBAC 与白名单

Dify 采用基于“权限点(Scene)”的集中校验,并在 API 入口处配合装饰器管控:

class RBACPermission(StrEnum):
    APP_EDIT = "app_edit"
    APP_DELETE = "app_delete"
    APP_ACCESS_CONFIG = "app_access_config"
    DATASET_DELETE = "dataset_delete"
    WORKSPACE_MEMBER_MANAGE = "workspace_member_manage"
    ...

针对资源维度的访问控制,Dify 采用作用域白名单枚举:

class RBACResourceWhitelistScope(StrEnum):
    ALL = "all"            # 全体成员
    SPECIFIC = "specific"  # 指定成员
    ONLY_ME = "only_me"    # 仅我自己

Dify 的优势在于权限点定义极其详尽,但在资源实体这一侧,其共享关系主要是扁平白名单,没有在开源核心中内建复杂的组织树级联继承。

RAGFlow:极简二值可见性

RAGFlow 对知识库的判定极其简洁,核心仅维护 meteam 二值状态:

// HasKBTeamPermission mirrors Python check_kb_team_permission:
// direct owner access is always allowed; otherwise the KB must be team-shared
// and the caller must be a joined normal member of the owner tenant.
func HasKBTeamPermission(ctx, kb *entity.Knowledgebase, userID string, tenantDAO *dao.TenantDAO) bool {
        if kb.TenantID == userID {
                return true
        }
        if kb.Permission != string(entity.TenantPermissionTeam) {
                return false
        }
        joinedTenants, _ := tenantDAO.GetJoinedTenantsByUserID(ctx, dao.DB, userID)
        for _, tenant := range joinedTenants {
                if tenant.TenantID == kb.TenantID {
                        return true
                }
        }
        return false
}

将复杂的权限问题完全让渡给租户隔离,适合中小规模场景,但在大中型企业“市场部可编辑、技术部只读”的细粒度协同下力不从心。

Google Zanzibar:完整 ReBAC 范式

Zanzibar 将所有权限抽象为统一的关系元组:

document:1#viewer@user:2
document:1#editor@group:eng#member

通过图遍历引擎求取连通性,天然统一了文件夹继承、用户组及跨租户授权。但其前提是需要自建图存储、专用反向索引与缓存失效管道。

总结

架构选型本质上是负载特征驱动的权衡:

  • RAGFlow 舍弃权限表达力,换取极简的实现与近乎为零的计算成本;

  • Dify 聚焦权限点枚举,简化资源关系,由统一角色层兜底;

  • FastGPT 则立足于私有化与深度团队协作场景,单租户内资源规模持续增长且读远大于写。

FastGPT 最终选择将计算复杂度前移至写路径,换取读路径上的 O(1)O(1) 索引直达。这样可以消除树遍历带来的反查雪崩,在单表内满足了多租户、复杂组织架构与资源继承的综合需求。

FastGPT
讓AI成為業務增長的新引擎

即刻嘗試