diff --git a/.agents/design/support/default-team-basic-permissions-design.md b/.agents/design/support/default-team-basic-permissions-design.md new file mode 100644 index 000000000000..b768665b7276 --- /dev/null +++ b/.agents/design/support/default-team-basic-permissions-design.md @@ -0,0 +1,202 @@ +# 新建团队默认基础权限技术设计 + +## 1. 目标 + +新增环境变量: + +```env +DEFAULT_TEAM_BASIC_PERMISSIONS_ENABLED=true +``` + +开启后,系统创建团队时为默认全员组授予以下权限: + +- 创建应用; +- 创建 Skill; +- 创建知识库。 + +该能力作用于所有新建团队。环境变量默认值为 `false`,未配置时保持现有行为。 + +## 2. 总体方案 + +团队存在两条创建链路,因此主仓库和 Pro 都需要接入: + +| 场景 | 创建入口 | 仓库 | +| --- | --- | --- | +| 应用初始化 `root` 团队 | `createDefaultTeam()` | FastGPT | +| 注册、手动创建等普通团队 | `createTeam()` | fastgpt-pro | + +公共逻辑放在 FastGPT 主仓库的 `packages/service` 中: + +1. 在 `serviceEnv` 定义环境变量; +2. 提供 `createTeamDefaultGroup()`,创建默认全员组并按配置初始化权限; +3. `createDefaultTeam()` 和 Pro `createTeam()` 统一调用该函数。 + +## 3. 权限设计 + +默认全员组名称为 `DEFAULT_GROUP`。鉴权时,系统会自动将该组加入团队成员的群组列表,因此只需为默认组创建一条团队资源权限记录,不需要逐个成员授权。 + +权限使用现有角色常量按位组合: + +```ts +const defaultBasicRole = + TeamAppCreateRoleVal | + TeamSkillCreateRoleVal | + TeamDatasetCreateRoleVal; +``` + +创建的权限记录如下: + +```ts +{ + teamId, + resourceType: PerResourceTypeEnum.team, + groupId: defaultGroup._id, + permission: defaultBasicRole +} +``` + +该组合不包含团队管理和 API Key 创建权限。实现中应使用角色常量,不直接写权限数值。 + +## 4. 实现设计 + +### 4.1 环境变量 + +在主仓库 `packages/service/env.ts` 中增加: + +```ts +DEFAULT_TEAM_BASIC_PERMISSIONS_ENABLED: BoolSchema.default(false).meta({ + description: '新建团队时是否为默认全员组授予应用、Skill 和知识库创建权限' +}) +``` + +FastGPT App 和 Pro 均使用 `packages/service`,因此共享同一份环境变量定义。部署时两个服务必须配置相同值。 + +### 4.2 默认全员组初始化 + +在主仓库新增独立模块: + +```text +packages/service/support/permission/memberGroup/teamDefaultGroup.ts +``` + +该模块只依赖环境变量、权限常量、默认组 Model 和权限 service,不依赖团队 controller,避免与团队创建逻辑形成循环依赖。 + +新增函数: + +```ts +export async function createTeamDefaultGroup({ + teamId, + avatar, + session +}: { + teamId: string; + avatar?: string; + session?: ClientSession; +}) +``` + +函数负责: + +1. 创建团队默认全员组; +2. 环境变量开启时,创建默认组的团队资源权限; +3. 返回创建的默认组; +4. 调用方传入 session 时,默认组和权限写入复用该 session;未传入时由函数开启事务。 + +核心逻辑: + +```ts +const [group] = await MongoMemberGroupModel.create( + [ + { + teamId, + name: DefaultGroupName, + avatar + } + ], + { session, ordered: true } +); + +if (serviceEnv.DEFAULT_TEAM_BASIC_PERMISSIONS_ENABLED) { + await updateTeamCollaborator({ + teamId, + collaborator: { groupId: String(group._id) }, + permission: + TeamAppCreateRoleVal | + TeamSkillCreateRoleVal | + TeamDatasetCreateRoleVal, + session + }); +} + +return group; +``` + +权限记录通过现有 `updateTeamCollaborator()` 和 permission repository 写入,不直接操作 `MongoResourcePermission`。 + +现有 `getTeamDefaultGroup()` 继续只负责读取时补建缺失的默认组,不自动授予基础权限,避免环境变量开启后改变存量团队权限。 + +### 4.3 接入团队创建链路 + +主仓库修改 `packages/service/support/user/team/controller.ts`: + +```text +createDefaultTeam() + -> 创建 Team + -> 创建 Owner TeamMember + -> createTeamDefaultGroup() + -> 创建根组织 +``` + +Pro 修改 `admin/src/service/support/user/team/controller.ts`: + +```text +createTeam() + -> 创建 Team + -> 创建 Owner TeamMember + -> createTeamDefaultGroup() + -> 执行原有后续初始化 +``` + +两个入口都用 `createTeamDefaultGroup()` 替换原来直接创建 `MongoMemberGroupModel` 的代码。 + +### 4.4 事务 + +主仓库 `createDefaultTeam()`、手动创建团队和注册流程原本已使用事务,本次新增的默认权限写入继续复用同一个 session。 + +Pro `createTeam()` 的 session 调整为必传,并为企业微信授权创建团队增加 `mongoSessionRun()`。团队、Owner、默认组和默认权限任一步写入失败时整体回滚。公共 `createTeamDefaultGroup()` 按代码规范保留可选 session,但两个团队创建入口必须传入已有 session。 + +## 5. 修改文件 + +FastGPT 主仓库: + +```text +packages/service/env.ts +packages/service/support/permission/memberGroup/teamDefaultGroup.ts +packages/service/support/user/team/controller.ts +projects/app/.env.template +``` + +fastgpt-pro: + +```text +admin/src/service/support/user/team/controller.ts +admin/src/service/support/wecom/handler/auth.ts +admin/.env.template +``` + +## 6. 测试要点 + +1. 环境变量未配置或为 `false` 时,只创建默认组,不创建权限记录; +2. 环境变量为 `true` 时,默认组获得应用、Skill、知识库创建权限; +3. 默认权限不包含团队管理和 API Key 创建权限; +4. `root` 团队和 Pro 创建的普通团队行为一致; +5. 普通成员加入团队后可以创建应用、Skill 和知识库; +6. 权限写入失败时,团队创建事务能够回滚; +7. 企业微信授权创建团队时,所有初始化数据在同一事务中写入。 + +## 7. 验收标准 + +1. 默认关闭时,团队创建行为与当前版本一致; +2. 开启后,所有新建团队的全员组均获得三项基础权限; +3. 普通成员不能管理团队或创建 API Key; +4. 所有团队创建入口均在同一事务内完成团队和默认权限初始化。 diff --git a/packages/service/env.ts b/packages/service/env.ts index 3cac162cb857..a6bdce5b7422 100644 --- a/packages/service/env.ts +++ b/packages/service/env.ts @@ -321,6 +321,9 @@ export const serviceEnv = createEnv({ DISABLE_CACHE: BoolSchema.default(false).meta({ description: '是否禁用系统内存缓存' }), + DEFAULT_TEAM_BASIC_PERMISSIONS_ENABLED: BoolSchema.default(false).meta({ + description: '新建团队时是否为默认全员组授予应用、Skill 和知识库创建权限' + }), // ==================== 并发控制与限制 ==================== WECHAT_CHANNEL_CONCURRENCY: IntSchema.min(10).default(1000).meta({ diff --git a/packages/service/support/permission/memberGroup/teamDefaultGroup.ts b/packages/service/support/permission/memberGroup/teamDefaultGroup.ts new file mode 100644 index 000000000000..64cc49013185 --- /dev/null +++ b/packages/service/support/permission/memberGroup/teamDefaultGroup.ts @@ -0,0 +1,54 @@ +import { + TeamAppCreateRoleVal, + TeamDatasetCreateRoleVal, + TeamSkillCreateRoleVal +} from '@fastgpt/global/support/permission/user/constant'; +import { DefaultGroupName } from '@fastgpt/global/support/user/team/group/constant'; +import { serviceEnv } from '../../../env'; +import type { ClientSession } from '../../../common/mongo'; +import { mongoSessionRun } from '../../../common/mongo/sessionRun'; +import { updateTeamCollaborator } from '../resourcePermissionService'; +import { MongoMemberGroupModel } from './memberGroupSchema'; + +const defaultTeamBasicRole = + TeamAppCreateRoleVal | TeamSkillCreateRoleVal | TeamDatasetCreateRoleVal; + +/** + * 创建团队默认全员组,并按环境配置初始化团队级基础权限。 + * 调用方传入 session 时复用现有事务,否则为组和权限写入开启独立事务。 + */ +export async function createTeamDefaultGroup({ + teamId, + avatar, + session +}: { + teamId: string; + avatar?: string; + session?: ClientSession; +}) { + const create = async (activeSession: ClientSession) => { + const [group] = await MongoMemberGroupModel.create( + [ + { + teamId, + name: DefaultGroupName, + avatar + } + ], + { session: activeSession, ordered: true } + ); + + if (serviceEnv.DEFAULT_TEAM_BASIC_PERMISSIONS_ENABLED) { + await updateTeamCollaborator({ + teamId, + collaborator: { groupId: String(group._id) }, + permission: defaultTeamBasicRole, + session: activeSession + }); + } + + return group; + }; + + return session ? create(session) : mongoSessionRun(create); +} diff --git a/packages/service/support/user/team/controller.ts b/packages/service/support/user/team/controller.ts index 15c319cd2539..1dec4dbed446 100644 --- a/packages/service/support/user/team/controller.ts +++ b/packages/service/support/user/team/controller.ts @@ -23,6 +23,7 @@ import { formatTeamAccountCancellationSummary, getActiveAccountCancellationsByTeamIds } from '../account/cancellation'; +import { createTeamDefaultGroup } from '../../permission/memberGroup/teamDefaultGroup'; const logger = getLogger(LogCategories.MODULE.USER.TEAM); @@ -163,17 +164,7 @@ export async function createDefaultTeam({ ], { session } ); - // create default group - await MongoMemberGroupModel.create( - [ - { - teamId: tmb.teamId, - name: DefaultGroupName, - avatar - } - ], - { session } - ); + await createTeamDefaultGroup({ teamId: tmb.teamId, avatar, session }); await createRootOrg({ teamId: tmb.teamId, session }); logger.info('Default team created', { userId, teamId: tmb.teamId, tmbId: tmb._id }); return tmb; diff --git a/packages/service/test/env.test.ts b/packages/service/test/env.test.ts index 22887d46bfbb..ab664edd40c6 100644 --- a/packages/service/test/env.test.ts +++ b/packages/service/test/env.test.ts @@ -20,6 +20,7 @@ const originalEnv = { FILE_DOWNLOAD_PUBLIC_URL_PREFIX: process.env.FILE_DOWNLOAD_PUBLIC_URL_PREFIX, STORAGE_DOWNLOAD_URL_MODE: process.env.STORAGE_DOWNLOAD_URL_MODE, SYNC_INDEX: process.env.SYNC_INDEX, + DEFAULT_TEAM_BASIC_PERMISSIONS_ENABLED: process.env.DEFAULT_TEAM_BASIC_PERMISSIONS_ENABLED, AES256_SECRET_KEY: process.env.AES256_SECRET_KEY, INVOKE_TOKEN_SECRET: process.env.INVOKE_TOKEN_SECRET, SOMARK_API_KEY: process.env.SOMARK_API_KEY, @@ -69,6 +70,10 @@ describe('serviceEnv', () => { vi.stubEnv('FILE_DOWNLOAD_PUBLIC_URL_PREFIX', originalEnv.FILE_DOWNLOAD_PUBLIC_URL_PREFIX); vi.stubEnv('STORAGE_DOWNLOAD_URL_MODE', originalEnv.STORAGE_DOWNLOAD_URL_MODE); vi.stubEnv('SYNC_INDEX', originalEnv.SYNC_INDEX); + vi.stubEnv( + 'DEFAULT_TEAM_BASIC_PERMISSIONS_ENABLED', + originalEnv.DEFAULT_TEAM_BASIC_PERMISSIONS_ENABLED + ); vi.stubEnv('AES256_SECRET_KEY', originalEnv.AES256_SECRET_KEY); vi.stubEnv('INVOKE_TOKEN_SECRET', originalEnv.INVOKE_TOKEN_SECRET); vi.stubEnv('SOMARK_API_KEY', originalEnv.SOMARK_API_KEY); @@ -169,6 +174,22 @@ describe('serviceEnv', () => { await expect(importServiceEnv()).rejects.toThrow('Invalid environment variables'); }); + it('disables default team basic permissions by default and supports enabling them', async () => { + vi.stubEnv('FILE_TOKEN_KEY', 'filetokenkey'); + vi.stubEnv('AES256_SECRET_KEY', 'fastgptsecret'); + vi.stubEnv('INVOKE_TOKEN_SECRET', validInvokeTokenSecret); + + vi.stubEnv('DEFAULT_TEAM_BASIC_PERMISSIONS_ENABLED', undefined); + await expect(importServiceEnv()).resolves.toMatchObject({ + serviceEnv: { DEFAULT_TEAM_BASIC_PERMISSIONS_ENABLED: false } + }); + + vi.stubEnv('DEFAULT_TEAM_BASIC_PERMISSIONS_ENABLED', 'true'); + await expect(importServiceEnv()).resolves.toMatchObject({ + serviceEnv: { DEFAULT_TEAM_BASIC_PERMISSIONS_ENABLED: true } + }); + }); + it('reads the optional SoMark API key', async () => { vi.stubEnv('FILE_TOKEN_KEY', 'filetokenkey'); vi.stubEnv('AES256_SECRET_KEY', 'fastgptsecret'); diff --git a/packages/service/test/support/permission/memberGroup/teamDefaultGroup.test.ts b/packages/service/test/support/permission/memberGroup/teamDefaultGroup.test.ts new file mode 100644 index 000000000000..722f3685f7b5 --- /dev/null +++ b/packages/service/test/support/permission/memberGroup/teamDefaultGroup.test.ts @@ -0,0 +1,131 @@ +import { TeamPermission } from '@fastgpt/global/support/permission/user/controller'; +import { + TeamAppCreateRoleVal, + TeamDatasetCreateRoleVal, + TeamSkillCreateRoleVal +} from '@fastgpt/global/support/permission/user/constant'; +import { DefaultGroupName } from '@fastgpt/global/support/user/team/group/constant'; +import { PerResourceTypeEnum } from '@fastgpt/global/support/permission/constant'; +import { Types } from '@fastgpt/service/common/mongo'; +import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun'; +import { serviceEnv } from '@fastgpt/service/env'; +import { getTmbPermission } from '@fastgpt/service/support/permission/controller'; +import { MongoMemberGroupModel } from '@fastgpt/service/support/permission/memberGroup/memberGroupSchema'; +import { createTeamDefaultGroup } from '@fastgpt/service/support/permission/memberGroup/teamDefaultGroup'; +import { resourcePermissionRepo } from '@fastgpt/service/support/permission/repository/resourcePermissionRepo'; +import { MongoResourcePermission } from '@fastgpt/service/support/permission/schema'; +import { MongoTeamMember } from '@fastgpt/service/support/user/team/teamMemberSchema'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.unmock(import('@fastgpt/service/common/mongo/sessionRun')); + +const originalDefaultPermissionsEnabled = serviceEnv.DEFAULT_TEAM_BASIC_PERMISSIONS_ENABLED; +const defaultBasicRole = TeamAppCreateRoleVal | TeamSkillCreateRoleVal | TeamDatasetCreateRoleVal; +const setDefaultPermissionsEnabled = (enabled: boolean) => + Reflect.set(serviceEnv, 'DEFAULT_TEAM_BASIC_PERMISSIONS_ENABLED', enabled); + +describe('createTeamDefaultGroup', () => { + beforeEach(() => { + setDefaultPermissionsEnabled(false); + }); + + afterEach(() => { + setDefaultPermissionsEnabled(originalDefaultPermissionsEnabled); + vi.restoreAllMocks(); + }); + + it('creates only the default group when basic permissions are disabled', async () => { + const teamId = String(new Types.ObjectId()); + + const group = await createTeamDefaultGroup({ + teamId, + avatar: '/default-team.svg' + }); + + expect(group).toMatchObject({ + name: DefaultGroupName, + avatar: '/default-team.svg' + }); + await expect(MongoResourcePermission.findOne({ teamId }).lean()).resolves.toBeNull(); + }); + + it('grants the configured basic roles using the caller transaction', async () => { + setDefaultPermissionsEnabled(true); + const teamId = String(new Types.ObjectId()); + + const group = await mongoSessionRun((session) => createTeamDefaultGroup({ teamId, session })); + const permissionRow = await MongoResourcePermission.findOne({ + teamId, + groupId: group._id, + resourceType: 'team', + resourceId: null + }).lean(); + + expect(permissionRow?.permission).toBe(defaultBasicRole); + + const permission = new TeamPermission({ role: permissionRow?.permission }); + expect(permission.hasAppCreatePer).toBe(true); + expect(permission.hasSkillCreatePer).toBe(true); + expect(permission.hasDatasetCreatePer).toBe(true); + expect(permission.hasApikeyCreatePer).toBe(false); + expect(permission.hasManagePer).toBe(false); + }); + + it('allows an ordinary member to inherit the default group basic permissions', async () => { + setDefaultPermissionsEnabled(true); + const teamId = String(new Types.ObjectId()); + const member = await MongoTeamMember.create({ + teamId, + userId: new Types.ObjectId(), + name: 'Member', + status: 'active' + }); + await createTeamDefaultGroup({ teamId }); + + const role = await getTmbPermission({ + resourceType: PerResourceTypeEnum.team, + teamId, + tmbId: String(member._id) + }); + const permission = new TeamPermission({ role }); + + expect(role).toBe(defaultBasicRole); + expect(permission.hasAppCreatePer).toBe(true); + expect(permission.hasSkillCreatePer).toBe(true); + expect(permission.hasDatasetCreatePer).toBe(true); + expect(permission.hasApikeyCreatePer).toBe(false); + expect(permission.hasManagePer).toBe(false); + }); + + it('rolls back the group and permissions when the caller transaction fails', async () => { + setDefaultPermissionsEnabled(true); + const teamId = String(new Types.ObjectId()); + + await expect( + mongoSessionRun(async (session) => { + await createTeamDefaultGroup({ teamId, session }); + throw new Error('caller transaction failed'); + }) + ).rejects.toThrow('caller transaction failed'); + + await expect( + Promise.all([ + MongoMemberGroupModel.findOne({ teamId, name: DefaultGroupName }).lean(), + MongoResourcePermission.findOne({ teamId, resourceType: 'team' }).lean() + ]) + ).resolves.toEqual([null, null]); + }); + + it('rolls back the default group when permission initialization fails', async () => { + setDefaultPermissionsEnabled(true); + const teamId = String(new Types.ObjectId()); + vi.spyOn(resourcePermissionRepo, 'updateCollaborator').mockRejectedValueOnce( + new Error('permission write failed') + ); + + await expect(createTeamDefaultGroup({ teamId })).rejects.toThrow('permission write failed'); + await expect( + MongoMemberGroupModel.findOne({ teamId, name: DefaultGroupName }).lean() + ).resolves.toBeNull(); + }); +}); diff --git a/packages/service/test/support/user/team/controller.test.ts b/packages/service/test/support/user/team/controller.test.ts new file mode 100644 index 000000000000..21c6cc6d68a9 --- /dev/null +++ b/packages/service/test/support/user/team/controller.test.ts @@ -0,0 +1,100 @@ +import { + TeamAppCreateRoleVal, + TeamDatasetCreateRoleVal, + TeamSkillCreateRoleVal +} from '@fastgpt/global/support/permission/user/constant'; +import { DefaultGroupName } from '@fastgpt/global/support/user/team/group/constant'; +import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun'; +import { serviceEnv } from '@fastgpt/service/env'; +import { MongoMemberGroupModel } from '@fastgpt/service/support/permission/memberGroup/memberGroupSchema'; +import { MongoOrgModel } from '@fastgpt/service/support/permission/org/orgSchema'; +import { resourcePermissionRepo } from '@fastgpt/service/support/permission/repository/resourcePermissionRepo'; +import { MongoResourcePermission } from '@fastgpt/service/support/permission/schema'; +import { MongoUser } from '@fastgpt/service/support/user/schema'; +import { createDefaultTeam } from '@fastgpt/service/support/user/team/controller'; +import { MongoTeamMember } from '@fastgpt/service/support/user/team/teamMemberSchema'; +import { MongoTeam } from '@fastgpt/service/support/user/team/teamSchema'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.unmock(import('@fastgpt/service/common/mongo/sessionRun')); + +const originalDefaultPermissionsEnabled = serviceEnv.DEFAULT_TEAM_BASIC_PERMISSIONS_ENABLED; +const defaultBasicRole = TeamAppCreateRoleVal | TeamSkillCreateRoleVal | TeamDatasetCreateRoleVal; +const setDefaultPermissionsEnabled = (enabled: boolean) => + Reflect.set(serviceEnv, 'DEFAULT_TEAM_BASIC_PERMISSIONS_ENABLED', enabled); + +describe('createDefaultTeam', () => { + beforeEach(() => { + setDefaultPermissionsEnabled(true); + }); + + afterEach(() => { + setDefaultPermissionsEnabled(originalDefaultPermissionsEnabled); + vi.restoreAllMocks(); + }); + + it('creates the root team and initializes its default group permissions', async () => { + const owner = await MongoUser.create({ + username: 'root-team-owner', + password: 'password' + }); + + const member = await mongoSessionRun((session) => + createDefaultTeam({ + userId: String(owner._id), + teamName: 'Root team', + avatar: '/root-team.svg', + session + }) + ); + const teamId = String(member?.teamId); + const [team, group, rootOrg] = await Promise.all([ + MongoTeam.findById(teamId).lean(), + MongoMemberGroupModel.findOne({ teamId, name: DefaultGroupName }).lean(), + MongoOrgModel.findOne({ teamId, path: '' }).lean() + ]); + const permission = await MongoResourcePermission.findOne({ + teamId, + groupId: group?._id, + resourceType: 'team', + resourceId: null + }).lean(); + + expect(member).toMatchObject({ name: 'Owner', role: 'owner', status: 'active' }); + expect(team).toMatchObject({ name: 'Root team', avatar: '/root-team.svg' }); + expect(String(team?.ownerId)).toBe(String(owner._id)); + expect(group).toMatchObject({ name: DefaultGroupName, avatar: '/root-team.svg' }); + expect(rootOrg).not.toBeNull(); + expect(permission?.permission).toBe(defaultBasicRole); + }); + + it('rolls back the root team when default permission initialization fails', async () => { + const owner = await MongoUser.create({ + username: 'root-team-rollback-owner', + password: 'password' + }); + vi.spyOn(resourcePermissionRepo, 'updateCollaborator').mockRejectedValueOnce( + new Error('permission write failed') + ); + + await expect( + mongoSessionRun((session) => + createDefaultTeam({ + userId: String(owner._id), + teamName: 'Rollback root team', + session + }) + ) + ).rejects.toThrow('permission write failed'); + + await expect( + Promise.all([ + MongoTeam.findOne({ ownerId: owner._id }).lean(), + MongoTeamMember.findOne({ userId: owner._id }).lean(), + MongoMemberGroupModel.findOne({ teamId: { $ne: null } }).lean(), + MongoResourcePermission.findOne({ teamId: { $ne: null } }).lean(), + MongoOrgModel.findOne({ teamId: { $ne: null } }).lean() + ]) + ).resolves.toEqual([null, null, null, null, null]); + }); +}); diff --git a/projects/app/.env.template b/projects/app/.env.template index 81db680efab7..14e7ba1694fb 100644 --- a/projects/app/.env.template +++ b/projects/app/.env.template @@ -196,6 +196,8 @@ MULTIPLE_DATA_TO_BASE64=true SHOW_COUPON=false # 是否展示优惠券功能 SHOW_DISCOUNT_COUPON=false +# 新建团队时是否为默认全员组授予应用、Skill 和知识库创建权限 +DEFAULT_TEAM_BASIC_PERMISSIONS_ENABLED=false # 申请应用备案地址 APP_REGISTRATION_URL= # 是否隐藏版权信息配置,只有值为 true 时隐藏