From 7d459bc3b6b4d573bf69a904a17b2716a8728867 Mon Sep 17 00:00:00 2001 From: Su Xiangcheng Date: Wed, 9 Sep 2026 10:56:43 +0800 Subject: [PATCH 1/2] feat: add default team basic permissions --- packages/service/env.ts | 3 + .../memberGroup/teamDefaultGroup.ts | 54 ++++++++ .../service/support/user/team/controller.ts | 13 +- packages/service/test/env.test.ts | 21 +++ .../memberGroup/teamDefaultGroup.test.ts | 131 ++++++++++++++++++ .../test/support/user/team/controller.test.ts | 100 +++++++++++++ projects/app/.env.template | 2 + 7 files changed, 313 insertions(+), 11 deletions(-) create mode 100644 packages/service/support/permission/memberGroup/teamDefaultGroup.ts create mode 100644 packages/service/test/support/permission/memberGroup/teamDefaultGroup.test.ts create mode 100644 packages/service/test/support/user/team/controller.test.ts diff --git a/packages/service/env.ts b/packages/service/env.ts index 4e91c7d9f76c..07eb1ad4fea9 100644 --- a/packages/service/env.ts +++ b/packages/service/env.ts @@ -324,6 +324,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 100462ab75c9..8269e285b5d4 100644 --- a/packages/service/support/user/team/controller.ts +++ b/packages/service/support/user/team/controller.ts @@ -28,6 +28,7 @@ import { formatTeamAccountCancellationSummary, getActiveAccountCancellationsByTeamIds } from '../account/cancellation'; +import { createTeamDefaultGroup } from '../../permission/memberGroup/teamDefaultGroup'; const logger = getLogger(LogCategories.MODULE.USER.TEAM); @@ -239,17 +240,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 时隐藏 From f63175697aad123290a1d955d7e652fb5e620599 Mon Sep 17 00:00:00 2001 From: Finley Ge Date: Wed, 9 Sep 2026 12:04:39 +0800 Subject: [PATCH 2/2] chore: update submodule reference --- pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pro b/pro index 9b844c0bca18..7c154fb80225 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 9b844c0bca18ef833f0a343460239279ba08e56a +Subproject commit 7c154fb802259db873b3e4d4854e0420bd500e15