diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/__tests__/role-tool.provider.spec.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/__tests__/role-tool.provider.spec.ts new file mode 100644 index 0000000000..36cddfd0a8 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/__tests__/role-tool.provider.spec.ts @@ -0,0 +1,582 @@ +import { PermissionFlagType } from 'twenty-shared/constants'; +import { FieldActorSource } from 'twenty-shared/types'; + +import { type ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type'; +import { RoleToolProvider } from 'src/engine/core-modules/tool-provider/providers/role-tool.provider'; +import { type ObjectPermissionService } from 'src/engine/metadata-modules/object-permission/object-permission.service'; +import { + PermissionsException, + PermissionsExceptionCode, + PermissionsExceptionMessage, +} from 'src/engine/metadata-modules/permissions/permissions.exception'; +import { type PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; +import { type RoleService } from 'src/engine/metadata-modules/role/role.service'; +import { RoleToolWorkspaceService } from 'src/engine/metadata-modules/role/tools/services/role-tool.workspace-service'; +import { type RowLevelPermissionPredicateGroupService } from 'src/engine/metadata-modules/row-level-permission-predicate/services/row-level-permission-predicate-group.service'; +import { type RowLevelPermissionPredicateService } from 'src/engine/metadata-modules/row-level-permission-predicate/services/row-level-permission-predicate.service'; +import { type UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; +import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception'; + +const workspaceId = 'workspace-id'; +const callerRoleId = 'caller-role-id'; +const callerUserWorkspaceId = 'caller-user-workspace-id'; + +const buildProvider = (options?: { hasRolesPermission?: boolean }) => { + const roleService = { + getWorkspaceRoles: jest.fn().mockResolvedValue([]), + createRole: jest.fn(), + updateRole: jest.fn(), + deleteRole: jest.fn(), + }; + const userRoleService = { + assignRoleToWorkspaceMember: jest.fn().mockResolvedValue({ + workspaceMember: { + id: 'target-member-id', + userId: 'target-user-id', + name: { firstName: 'Jane', lastName: 'Doe' }, + }, + userWorkspaceId: 'target-user-workspace-id', + }), + }; + const objectPermissionService = { + upsertObjectPermissions: jest.fn().mockResolvedValue([]), + }; + const rowLevelPermissionPredicateService = { + findByWorkspaceId: jest.fn().mockResolvedValue([]), + upsertRowLevelPermissionPredicates: jest + .fn() + .mockResolvedValue({ predicates: [], predicateGroups: [] }), + }; + const rowLevelPermissionPredicateGroupService = { + findByWorkspaceId: jest.fn().mockResolvedValue([]), + }; + const applicationService = { + findWorkspaceTwentyStandardAndCustomApplicationOrThrow: jest + .fn() + .mockResolvedValue({ + workspaceCustomFlatApplication: { + id: 'application-id', + universalIdentifier: 'application-universal-identifier', + }, + }), + }; + const permissionsService = { + checkRolesPermissions: jest + .fn() + .mockResolvedValue(options?.hasRolesPermission ?? true), + }; + + const roleToolWorkspaceService = new RoleToolWorkspaceService( + roleService as unknown as RoleService, + userRoleService as unknown as UserRoleService, + objectPermissionService as unknown as ObjectPermissionService, + rowLevelPermissionPredicateService as unknown as RowLevelPermissionPredicateService, + rowLevelPermissionPredicateGroupService as unknown as RowLevelPermissionPredicateGroupService, + applicationService as unknown as ApplicationService, + ); + + const provider = new RoleToolProvider( + roleToolWorkspaceService, + permissionsService as unknown as PermissionsService, + ); + + return { + provider, + roleService, + userRoleService, + objectPermissionService, + rowLevelPermissionPredicateService, + rowLevelPermissionPredicateGroupService, + permissionsService, + }; +}; + +const context: ToolProviderContext = { + workspaceId, + roleId: callerRoleId, + rolePermissionConfig: { unionOf: [callerRoleId] }, + userWorkspaceId: callerUserWorkspaceId, + actorContext: { + source: FieldActorSource.MANUAL, + workspaceMemberId: 'caller-workspace-member-id', + name: 'Caller', + context: {}, + }, +}; + +describe('RoleToolProvider', () => { + describe('isAvailable', () => { + it('is available when the caller has the ROLES settings permission', async () => { + const { provider, permissionsService } = buildProvider({ + hasRolesPermission: true, + }); + + await expect(provider.isAvailable(context)).resolves.toBe(true); + expect(permissionsService.checkRolesPermissions).toHaveBeenCalledWith( + context.rolePermissionConfig, + workspaceId, + PermissionFlagType.ROLES, + ); + }); + + it('is not available without the ROLES settings permission', async () => { + const { provider } = buildProvider({ hasRolesPermission: false }); + + await expect(provider.isAvailable(context)).resolves.toBe(false); + }); + }); + + describe('generateDescriptors', () => { + it('exposes the role management tools', async () => { + const { provider } = buildProvider(); + + const descriptors = await provider.generateDescriptors(context, { + includeSchemas: false, + }); + + expect(descriptors.map((descriptor) => descriptor.name)).toEqual( + expect.arrayContaining([ + 'list_roles', + 'create_role', + 'update_role', + 'delete_role', + 'assign_role_to_workspace_member', + 'upsert_object_permissions', + 'upsert_row_level_permission_rules', + ]), + ); + + for (const descriptor of descriptors) { + expect(descriptor.label).toBeDefined(); + expect(descriptor.description.length).toBeGreaterThan(0); + } + }); + }); + + describe('list_roles', () => { + it('returns the workspace roles', async () => { + const { provider, roleService } = buildProvider(); + + roleService.getWorkspaceRoles.mockResolvedValue([ + { + id: 'role-1', + label: 'Support', + isEditable: true, + objectPermissions: [], + permissionFlags: [], + }, + ]); + + const output = await provider.executeStaticTool( + 'list_roles', + {}, + context, + ); + + expect(output.success).toBe(true); + expect(roleService.getWorkspaceRoles).toHaveBeenCalledWith(workspaceId); + }); + + it('includes row-level permission rules when requested', async () => { + const { + provider, + roleService, + rowLevelPermissionPredicateService, + rowLevelPermissionPredicateGroupService, + } = buildProvider(); + + roleService.getWorkspaceRoles.mockResolvedValue([ + { id: 'role-1', label: 'Support', isEditable: true }, + ]); + rowLevelPermissionPredicateService.findByWorkspaceId.mockResolvedValue([ + { id: 'predicate-1', roleId: 'role-1' }, + { id: 'predicate-2', roleId: 'other-role' }, + ]); + rowLevelPermissionPredicateGroupService.findByWorkspaceId.mockResolvedValue( + [ + { id: 'group-1', roleId: 'role-1' }, + { id: 'group-2', roleId: 'other-role' }, + ], + ); + + const output = await provider.executeStaticTool( + 'list_roles', + { includeRowLevelPermissionRules: true }, + context, + ); + + expect(output.success).toBe(true); + + const { roles } = output.result as { + roles: { + rowLevelPermissionPredicates: { id: string }[]; + rowLevelPermissionPredicateGroups: { id: string }[]; + }[]; + }; + + expect(roles[0].rowLevelPermissionPredicates).toEqual([ + { id: 'predicate-1', roleId: 'role-1' }, + ]); + expect(roles[0].rowLevelPermissionPredicateGroups).toEqual([ + { id: 'group-1', roleId: 'role-1' }, + ]); + }); + + it('fetches predicates and groups once for the workspace, not per role', async () => { + const { + provider, + roleService, + rowLevelPermissionPredicateService, + rowLevelPermissionPredicateGroupService, + } = buildProvider(); + + roleService.getWorkspaceRoles.mockResolvedValue([ + { id: 'role-1', label: 'Support', isEditable: true }, + { id: 'role-2', label: 'Sales', isEditable: true }, + { id: 'role-3', label: 'Guest', isEditable: true }, + ]); + + await provider.executeStaticTool( + 'list_roles', + { includeRowLevelPermissionRules: true }, + context, + ); + + expect( + rowLevelPermissionPredicateService.findByWorkspaceId, + ).toHaveBeenCalledTimes(1); + expect( + rowLevelPermissionPredicateGroupService.findByWorkspaceId, + ).toHaveBeenCalledTimes(1); + expect( + rowLevelPermissionPredicateGroupService.findByWorkspaceId, + ).toHaveBeenCalledWith(workspaceId); + }); + }); + + describe('create_role', () => { + it('creates a role through the role service', async () => { + const { provider, roleService } = buildProvider(); + + roleService.createRole.mockResolvedValue({ + id: 'new-role-id', + label: 'Sales', + }); + + const output = await provider.executeStaticTool( + 'create_role', + { label: 'Sales', canReadAllObjectRecords: true }, + context, + ); + + expect(output.success).toBe(true); + expect(roleService.createRole).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId, + input: expect.objectContaining({ + label: 'Sales', + canReadAllObjectRecords: true, + }), + }), + ); + }); + + it('surfaces the underlying validation errors from a failed migration build', async () => { + const { provider, roleService } = buildProvider(); + + roleService.createRole.mockRejectedValue( + new WorkspaceMigrationBuilderException( + { + status: 'fail', + report: { + role: [ + { + flatEntityMinimalInformation: { label: 'Sales' }, + errors: [ + { code: 'INVALID', message: 'Role label already exists' }, + ], + }, + ], + }, + } as unknown as ConstructorParameters< + typeof WorkspaceMigrationBuilderException + >[0], + 'Multiple validation errors occurred while creating role', + ), + ); + + const output = await provider.executeStaticTool( + 'create_role', + { label: 'Sales' }, + context, + ); + + expect(output.success).toBe(false); + expect(output.error).toContain('Role label already exists'); + expect(output.error).toContain('Sales'); + }); + }); + + describe('update_role', () => { + it('updates the role, forwarding the caller roles for lockout protection', async () => { + const { provider, roleService } = buildProvider(); + + roleService.updateRole.mockResolvedValue({ + id: 'other-role-id', + label: 'Support L1', + }); + + const output = await provider.executeStaticTool( + 'update_role', + { + roleId: 'other-role-id', + update: { canUpdateAllSettings: false, label: 'Support L1' }, + }, + context, + ); + + expect(output.success).toBe(true); + expect(roleService.updateRole).toHaveBeenCalledWith({ + workspaceId, + input: { + id: 'other-role-id', + update: { canUpdateAllSettings: false, label: 'Support L1' }, + }, + actingRoleIds: [callerRoleId], + }); + }); + + it('surfaces service rejections such as lockout protection', async () => { + const { provider, roleService } = buildProvider(); + + roleService.updateRole.mockRejectedValue( + new PermissionsException( + PermissionsExceptionMessage.CANNOT_REVOKE_OWN_SETTINGS_ACCESS, + PermissionsExceptionCode.CANNOT_REVOKE_OWN_SETTINGS_ACCESS, + ), + ); + + const output = await provider.executeStaticTool( + 'update_role', + { roleId: callerRoleId, update: { canUpdateAllSettings: false } }, + context, + ); + + expect(output.success).toBe(false); + expect(output.error).toContain( + PermissionsExceptionMessage.CANNOT_REVOKE_OWN_SETTINGS_ACCESS, + ); + }); + }); + + describe('delete_role', () => { + it('deletes the role, forwarding the caller roles for lockout protection', async () => { + const { provider, roleService } = buildProvider(); + + roleService.deleteRole.mockResolvedValue({ + id: 'other-role-id', + label: 'Support', + }); + + const output = await provider.executeStaticTool( + 'delete_role', + { roleId: 'other-role-id' }, + context, + ); + + expect(output.success).toBe(true); + expect(roleService.deleteRole).toHaveBeenCalledWith({ + roleId: 'other-role-id', + workspaceId, + actingRoleIds: [callerRoleId], + }); + }); + + it('surfaces service rejections such as deleting an own role', async () => { + const { provider, roleService } = buildProvider(); + + roleService.deleteRole.mockRejectedValue( + new PermissionsException( + PermissionsExceptionMessage.CANNOT_DELETE_OWN_ROLE, + PermissionsExceptionCode.CANNOT_DELETE_OWN_ROLE, + ), + ); + + const output = await provider.executeStaticTool( + 'delete_role', + { roleId: callerRoleId }, + context, + ); + + expect(output.success).toBe(false); + expect(output.error).toContain( + PermissionsExceptionMessage.CANNOT_DELETE_OWN_ROLE, + ); + }); + }); + + describe('assign_role_to_workspace_member', () => { + it('delegates to the user role service with the acting user workspace', async () => { + const { provider, userRoleService } = buildProvider(); + + const output = await provider.executeStaticTool( + 'assign_role_to_workspace_member', + { workspaceMemberId: 'target-member-id', roleId: 'other-role-id' }, + context, + ); + + expect(output.success).toBe(true); + expect(userRoleService.assignRoleToWorkspaceMember).toHaveBeenCalledWith({ + workspaceId, + workspaceMemberId: 'target-member-id', + roleId: 'other-role-id', + actingUserWorkspaceId: callerUserWorkspaceId, + }); + }); + + it('surfaces service rejections such as changing your own role', async () => { + const { provider, userRoleService } = buildProvider(); + + userRoleService.assignRoleToWorkspaceMember.mockRejectedValue( + new PermissionsException( + PermissionsExceptionMessage.CANNOT_UPDATE_SELF_ROLE, + PermissionsExceptionCode.CANNOT_UPDATE_SELF_ROLE, + ), + ); + + const output = await provider.executeStaticTool( + 'assign_role_to_workspace_member', + { + workspaceMemberId: 'caller-workspace-member-id', + roleId: 'other-role-id', + }, + context, + ); + + expect(output.success).toBe(false); + expect(output.error).toContain( + PermissionsExceptionMessage.CANNOT_UPDATE_SELF_ROLE, + ); + }); + }); + + describe('upsert_object_permissions', () => { + it('upserts object permission overrides through the service', async () => { + const { provider, objectPermissionService } = buildProvider(); + + objectPermissionService.upsertObjectPermissions.mockResolvedValue([ + { + objectMetadataId: 'object-metadata-id', + canReadObjectRecords: true, + canUpdateObjectRecords: false, + canSoftDeleteObjectRecords: false, + canDestroyObjectRecords: false, + }, + ]); + + const output = await provider.executeStaticTool( + 'upsert_object_permissions', + { + roleId: 'support-role-id', + objectPermissions: [ + { + objectMetadataId: 'object-metadata-id', + canReadObjectRecords: true, + canUpdateObjectRecords: false, + canSoftDeleteObjectRecords: false, + canDestroyObjectRecords: false, + }, + ], + }, + context, + ); + + expect(output.success).toBe(true); + expect( + objectPermissionService.upsertObjectPermissions, + ).toHaveBeenCalledWith({ + workspaceId, + input: { + roleId: 'support-role-id', + objectPermissions: [ + { + objectMetadataId: 'object-metadata-id', + canReadObjectRecords: true, + canUpdateObjectRecords: false, + canSoftDeleteObjectRecords: false, + canDestroyObjectRecords: false, + }, + ], + }, + }); + }); + }); + + describe('upsert_row_level_permission_rules', () => { + it('upserts predicates and injects the object metadata id into groups', async () => { + const { provider, rowLevelPermissionPredicateService } = buildProvider(); + + const output = await provider.executeStaticTool( + 'upsert_row_level_permission_rules', + { + roleId: 'support-role-id', + objectMetadataId: 'object-metadata-id', + predicates: [ + { + fieldMetadataId: 'owner-field-metadata-id', + operand: 'IS', + workspaceMemberFieldMetadataId: + 'workspace-member-id-field-metadata-id', + rowLevelPermissionPredicateGroupId: 'group-id', + }, + ], + predicateGroups: [{ id: 'group-id', logicalOperator: 'AND' }], + }, + context, + ); + + expect(output.success).toBe(true); + expect( + rowLevelPermissionPredicateService.upsertRowLevelPermissionPredicates, + ).toHaveBeenCalledWith({ + workspaceId, + input: expect.objectContaining({ + roleId: 'support-role-id', + objectMetadataId: 'object-metadata-id', + predicateGroups: [ + expect.objectContaining({ + id: 'group-id', + logicalOperator: 'AND', + objectMetadataId: 'object-metadata-id', + }), + ], + }), + }); + }); + + it('surfaces service rejections such as cross-role ownership violations', async () => { + const { provider, rowLevelPermissionPredicateService } = buildProvider(); + + rowLevelPermissionPredicateService.upsertRowLevelPermissionPredicates.mockRejectedValue( + new Error( + 'Predicate "foreign-predicate-id" belongs to a different role or object and cannot be modified here.', + ), + ); + + const output = await provider.executeStaticTool( + 'upsert_row_level_permission_rules', + { + roleId: 'support-role-id', + objectMetadataId: 'object-metadata-id', + predicates: [], + predicateGroups: [], + }, + context, + ); + + expect(output.success).toBe(false); + expect(output.error).toContain('different role or object'); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/role-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/role-tool.provider.ts new file mode 100644 index 0000000000..9472f2e5ee --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/role-tool.provider.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@nestjs/common'; + +import { type ToolSet } from 'ai'; +import { ToolCategory } from 'twenty-shared/ai'; +import { PermissionFlagType } from 'twenty-shared/constants'; + +import { type GenerateDescriptorOptions } from 'src/engine/core-modules/tool-provider/interfaces/generate-descriptor-options.type'; +import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface'; +import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type'; +import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type'; +import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type'; +import { executeToolFromToolSet } from 'src/engine/core-modules/tool-provider/utils/execute-tool-from-tool-set.util'; +import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util'; +import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; +import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; +import { RoleToolWorkspaceService } from 'src/engine/metadata-modules/role/tools/services/role-tool.workspace-service'; +import { getRoleIdsFromRolePermissionConfig } from 'src/engine/twenty-orm/utils/get-role-ids-from-role-permission-config.util'; + +@Injectable() +export class RoleToolProvider implements ToolProvider { + readonly category = ToolCategory.ROLE; + + constructor( + private readonly roleToolWorkspaceService: RoleToolWorkspaceService, + private readonly permissionsService: PermissionsService, + ) {} + + async isAvailable(context: ToolProviderContext): Promise { + return this.permissionsService.checkRolesPermissions( + context.rolePermissionConfig, + context.workspaceId, + PermissionFlagType.ROLES, + ); + } + + async generateDescriptors( + context: ToolProviderContext, + options?: GenerateDescriptorOptions, + ): Promise<(ToolIndexEntry | ToolDescriptor)[]> { + return toolSetToDescriptors(this.buildToolSet(context), ToolCategory.ROLE, { + includeSchemas: options?.includeSchemas ?? true, + }); + } + + async executeStaticTool( + toolName: string, + args: Record, + context: ToolProviderContext, + ): Promise { + return executeToolFromToolSet( + this.buildToolSet(context), + toolName, + args, + ToolCategory.ROLE, + ); + } + + private buildToolSet(context: ToolProviderContext): ToolSet { + const callerRoleIds = new Set([ + ...getRoleIdsFromRolePermissionConfig(context.rolePermissionConfig), + context.roleId, + ]); + + return this.roleToolWorkspaceService.generateRoleTools({ + workspaceId: context.workspaceId, + callerRoleIds: [...callerRoleIds], + callerUserWorkspaceId: context.userWorkspaceId, + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts index 633778ce59..c0c748d034 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts @@ -9,6 +9,7 @@ import { DatabaseToolProvider } from 'src/engine/core-modules/tool-provider/prov import { LogicFunctionToolProvider } from 'src/engine/core-modules/tool-provider/providers/logic-function-tool.provider'; import { MetadataToolProvider } from 'src/engine/core-modules/tool-provider/providers/metadata-tool.provider'; import { NavigationMenuItemToolProvider } from 'src/engine/core-modules/tool-provider/providers/navigation-menu-item-tool.provider'; +import { RoleToolProvider } from 'src/engine/core-modules/tool-provider/providers/role-tool.provider'; import { ViewToolProvider } from 'src/engine/core-modules/tool-provider/providers/view-tool.provider'; import { WebhookToolProvider } from 'src/engine/core-modules/tool-provider/providers/webhook-tool.provider'; import { WorkflowToolProvider } from 'src/engine/core-modules/tool-provider/providers/workflow-tool.provider'; @@ -23,6 +24,7 @@ import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/ import { NavigationMenuItemModule } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module'; import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; +import { RoleModule } from 'src/engine/metadata-modules/role/role.module'; import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; import { ViewFieldModule } from 'src/engine/metadata-modules/view-field/view-field.module'; import { ViewFilterModule } from 'src/engine/metadata-modules/view-filter/view-filter.module'; @@ -62,6 +64,7 @@ import { ToolRegistryService } from './services/tool-registry.service'; LogicFunctionModule, NavigationMenuItemModule, WebhookModule, + RoleModule, UserRoleModule, TypeOrmModule.forFeature([UserEntity]), ], @@ -74,6 +77,7 @@ import { ToolRegistryService } from './services/tool-registry.service'; MetadataToolProvider, NavigationMenuItemToolProvider, LogicFunctionToolProvider, + RoleToolProvider, ViewToolProvider, WebhookToolProvider, WorkflowToolProvider, @@ -89,6 +93,7 @@ import { ToolRegistryService } from './services/tool-registry.service'; metadataProvider: MetadataToolProvider, logicFunctionProvider: LogicFunctionToolProvider, navigationMenuItemProvider: NavigationMenuItemToolProvider, + roleProvider: RoleToolProvider, viewProvider: ViewToolProvider, webhookProvider: WebhookToolProvider, workflowProvider: WorkflowToolProvider, @@ -99,6 +104,7 @@ import { ToolRegistryService } from './services/tool-registry.service'; metadataProvider, logicFunctionProvider, navigationMenuItemProvider, + roleProvider, viewProvider, webhookProvider, workflowProvider, @@ -110,6 +116,7 @@ import { ToolRegistryService } from './services/tool-registry.service'; MetadataToolProvider, LogicFunctionToolProvider, NavigationMenuItemToolProvider, + RoleToolProvider, ViewToolProvider, WebhookToolProvider, WorkflowToolProvider, diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/utils/build-tool-catalog-section.util.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/build-tool-catalog-section.util.ts index d17d933801..277b0237ea 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/utils/build-tool-catalog-section.util.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/build-tool-catalog-section.util.ts @@ -27,6 +27,8 @@ const getCategoryLabel = (category: ToolCategory): string => { return 'Navigation Menu Item Tools (sidebar entries, folders, and user favorites)'; case ToolCategory.WEBHOOK: return 'Webhook Tools (outgoing webhooks)'; + case ToolCategory.ROLE: + return 'Role Tools (manage roles and permissions)'; default: return assertUnreachable(category); } diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts index 6f4fd477a1..d9aa6948da 100644 --- a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts +++ b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts @@ -18,6 +18,8 @@ export enum PermissionsExceptionCode { UNKNOWN_OPERATION_NAME = 'UNKNOWN_OPERATION_NAME_PERMISSIONS', UNKNOWN_REQUIRED_PERMISSION = 'UNKNOWN_REQUIRED_PERMISSION', CANNOT_UPDATE_SELF_ROLE = 'CANNOT_UPDATE_SELF_ROLE', + CANNOT_DELETE_OWN_ROLE = 'CANNOT_DELETE_OWN_ROLE', + CANNOT_REVOKE_OWN_SETTINGS_ACCESS = 'CANNOT_REVOKE_OWN_SETTINGS_ACCESS', NO_ROLE_FOUND_FOR_USER_WORKSPACE = 'NO_ROLE_FOUND_FOR_USER_WORKSPACE', API_KEY_ROLE_NOT_FOUND = 'API_KEY_ROLE_NOT_FOUND', NO_AUTHENTICATION_CONTEXT = 'NO_AUTHENTICATION_CONTEXT', @@ -84,6 +86,10 @@ const getPermissionsExceptionUserFriendlyMessage = ( return msg`Unknown permission required.`; case PermissionsExceptionCode.CANNOT_UPDATE_SELF_ROLE: return msg`You cannot update your own role.`; + case PermissionsExceptionCode.CANNOT_DELETE_OWN_ROLE: + return msg`You cannot delete a role you are assigned to. Ask another administrator to delete it.`; + case PermissionsExceptionCode.CANNOT_REVOKE_OWN_SETTINGS_ACCESS: + return msg`You cannot remove settings access from your own role. Ask another administrator to make this change.`; case PermissionsExceptionCode.NO_ROLE_FOUND_FOR_USER_WORKSPACE: return msg`No role found for this user in the workspace.`; case PermissionsExceptionCode.API_KEY_ROLE_NOT_FOUND: @@ -178,6 +184,8 @@ export enum PermissionsExceptionMessage { CANNOT_DELETE_LAST_ADMIN_USER = 'Cannot delete account: user is the unique admin of a workspace', UNKNOWN_OPERATION_NAME = 'Unknown operation name, cannot determine required permission', CANNOT_UPDATE_SELF_ROLE = 'Cannot update self role', + CANNOT_DELETE_OWN_ROLE = 'Cannot delete a role assigned to the acting user', + CANNOT_REVOKE_OWN_SETTINGS_ACCESS = 'Cannot revoke settings access from the acting user role', NO_ROLE_FOUND_FOR_USER_WORKSPACE = 'No role found for userWorkspace', API_KEY_ROLE_NOT_FOUND = 'API key has no role assigned', NO_AUTHENTICATION_CONTEXT = 'No valid authentication context found', diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util.ts index a53069f69a..66a296e465 100644 --- a/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util.ts @@ -29,6 +29,8 @@ export const permissionGraphqlApiExceptionHandler = ( throw new ForbiddenError(error); case PermissionsExceptionCode.CANNOT_UNASSIGN_LAST_ADMIN: case PermissionsExceptionCode.CANNOT_UPDATE_SELF_ROLE: + case PermissionsExceptionCode.CANNOT_DELETE_OWN_ROLE: + case PermissionsExceptionCode.CANNOT_REVOKE_OWN_SETTINGS_ACCESS: case PermissionsExceptionCode.CANNOT_DELETE_LAST_ADMIN_USER: case PermissionsExceptionCode.ROLE_NOT_EDITABLE: case PermissionsExceptionCode.CANNOT_ADD_OBJECT_PERMISSION_ON_SYSTEM_OBJECT: diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permission-rest-api-exception-code-to-http-status.util.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permission-rest-api-exception-code-to-http-status.util.ts index b58728a73d..e58bbd448d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permission-rest-api-exception-code-to-http-status.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permission-rest-api-exception-code-to-http-status.util.ts @@ -11,6 +11,8 @@ export const permissionRestApiExceptionCodeToHttpStatus = ( case PermissionsExceptionCode.ROLE_LABEL_ALREADY_EXISTS: case PermissionsExceptionCode.CANNOT_UNASSIGN_LAST_ADMIN: case PermissionsExceptionCode.CANNOT_UPDATE_SELF_ROLE: + case PermissionsExceptionCode.CANNOT_DELETE_OWN_ROLE: + case PermissionsExceptionCode.CANNOT_REVOKE_OWN_SETTINGS_ACCESS: case PermissionsExceptionCode.CANNOT_DELETE_LAST_ADMIN_USER: case PermissionsExceptionCode.ROLE_NOT_EDITABLE: case PermissionsExceptionCode.CANNOT_ADD_OBJECT_PERMISSION_ON_SYSTEM_OBJECT: diff --git a/packages/twenty-server/src/engine/metadata-modules/role/role.module.ts b/packages/twenty-server/src/engine/metadata-modules/role/role.module.ts index 98e6a9e1a1..fef8a6089b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role/role.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role/role.module.ts @@ -6,7 +6,6 @@ import { ApplicationEntity } from 'src/engine/core-modules/application/applicati import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { FileModule } from 'src/engine/core-modules/file/file.module'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; -import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; import { AiAgentRoleModule } from 'src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.module'; import { FlatAgentModule } from 'src/engine/metadata-modules/flat-agent/flat-agent.module'; @@ -24,6 +23,7 @@ import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { RoleResolver } from 'src/engine/metadata-modules/role/role.resolver'; import { RoleService } from 'src/engine/metadata-modules/role/role.service'; import { WorkspaceFlatRoleMapCacheService } from 'src/engine/metadata-modules/role/services/workspace-flat-role-map-cache.service'; +import { RoleToolWorkspaceService } from 'src/engine/metadata-modules/role/tools/services/role-tool.workspace-service'; import { WorkspaceRolesPermissionsCacheService } from 'src/engine/metadata-modules/role/services/workspace-roles-permissions-cache.service'; import { RowLevelPermissionPredicateGroupEntity } from 'src/engine/metadata-modules/row-level-permission-predicate/entities/row-level-permission-predicate-group.entity'; import { RowLevelPermissionPredicateEntity } from 'src/engine/metadata-modules/row-level-permission-predicate/entities/row-level-permission-predicate.entity'; @@ -59,7 +59,6 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace RowLevelPermissionModule, WorkspaceManyOrAllFlatEntityMapsCacheModule, WorkspaceMigrationModule, - UserWorkspaceModule, FileModule, ApplicationModule, WorkspaceCacheModule, @@ -68,6 +67,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace providers: [ RoleService, RoleResolver, + RoleToolWorkspaceService, WorkspaceFlatRoleMapCacheService, WorkspaceFlatRoleTargetMapCacheService, WorkspaceMigrationGraphqlApiExceptionInterceptor, @@ -82,6 +82,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace ], exports: [ RoleService, + RoleToolWorkspaceService, WorkspaceFlatRoleMapCacheService, WorkspaceFlatRoleTargetMapCacheService, ], diff --git a/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts index 3563150cca..730db2bb9f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts @@ -6,7 +6,6 @@ import { } from '@nestjs/common'; import { Args, Mutation, Parent, Query, ResolveField } from '@nestjs/graphql'; -import { msg } from '@lingui/core/macro'; import { PermissionFlagType } from 'twenty-shared/constants'; import { isDefined } from 'twenty-shared/utils'; @@ -16,10 +15,9 @@ import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api- import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; -import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { WorkspaceMemberDTO } from 'src/engine/core-modules/user/dtos/workspace-member.dto'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; -import { AuthWorkspaceMemberId } from 'src/engine/decorators/auth/auth-workspace-member-id.decorator'; +import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; @@ -44,11 +42,6 @@ import { RolePermissionFlagDTO } from 'src/engine/metadata-modules/role-permissi import { UpsertPermissionFlagsInput } from 'src/engine/metadata-modules/role-permission-flag/dtos/upsert-permission-flags.input'; import { RolePermissionFlagService } from 'src/engine/metadata-modules/role-permission-flag/role-permission-flag.service'; import { fromFlatRolePermissionFlagToRolePermissionFlagDto } from 'src/engine/metadata-modules/role-permission-flag/utils/from-flat-role-permission-flag-to-role-permission-flag-dto.util'; -import { - PermissionsException, - PermissionsExceptionCode, - PermissionsExceptionMessage, -} from 'src/engine/metadata-modules/permissions/permissions.exception'; import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; import { CreateRoleInput } from 'src/engine/metadata-modules/role/dtos/create-role.input'; import { @@ -85,7 +78,6 @@ export class RoleResolver { constructor( private readonly userRoleService: UserRoleService, private readonly roleService: RoleService, - private readonly userWorkspaceService: UserWorkspaceService, private readonly objectPermissionService: ObjectPermissionService, private readonly rolePermissionFlagService: RolePermissionFlagService, private readonly agentRoleService: AiAgentRoleService, @@ -112,50 +104,30 @@ export class RoleResolver { @Args('workspaceMemberId', { type: () => UUIDScalarType }) workspaceMemberId: string, @Args('roleId', { type: () => UUIDScalarType }) roleId: string, - @AuthWorkspaceMemberId() - updatorWorkspaceMemberId: string, + @AuthUserWorkspaceId() + actingUserWorkspaceId: string, ): Promise { - if (updatorWorkspaceMemberId === workspaceMemberId) { - throw new PermissionsException( - PermissionsExceptionMessage.CANNOT_UPDATE_SELF_ROLE, - PermissionsExceptionCode.CANNOT_UPDATE_SELF_ROLE, - { - userFriendlyMessage: msg`You cannot change your own role. Please ask another administrator to update your role.`, - }, - ); - } - - const workspaceMember = - await this.userWorkspaceService.getWorkspaceMemberOrThrow({ + const { workspaceMember, userWorkspaceId } = + await this.userRoleService.assignRoleToWorkspaceMember({ + workspaceId: workspace.id, workspaceMemberId, - workspaceId: workspace.id, + roleId, + actingUserWorkspaceId, }); - const userWorkspace = - await this.userWorkspaceService.getUserWorkspaceForUserOrThrow({ - userId: workspaceMember.userId, - workspaceId: workspace.id, - }); - - await this.userRoleService.assignRoleToManyUserWorkspace({ - userWorkspaceIds: [userWorkspace.id], - workspaceId: workspace.id, - roleId, - }); - const roles = await this.userRoleService .getRolesByUserWorkspaces({ - userWorkspaceIds: [userWorkspace.id], + userWorkspaceIds: [userWorkspaceId], workspaceId: workspace.id, }) .then( (rolesByUserWorkspaces) => - rolesByUserWorkspaces?.get(userWorkspace.id) ?? [], + rolesByUserWorkspaces?.get(userWorkspaceId) ?? [], ); return { ...workspaceMember, - userWorkspaceId: userWorkspace.id, + userWorkspaceId, roles, } as WorkspaceMemberDTO; } @@ -184,10 +156,16 @@ export class RoleResolver { async updateOneRole( @AuthWorkspace() workspace: WorkspaceEntity, @Args('updateRoleInput') updateRoleInput: UpdateRoleInput, + @AuthUserWorkspaceId({ allowUndefined: true }) + actingUserWorkspaceId?: string, ): Promise { const role = await this.roleService.updateRole({ input: updateRoleInput, workspaceId: workspace.id, + actingRoleIds: await this.getActingRoleIds({ + workspaceId: workspace.id, + actingUserWorkspaceId, + }), }); return role; @@ -197,15 +175,42 @@ export class RoleResolver { async deleteOneRole( @AuthWorkspace() workspace: WorkspaceEntity, @Args('roleId', { type: () => UUIDScalarType }) roleId: string, + @AuthUserWorkspaceId({ allowUndefined: true }) + actingUserWorkspaceId?: string, ): Promise { const deletedRole = await this.roleService.deleteRole({ roleId, workspaceId: workspace.id, + actingRoleIds: await this.getActingRoleIds({ + workspaceId: workspace.id, + actingUserWorkspaceId, + }), }); return deletedRole.id; } + // API-key callers have no user workspace; lockout protection only applies to + // human actors, so they resolve to no acting roles. + private async getActingRoleIds({ + workspaceId, + actingUserWorkspaceId, + }: { + workspaceId: string; + actingUserWorkspaceId?: string; + }): Promise { + if (!isDefined(actingUserWorkspaceId)) { + return undefined; + } + + return [ + await this.userRoleService.getRoleIdForUserWorkspace({ + workspaceId, + userWorkspaceId: actingUserWorkspaceId, + }), + ]; + } + @Mutation(() => [ObjectPermissionDTO]) async upsertObjectPermissions( @AuthWorkspace() workspace: WorkspaceEntity, diff --git a/packages/twenty-server/src/engine/metadata-modules/role/role.service.ts b/packages/twenty-server/src/engine/metadata-modules/role/role.service.ts index 4abadef46a..33e56e4abf 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role/role.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role/role.service.ts @@ -19,6 +19,7 @@ import { AiExceptionCode, } from 'src/engine/metadata-modules/ai/ai.exception'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util'; import { findManyFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps.util'; @@ -39,6 +40,10 @@ import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto'; import { type UpdateRoleInput } from 'src/engine/metadata-modules/role/dtos/update-role.input'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { fromFlatRoleToRoleDto } from 'src/engine/metadata-modules/role/utils/fromFlatRoleToRoleDto.util'; +import { + validateRoleDeletionDoesNotLockOutActorOrThrow, + validateRoleUpdateDoesNotLockOutActorOrThrow, +} from 'src/engine/metadata-modules/role/utils/validate-role-mutation-does-not-lock-out-actor.util'; import { fromFlatRolePermissionFlagToRolePermissionFlagDto } from 'src/engine/metadata-modules/role-permission-flag/utils/from-flat-role-permission-flag-to-role-permission-flag-dto.util'; import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; @@ -228,10 +233,12 @@ export class RoleService { input, workspaceId, ownerFlatApplication, + actingRoleIds, }: { input: UpdateRoleInput; workspaceId: string; ownerFlatApplication?: FlatApplication; + actingRoleIds?: string[]; }): Promise { const resolvedOwnerFlatApplication = ownerFlatApplication ?? @@ -241,11 +248,11 @@ export class RoleService { ) ).workspaceCustomFlatApplication; - const { flatRoleMaps: existingFlatRoleMaps } = + const { flatRoleMaps: existingFlatRoleMaps, flatRolePermissionFlagMaps } = await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( { workspaceId, - flatMapsKeys: ['flatRoleMaps'], + flatMapsKeys: ['flatRoleMaps', 'flatRolePermissionFlagMaps'], }, ); @@ -254,6 +261,20 @@ export class RoleService { updateRoleInput: input, }); + const existingFlatRole = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: input.id, + flatEntityMaps: existingFlatRoleMaps, + }); + + if (isDefined(existingFlatRole)) { + validateRoleUpdateDoesNotLockOutActorOrThrow({ + flatRole: existingFlatRole, + canUpdateAllSettingsUpdate: input.update.canUpdateAllSettings, + actingRoleIds, + flatRolePermissionFlagMaps, + }); + } + const validateAndBuildResult = await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( { @@ -298,16 +319,19 @@ export class RoleService { roleId, workspaceId, ownerFlatApplication, + actingRoleIds, }: { roleId: string; workspaceId: string; ownerFlatApplication?: FlatApplication; + actingRoleIds?: string[]; }): Promise { const deletedRoles = await this.deleteManyRoles({ ids: [roleId], workspaceId, isSystemBuild: false, ownerFlatApplication, + actingRoleIds, }); const [deletedRole] = deletedRoles; @@ -320,11 +344,13 @@ export class RoleService { workspaceId, isSystemBuild = false, ownerFlatApplication, + actingRoleIds, }: { ids: string[]; workspaceId: string; isSystemBuild?: boolean; ownerFlatApplication?: FlatApplication; + actingRoleIds?: string[]; }): Promise { if (ids.length === 0) { return []; @@ -372,6 +398,11 @@ export class RoleService { roleId, }); + validateRoleDeletionDoesNotLockOutActorOrThrow({ + flatRole: flatRoleToDelete, + actingRoleIds, + }); + if (defaultRoleId === roleId) { throw new PermissionsException( PermissionsExceptionMessage.DEFAULT_ROLE_CANNOT_BE_DELETED, diff --git a/packages/twenty-server/src/engine/metadata-modules/role/tools/assign-role-to-workspace-member.tool.ts b/packages/twenty-server/src/engine/metadata-modules/role/tools/assign-role-to-workspace-member.tool.ts new file mode 100644 index 0000000000..41ad86decc --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/role/tools/assign-role-to-workspace-member.tool.ts @@ -0,0 +1,56 @@ +import { z } from 'zod'; + +import { type RoleToolContext } from 'src/engine/metadata-modules/role/tools/types/role-tool-context.type'; +import { type RoleToolDependencies } from 'src/engine/metadata-modules/role/tools/types/role-tool-dependencies.type'; +import { toRoleToolErrorMessage } from 'src/engine/metadata-modules/role/tools/utils/to-role-tool-error-message.util'; + +const assignRoleToWorkspaceMemberSchema = z.object({ + workspaceMemberId: z + .uuid() + .describe('Id of the workspace member to assign the role to'), + roleId: z.uuid().describe('Id of the role to assign'), +}); + +type AssignRoleToWorkspaceMemberParams = z.infer< + typeof assignRoleToWorkspaceMemberSchema +>; + +export const createAssignRoleToWorkspaceMemberTool = ( + deps: Pick, + context: RoleToolContext, +) => ({ + name: 'assign_role_to_workspace_member' as const, + description: `Assign a role to a workspace member, replacing their current role. + +You cannot change your own role, assign a role that does not allow user assignment (canBeAssignedToUsers=false), or remove the admin role from the last administrator.`, + inputSchema: assignRoleToWorkspaceMemberSchema, + execute: async (parameters: AssignRoleToWorkspaceMemberParams) => { + try { + const { workspaceMember } = + await deps.userRoleService.assignRoleToWorkspaceMember({ + workspaceId: context.workspaceId, + workspaceMemberId: parameters.workspaceMemberId, + roleId: parameters.roleId, + actingUserWorkspaceId: context.callerUserWorkspaceId, + }); + + return { + success: true, + message: + `Role assigned to workspace member ${workspaceMember.name?.firstName ?? ''} ${workspaceMember.name?.lastName ?? ''}`.trim(), + result: { + workspaceMemberId: parameters.workspaceMemberId, + roleId: parameters.roleId, + }, + }; + } catch (error) { + const message = toRoleToolErrorMessage(error); + + return { + success: false, + message: `Failed to assign role: ${message}`, + error: message, + }; + } + }, +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/role/tools/create-role.tool.ts b/packages/twenty-server/src/engine/metadata-modules/role/tools/create-role.tool.ts new file mode 100644 index 0000000000..ba423dded3 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/role/tools/create-role.tool.ts @@ -0,0 +1,59 @@ +import { z } from 'zod'; + +import { rolePermissionsSchema } from 'src/engine/metadata-modules/role/tools/schemas/role-permissions.schema'; +import { type RoleToolContext } from 'src/engine/metadata-modules/role/tools/types/role-tool-context.type'; +import { type RoleToolDependencies } from 'src/engine/metadata-modules/role/tools/types/role-tool-dependencies.type'; +import { toRoleSummary } from 'src/engine/metadata-modules/role/tools/utils/to-role-summary.util'; +import { toRoleToolErrorMessage } from 'src/engine/metadata-modules/role/tools/utils/to-role-tool-error-message.util'; + +const createRoleSchema = rolePermissionsSchema.extend({ + label: z.string().min(1).describe('Display name of the role'), + description: z.string().optional().describe('Optional role description'), + icon: z + .string() + .optional() + .describe('Optional icon identifier (e.g. "IconUser")'), +}); + +type CreateRoleParams = z.infer; + +export const createCreateRoleTool = ( + deps: Pick, + context: RoleToolContext, +) => ({ + name: 'create_role' as const, + description: `Create a new role in this workspace. + +Global record permissions (canReadAllObjectRecords, canUpdateAllObjectRecords, ...) and settings access default to false; assignability to users, agents and API keys defaults to true. +After creating the role, use upsert_object_permissions to set per-object overrides and upsert_row_level_permission_rules to restrict which records are visible. +Granting write access on an object without read access is invalid.`, + inputSchema: createRoleSchema, + execute: async (parameters: CreateRoleParams) => { + try { + const { workspaceCustomFlatApplication } = + await deps.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { workspaceId: context.workspaceId }, + ); + + const role = await deps.roleService.createRole({ + workspaceId: context.workspaceId, + input: parameters, + ownerFlatApplication: workspaceCustomFlatApplication, + }); + + return { + success: true, + message: `Role "${role.label}" created`, + result: toRoleSummary(role), + }; + } catch (error) { + const message = toRoleToolErrorMessage(error); + + return { + success: false, + message: `Failed to create role: ${message}`, + error: message, + }; + } + }, +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/role/tools/delete-role.tool.ts b/packages/twenty-server/src/engine/metadata-modules/role/tools/delete-role.tool.ts new file mode 100644 index 0000000000..4f3e51f410 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/role/tools/delete-role.tool.ts @@ -0,0 +1,45 @@ +import { z } from 'zod'; + +import { type RoleToolContext } from 'src/engine/metadata-modules/role/tools/types/role-tool-context.type'; +import { type RoleToolDependencies } from 'src/engine/metadata-modules/role/tools/types/role-tool-dependencies.type'; +import { toRoleToolErrorMessage } from 'src/engine/metadata-modules/role/tools/utils/to-role-tool-error-message.util'; + +const deleteRoleSchema = z.object({ + roleId: z.uuid().describe('Id of the role to delete'), +}); + +type DeleteRoleParams = z.infer; + +export const createDeleteRoleTool = ( + deps: Pick, + context: RoleToolContext, +) => ({ + name: 'delete_role' as const, + description: `Delete a role. Members, agents and API keys assigned to it are reassigned to the workspace default role. + +System-managed roles (isEditable=false, like Admin), the workspace default role, and roles you are assigned to cannot be deleted.`, + inputSchema: deleteRoleSchema, + execute: async (parameters: DeleteRoleParams) => { + try { + const deletedRole = await deps.roleService.deleteRole({ + roleId: parameters.roleId, + workspaceId: context.workspaceId, + actingRoleIds: context.callerRoleIds, + }); + + return { + success: true, + message: `Role "${deletedRole.label}" deleted`, + result: { id: deletedRole.id, label: deletedRole.label }, + }; + } catch (error) { + const message = toRoleToolErrorMessage(error); + + return { + success: false, + message: `Failed to delete role: ${message}`, + error: message, + }; + } + }, +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/role/tools/list-roles.tool.ts b/packages/twenty-server/src/engine/metadata-modules/role/tools/list-roles.tool.ts new file mode 100644 index 0000000000..2df8d1703d --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/role/tools/list-roles.tool.ts @@ -0,0 +1,83 @@ +import { z } from 'zod'; + +import { type RoleToolContext } from 'src/engine/metadata-modules/role/tools/types/role-tool-context.type'; +import { type RoleToolDependencies } from 'src/engine/metadata-modules/role/tools/types/role-tool-dependencies.type'; +import { toRoleSummary } from 'src/engine/metadata-modules/role/tools/utils/to-role-summary.util'; +import { toRoleToolErrorMessage } from 'src/engine/metadata-modules/role/tools/utils/to-role-tool-error-message.util'; + +const listRolesSchema = z.object({ + includeRowLevelPermissionRules: z + .boolean() + .optional() + .describe( + "When true, include each role's row-level permission predicates and predicate groups (enterprise feature; empty when disabled).", + ), +}); + +type ListRolesParams = z.infer; + +export const createListRolesTool = ( + deps: Pick< + RoleToolDependencies, + | 'roleService' + | 'rowLevelPermissionPredicateService' + | 'rowLevelPermissionPredicateGroupService' + >, + context: RoleToolContext, +) => ({ + name: 'list_roles' as const, + description: `List all roles of this workspace with their permissions. + +Returns for each role: global record permissions (canReadAllObjectRecords, ...), settings access (canUpdateAllSettings), per-object permission overrides, permission flags, and assignability (users, agents, API keys). +Roles with isEditable=false (like Admin) are system-managed and cannot be changed.`, + inputSchema: listRolesSchema, + execute: async (parameters: ListRolesParams) => { + try { + if (!parameters.includeRowLevelPermissionRules) { + const roles = await deps.roleService.getWorkspaceRoles( + context.workspaceId, + ); + + return { + success: true, + message: `Found ${roles.length} role${roles.length === 1 ? '' : 's'}`, + result: { roles: roles.map(toRoleSummary) }, + }; + } + + const [roles, allPredicates, allPredicateGroups] = await Promise.all([ + deps.roleService.getWorkspaceRoles(context.workspaceId), + deps.rowLevelPermissionPredicateService.findByWorkspaceId( + context.workspaceId, + ), + deps.rowLevelPermissionPredicateGroupService.findByWorkspaceId( + context.workspaceId, + ), + ]); + + const rolesWithRules = roles.map((role) => ({ + ...toRoleSummary(role), + rowLevelPermissionPredicates: allPredicates.filter( + (predicate) => predicate.roleId === role.id, + ), + rowLevelPermissionPredicateGroups: allPredicateGroups.filter( + (predicateGroup) => predicateGroup.roleId === role.id, + ), + })); + + return { + success: true, + message: `Found ${roles.length} role${roles.length === 1 ? '' : 's'}`, + result: { roles: rolesWithRules }, + }; + } catch (error) { + const message = toRoleToolErrorMessage(error); + + return { + success: false, + message: `Failed to list roles: ${message}`, + error: message, + }; + } + }, +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/role/tools/schemas/role-permissions.schema.ts b/packages/twenty-server/src/engine/metadata-modules/role/tools/schemas/role-permissions.schema.ts new file mode 100644 index 0000000000..5049647ed0 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/role/tools/schemas/role-permissions.schema.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; + +export const rolePermissionsSchema = z.object({ + canUpdateAllSettings: z + .boolean() + .optional() + .describe('Grants full settings/admin access'), + canAccessAllTools: z + .boolean() + .optional() + .describe('Grants access to all workspace tools'), + canReadAllObjectRecords: z + .boolean() + .optional() + .describe('Default read access on all objects'), + canUpdateAllObjectRecords: z + .boolean() + .optional() + .describe('Default update access on all objects'), + canSoftDeleteAllObjectRecords: z + .boolean() + .optional() + .describe('Default soft-delete access on all objects'), + canDestroyAllObjectRecords: z + .boolean() + .optional() + .describe('Default destroy access on all objects'), + canBeAssignedToUsers: z + .boolean() + .optional() + .describe('Whether the role can be assigned to users'), + canBeAssignedToAgents: z + .boolean() + .optional() + .describe('Whether the role can be assigned to AI agents'), + canBeAssignedToApiKeys: z + .boolean() + .optional() + .describe('Whether the role can be assigned to API keys'), +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/role/tools/services/role-tool.workspace-service.ts b/packages/twenty-server/src/engine/metadata-modules/role/tools/services/role-tool.workspace-service.ts new file mode 100644 index 0000000000..27cfd4b257 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/role/tools/services/role-tool.workspace-service.ts @@ -0,0 +1,69 @@ +import { Injectable } from '@nestjs/common'; + +import { type ToolSet } from 'ai'; + +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { ObjectPermissionService } from 'src/engine/metadata-modules/object-permission/object-permission.service'; +import { RoleService } from 'src/engine/metadata-modules/role/role.service'; +import { createAssignRoleToWorkspaceMemberTool } from 'src/engine/metadata-modules/role/tools/assign-role-to-workspace-member.tool'; +import { createCreateRoleTool } from 'src/engine/metadata-modules/role/tools/create-role.tool'; +import { createDeleteRoleTool } from 'src/engine/metadata-modules/role/tools/delete-role.tool'; +import { createListRolesTool } from 'src/engine/metadata-modules/role/tools/list-roles.tool'; +import { type RoleToolContext } from 'src/engine/metadata-modules/role/tools/types/role-tool-context.type'; +import { type RoleToolDependencies } from 'src/engine/metadata-modules/role/tools/types/role-tool-dependencies.type'; +import { createUpdateRoleTool } from 'src/engine/metadata-modules/role/tools/update-role.tool'; +import { createUpsertObjectPermissionsTool } from 'src/engine/metadata-modules/role/tools/upsert-object-permissions.tool'; +import { createUpsertRowLevelPermissionRulesTool } from 'src/engine/metadata-modules/role/tools/upsert-row-level-permission-rules.tool'; +import { RowLevelPermissionPredicateGroupService } from 'src/engine/metadata-modules/row-level-permission-predicate/services/row-level-permission-predicate-group.service'; +import { RowLevelPermissionPredicateService } from 'src/engine/metadata-modules/row-level-permission-predicate/services/row-level-permission-predicate.service'; +import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; + +@Injectable() +export class RoleToolWorkspaceService { + private readonly deps: RoleToolDependencies; + + constructor( + roleService: RoleService, + userRoleService: UserRoleService, + objectPermissionService: ObjectPermissionService, + rowLevelPermissionPredicateService: RowLevelPermissionPredicateService, + rowLevelPermissionPredicateGroupService: RowLevelPermissionPredicateGroupService, + applicationService: ApplicationService, + ) { + this.deps = { + roleService, + userRoleService, + objectPermissionService, + rowLevelPermissionPredicateService, + rowLevelPermissionPredicateGroupService, + applicationService, + }; + } + + generateRoleTools(context: RoleToolContext): ToolSet { + const listRoles = createListRolesTool(this.deps, context); + const createRole = createCreateRoleTool(this.deps, context); + const updateRole = createUpdateRoleTool(this.deps, context); + const deleteRole = createDeleteRoleTool(this.deps, context); + const assignRoleToWorkspaceMember = createAssignRoleToWorkspaceMemberTool( + this.deps, + context, + ); + const upsertObjectPermissions = createUpsertObjectPermissionsTool( + this.deps, + context, + ); + const upsertRowLevelPermissionRules = + createUpsertRowLevelPermissionRulesTool(this.deps, context); + + return { + [listRoles.name]: listRoles, + [createRole.name]: createRole, + [updateRole.name]: updateRole, + [deleteRole.name]: deleteRole, + [assignRoleToWorkspaceMember.name]: assignRoleToWorkspaceMember, + [upsertObjectPermissions.name]: upsertObjectPermissions, + [upsertRowLevelPermissionRules.name]: upsertRowLevelPermissionRules, + }; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/role/tools/types/role-tool-context.type.ts b/packages/twenty-server/src/engine/metadata-modules/role/tools/types/role-tool-context.type.ts new file mode 100644 index 0000000000..e9fe17ee82 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/role/tools/types/role-tool-context.type.ts @@ -0,0 +1,7 @@ +// Caller identity forwarded to the services, which enforce the caller-aware +// rules (self role change, lockout protection). +export type RoleToolContext = { + workspaceId: string; + callerRoleIds: string[]; + callerUserWorkspaceId?: string; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/role/tools/types/role-tool-dependencies.type.ts b/packages/twenty-server/src/engine/metadata-modules/role/tools/types/role-tool-dependencies.type.ts new file mode 100644 index 0000000000..2ce4830c2a --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/role/tools/types/role-tool-dependencies.type.ts @@ -0,0 +1,15 @@ +import type { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import type { ObjectPermissionService } from 'src/engine/metadata-modules/object-permission/object-permission.service'; +import type { RoleService } from 'src/engine/metadata-modules/role/role.service'; +import type { RowLevelPermissionPredicateGroupService } from 'src/engine/metadata-modules/row-level-permission-predicate/services/row-level-permission-predicate-group.service'; +import type { RowLevelPermissionPredicateService } from 'src/engine/metadata-modules/row-level-permission-predicate/services/row-level-permission-predicate.service'; +import type { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; + +export type RoleToolDependencies = { + roleService: RoleService; + userRoleService: UserRoleService; + objectPermissionService: ObjectPermissionService; + rowLevelPermissionPredicateService: RowLevelPermissionPredicateService; + rowLevelPermissionPredicateGroupService: RowLevelPermissionPredicateGroupService; + applicationService: ApplicationService; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/role/tools/update-role.tool.ts b/packages/twenty-server/src/engine/metadata-modules/role/tools/update-role.tool.ts new file mode 100644 index 0000000000..965d6cf89e --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/role/tools/update-role.tool.ts @@ -0,0 +1,57 @@ +import { z } from 'zod'; + +import { rolePermissionsSchema } from 'src/engine/metadata-modules/role/tools/schemas/role-permissions.schema'; +import { type RoleToolContext } from 'src/engine/metadata-modules/role/tools/types/role-tool-context.type'; +import { type RoleToolDependencies } from 'src/engine/metadata-modules/role/tools/types/role-tool-dependencies.type'; +import { toRoleSummary } from 'src/engine/metadata-modules/role/tools/utils/to-role-summary.util'; +import { toRoleToolErrorMessage } from 'src/engine/metadata-modules/role/tools/utils/to-role-tool-error-message.util'; + +const updateRoleSchema = z.object({ + roleId: z.uuid().describe('Id of the role to update'), + update: rolePermissionsSchema + .extend({ + label: z.string().min(1).optional().describe('New display name'), + description: z.string().optional().describe('New description'), + icon: z.string().optional().describe('New icon identifier'), + }) + .describe('Fields to change; omitted fields are left untouched'), +}); + +type UpdateRoleParams = z.infer; + +export const createUpdateRoleTool = ( + deps: Pick, + context: RoleToolContext, +) => ({ + name: 'update_role' as const, + description: `Update an existing role's label, description, icon, global record permissions, settings access, or assignability. + +System-managed roles (isEditable=false, like Admin) cannot be updated. Updates that would remove your own access to role management are rejected.`, + inputSchema: updateRoleSchema, + execute: async (parameters: UpdateRoleParams) => { + try { + const role = await deps.roleService.updateRole({ + workspaceId: context.workspaceId, + input: { + id: parameters.roleId, + update: parameters.update, + }, + actingRoleIds: context.callerRoleIds, + }); + + return { + success: true, + message: `Role "${role.label}" updated`, + result: toRoleSummary(role), + }; + } catch (error) { + const message = toRoleToolErrorMessage(error); + + return { + success: false, + message: `Failed to update role: ${message}`, + error: message, + }; + } + }, +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/role/tools/upsert-object-permissions.tool.ts b/packages/twenty-server/src/engine/metadata-modules/role/tools/upsert-object-permissions.tool.ts new file mode 100644 index 0000000000..974ce22e21 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/role/tools/upsert-object-permissions.tool.ts @@ -0,0 +1,84 @@ +import { z } from 'zod'; + +import { type RoleToolContext } from 'src/engine/metadata-modules/role/tools/types/role-tool-context.type'; +import { type RoleToolDependencies } from 'src/engine/metadata-modules/role/tools/types/role-tool-dependencies.type'; +import { toObjectPermissionSummary } from 'src/engine/metadata-modules/role/tools/utils/to-role-summary.util'; +import { toRoleToolErrorMessage } from 'src/engine/metadata-modules/role/tools/utils/to-role-tool-error-message.util'; + +const upsertObjectPermissionsSchema = z.object({ + roleId: z.uuid().describe('Id of the role to set overrides on'), + objectPermissions: z + .array( + z.object({ + objectMetadataId: z + .uuid() + .describe('Id of the object metadata the override applies to'), + canReadObjectRecords: z + .boolean() + .optional() + .describe('Override read access for this object'), + canUpdateObjectRecords: z + .boolean() + .optional() + .describe('Override update access for this object'), + canSoftDeleteObjectRecords: z + .boolean() + .optional() + .describe('Override soft-delete access for this object'), + canDestroyObjectRecords: z + .boolean() + .optional() + .describe('Override destroy access for this object'), + }), + ) + .min(1) + .describe( + "The complete set of per-object overrides to keep on the role. Overrides for objects omitted from this list are removed and fall back to the role's global permissions.", + ), +}); + +type UpsertObjectPermissionsParams = z.infer< + typeof upsertObjectPermissionsSchema +>; + +export const createUpsertObjectPermissionsTool = ( + deps: Pick, + context: RoleToolContext, +) => ({ + name: 'upsert_object_permissions' as const, + description: `Set per-object permission overrides on a role, e.g. make an object read-only for that role. + +IMPORTANT: this replaces the role's full override list. Include every override you want to keep; objects omitted from the list revert to the role's global permissions (canReadAllObjectRecords, ...). Use list_roles first to see current overrides. +Example read-only override: { objectMetadataId, canReadObjectRecords: true, canUpdateObjectRecords: false, canSoftDeleteObjectRecords: false, canDestroyObjectRecords: false }. +Granting write access without read access is rejected. System-managed roles (like Admin) cannot be changed.`, + inputSchema: upsertObjectPermissionsSchema, + execute: async (parameters: UpsertObjectPermissionsParams) => { + try { + const objectPermissions = + await deps.objectPermissionService.upsertObjectPermissions({ + workspaceId: context.workspaceId, + input: { + roleId: parameters.roleId, + objectPermissions: parameters.objectPermissions, + }, + }); + + return { + success: true, + message: 'Object permissions updated', + result: { + roleId: parameters.roleId, + objectPermissions: objectPermissions.map(toObjectPermissionSummary), + }, + }; + } catch (error) { + const message = toRoleToolErrorMessage(error); + + return { + success: false, + message: `Failed to upsert object permissions: ${message}`, + error: message, + }; + } + }, +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/role/tools/upsert-row-level-permission-rules.tool.ts b/packages/twenty-server/src/engine/metadata-modules/role/tools/upsert-row-level-permission-rules.tool.ts new file mode 100644 index 0000000000..3b29fe52e0 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/role/tools/upsert-row-level-permission-rules.tool.ts @@ -0,0 +1,159 @@ +import { + RowLevelPermissionPredicateGroupLogicalOperator, + RowLevelPermissionPredicateOperand, +} from 'twenty-shared/types'; +import { z } from 'zod'; + +import { type RoleToolContext } from 'src/engine/metadata-modules/role/tools/types/role-tool-context.type'; +import { type RoleToolDependencies } from 'src/engine/metadata-modules/role/tools/types/role-tool-dependencies.type'; +import { toRoleToolErrorMessage } from 'src/engine/metadata-modules/role/tools/utils/to-role-tool-error-message.util'; + +const predicateValueSchema = z + .union([ + z.string(), + z.array(z.string()), + z.boolean(), + z.number(), + z.record(z.string(), z.unknown()), + z.null(), + ]) + .optional() + .describe( + 'Static value to compare the field against. Omit when using workspaceMemberFieldMetadataId or a value-less operand (IS_EMPTY, IS_IN_PAST, ...).', + ); + +const rowLevelPermissionPredicateSchema = z.object({ + id: z + .uuid() + .optional() + .describe( + 'Id of an existing predicate to update. Omit to create a new one.', + ), + fieldMetadataId: z + .uuid() + .describe('Id of the field (on the target object) the rule filters on'), + operand: z + .enum(RowLevelPermissionPredicateOperand) + .describe('Comparison operator'), + value: predicateValueSchema, + subFieldName: z + .string() + .nullable() + .optional() + .describe( + 'Sub-field for composite fields (e.g. "firstName" of a FULL_NAME field)', + ), + workspaceMemberFieldMetadataId: z + .uuid() + .nullable() + .optional() + .describe( + 'Dynamic comparison: id of a field on the workspaceMember object whose value for the CURRENT user is substituted at query time. Use the workspaceMember "id" field to express "matches the current user".', + ), + workspaceMemberSubFieldName: z + .string() + .nullable() + .optional() + .describe( + 'Sub-field of the workspace member field when it is a composite field', + ), + rowLevelPermissionPredicateGroupId: z + .uuid() + .nullable() + .optional() + .describe('Id of the predicate group this predicate belongs to'), + positionInRowLevelPermissionPredicateGroup: z + .number() + .nullable() + .optional() + .describe('Position of this predicate within its group'), +}); + +const rowLevelPermissionPredicateGroupSchema = z.object({ + id: z + .uuid() + .optional() + .describe( + 'Group id. Pass an existing id to update a group, or a new client-generated UUID so predicates can reference the group via rowLevelPermissionPredicateGroupId.', + ), + logicalOperator: z + .enum(RowLevelPermissionPredicateGroupLogicalOperator) + .describe('How predicates inside this group are combined (AND / OR)'), + parentRowLevelPermissionPredicateGroupId: z + .uuid() + .nullable() + .optional() + .describe('Parent group id for nested groups'), + positionInRowLevelPermissionPredicateGroup: z + .number() + .nullable() + .optional() + .describe('Position of this group within its parent'), +}); + +const upsertRowLevelPermissionRulesSchema = z.object({ + roleId: z.uuid().describe('Id of the role the rules apply to'), + objectMetadataId: z.uuid().describe('Id of the object the rules restrict'), + predicates: z + .array(rowLevelPermissionPredicateSchema) + .describe( + 'The complete list of predicates to keep for this role and object. Existing predicates omitted from the list are deleted; pass an empty array to remove all rules.', + ), + predicateGroups: z + .array(rowLevelPermissionPredicateGroupSchema) + .describe( + 'The complete list of predicate groups to keep for this role and object. Existing groups omitted from the list are deleted.', + ), +}); + +type UpsertRowLevelPermissionRulesParams = z.infer< + typeof upsertRowLevelPermissionRulesSchema +>; + +export const createUpsertRowLevelPermissionRulesTool = ( + deps: Pick, + context: RoleToolContext, +) => ({ + name: 'upsert_row_level_permission_rules' as const, + description: `Set row-level permission rules restricting which records members with a role can see on a given object (enterprise feature). + +Example, "members with this role only see records where the owner field matches the current user": pass one predicate with fieldMetadataId = the owner field on the object, operand = IS, and workspaceMemberFieldMetadataId = the "id" field of the workspaceMember object (resolved to the current user at query time). Use metadata tools to look up field ids. +Combine several predicates with predicateGroups (AND / OR): give each new group a client-generated UUID and reference it from predicates via rowLevelPermissionPredicateGroupId. +IMPORTANT: this replaces the full rule set for the role + object. Predicates or groups omitted from the lists are deleted; empty lists clear all rules. System-managed roles (like Admin) cannot be changed.`, + inputSchema: upsertRowLevelPermissionRulesSchema, + execute: async (parameters: UpsertRowLevelPermissionRulesParams) => { + try { + const { predicates, predicateGroups } = + await deps.rowLevelPermissionPredicateService.upsertRowLevelPermissionPredicates( + { + workspaceId: context.workspaceId, + input: { + roleId: parameters.roleId, + objectMetadataId: parameters.objectMetadataId, + predicates: parameters.predicates, + predicateGroups: parameters.predicateGroups.map( + (predicateGroup) => ({ + ...predicateGroup, + objectMetadataId: parameters.objectMetadataId, + }), + ), + }, + }, + ); + + return { + success: true, + message: `Row-level permission rules updated (${predicates.length} predicate${predicates.length === 1 ? '' : 's'}, ${predicateGroups.length} group${predicateGroups.length === 1 ? '' : 's'})`, + result: { predicates, predicateGroups }, + }; + } catch (error) { + const message = toRoleToolErrorMessage(error); + + return { + success: false, + message: `Failed to upsert row-level permission rules: ${message}`, + error: message, + }; + } + }, +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/role/tools/utils/to-role-summary.util.ts b/packages/twenty-server/src/engine/metadata-modules/role/tools/utils/to-role-summary.util.ts new file mode 100644 index 0000000000..725a12a94a --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/role/tools/utils/to-role-summary.util.ts @@ -0,0 +1,42 @@ +import { type RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto'; + +type ObjectPermissionFields = { + objectMetadataId: string; + canReadObjectRecords?: boolean; + canUpdateObjectRecords?: boolean; + canSoftDeleteObjectRecords?: boolean; + canDestroyObjectRecords?: boolean; +}; + +// Narrows to the permission booleans the model needs, dropping restrictedFields +// and the nested row-level predicate arrays that would bloat the tool output. +export const toObjectPermissionSummary = ( + objectPermission: ObjectPermissionFields, +) => ({ + objectMetadataId: objectPermission.objectMetadataId, + canReadObjectRecords: objectPermission.canReadObjectRecords, + canUpdateObjectRecords: objectPermission.canUpdateObjectRecords, + canSoftDeleteObjectRecords: objectPermission.canSoftDeleteObjectRecords, + canDestroyObjectRecords: objectPermission.canDestroyObjectRecords, +}); + +export const toRoleSummary = (role: RoleDTO) => ({ + id: role.id, + label: role.label, + description: role.description, + icon: role.icon, + isEditable: role.isEditable, + canUpdateAllSettings: role.canUpdateAllSettings, + canAccessAllTools: role.canAccessAllTools, + canReadAllObjectRecords: role.canReadAllObjectRecords, + canUpdateAllObjectRecords: role.canUpdateAllObjectRecords, + canSoftDeleteAllObjectRecords: role.canSoftDeleteAllObjectRecords, + canDestroyAllObjectRecords: role.canDestroyAllObjectRecords, + canBeAssignedToUsers: role.canBeAssignedToUsers, + canBeAssignedToAgents: role.canBeAssignedToAgents, + canBeAssignedToApiKeys: role.canBeAssignedToApiKeys, + objectPermissions: role.objectPermissions?.map(toObjectPermissionSummary), + permissionFlags: role.permissionFlags?.map( + (permissionFlag) => permissionFlag.flag, + ), +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/role/tools/utils/to-role-tool-error-message.util.ts b/packages/twenty-server/src/engine/metadata-modules/role/tools/utils/to-role-tool-error-message.util.ts new file mode 100644 index 0000000000..09b9e1b2a8 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/role/tools/utils/to-role-tool-error-message.util.ts @@ -0,0 +1,13 @@ +import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util'; +import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception'; + +// Role writes run through the workspace migration pipeline, whose exception +// carries a generic message and the actionable per-entity errors in its report. +// Expand it so the model gets something it can correct. +export const toRoleToolErrorMessage = (error: unknown): string => { + if (error instanceof WorkspaceMigrationBuilderException) { + return formatValidationErrors(error); + } + + return error instanceof Error ? error.message : String(error); +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/role/utils/__tests__/validate-role-mutation-does-not-lock-out-actor.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/role/utils/__tests__/validate-role-mutation-does-not-lock-out-actor.util.spec.ts new file mode 100644 index 0000000000..ccd5ed1355 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/role/utils/__tests__/validate-role-mutation-does-not-lock-out-actor.util.spec.ts @@ -0,0 +1,127 @@ +import { SystemPermissionFlag } from 'twenty-shared/constants'; + +import { type FlatRolePermissionFlagMaps } from 'src/engine/metadata-modules/flat-role-permission-flag/types/flat-role-permission-flag-maps.type'; +import { type FlatRole } from 'src/engine/metadata-modules/flat-role/types/flat-role.type'; +import { PermissionsException } from 'src/engine/metadata-modules/permissions/permissions.exception'; +import { + validateRoleDeletionDoesNotLockOutActorOrThrow, + validateRoleUpdateDoesNotLockOutActorOrThrow, +} from 'src/engine/metadata-modules/role/utils/validate-role-mutation-does-not-lock-out-actor.util'; + +const actingRoleId = 'acting-role-id'; + +const createFlatRole = (overrides: Partial): FlatRole => + ({ + id: actingRoleId, + label: 'Manager', + isEditable: true, + canUpdateAllSettings: true, + rolePermissionFlagIds: [], + ...overrides, + }) as FlatRole; + +const emptyFlagMaps = { + byUniversalIdentifier: {}, + universalIdentifierById: {}, +} as unknown as FlatRolePermissionFlagMaps; + +describe('validateRoleDeletionDoesNotLockOutActorOrThrow', () => { + it('throws when deleting a role the actor holds', () => { + expect(() => + validateRoleDeletionDoesNotLockOutActorOrThrow({ + flatRole: createFlatRole({}), + actingRoleIds: [actingRoleId], + }), + ).toThrow(PermissionsException); + }); + + it('allows deleting a role the actor does not hold', () => { + expect(() => + validateRoleDeletionDoesNotLockOutActorOrThrow({ + flatRole: createFlatRole({ id: 'other-role-id' }), + actingRoleIds: [actingRoleId], + }), + ).not.toThrow(); + }); + + it('allows deletion when no acting roles are provided (system caller)', () => { + expect(() => + validateRoleDeletionDoesNotLockOutActorOrThrow({ + flatRole: createFlatRole({}), + actingRoleIds: undefined, + }), + ).not.toThrow(); + }); +}); + +describe('validateRoleUpdateDoesNotLockOutActorOrThrow', () => { + it('throws when revoking settings access from a role the actor holds', () => { + expect(() => + validateRoleUpdateDoesNotLockOutActorOrThrow({ + flatRole: createFlatRole({}), + canUpdateAllSettingsUpdate: false, + actingRoleIds: [actingRoleId], + flatRolePermissionFlagMaps: emptyFlagMaps, + }), + ).toThrow(PermissionsException); + }); + + it('allows the revocation when the role keeps an explicit ROLES permission flag', () => { + const rolePermissionFlagId = 'role-permission-flag-id'; + const flagMaps = { + byUniversalIdentifier: { + [rolePermissionFlagId]: { + id: rolePermissionFlagId, + permissionFlagUniversalIdentifier: SystemPermissionFlag.ROLES, + }, + }, + universalIdentifierById: { + [rolePermissionFlagId]: rolePermissionFlagId, + }, + } as unknown as FlatRolePermissionFlagMaps; + + expect(() => + validateRoleUpdateDoesNotLockOutActorOrThrow({ + flatRole: createFlatRole({ + rolePermissionFlagIds: [rolePermissionFlagId], + }), + canUpdateAllSettingsUpdate: false, + actingRoleIds: [actingRoleId], + flatRolePermissionFlagMaps: flagMaps, + }), + ).not.toThrow(); + }); + + it('allows revoking settings access on a role the actor does not hold', () => { + expect(() => + validateRoleUpdateDoesNotLockOutActorOrThrow({ + flatRole: createFlatRole({ id: 'other-role-id' }), + canUpdateAllSettingsUpdate: false, + actingRoleIds: [actingRoleId], + flatRolePermissionFlagMaps: emptyFlagMaps, + }), + ).not.toThrow(); + }); + + it('allows updates that do not touch settings access', () => { + expect(() => + validateRoleUpdateDoesNotLockOutActorOrThrow({ + flatRole: createFlatRole({}), + canUpdateAllSettingsUpdate: undefined, + actingRoleIds: [actingRoleId], + flatRolePermissionFlagMaps: emptyFlagMaps, + }), + ).not.toThrow(); + }); + + it('allows the update when no acting roles are provided (system caller)', () => { + expect(() => + validateRoleUpdateDoesNotLockOutActorOrThrow({ + flatRole: createFlatRole({}), + canUpdateAllSettingsUpdate: false, + actingRoleIds: undefined, + flatRolePermissionFlagMaps: emptyFlagMaps, + }), + ).not.toThrow(); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/role/utils/validate-role-mutation-does-not-lock-out-actor.util.ts b/packages/twenty-server/src/engine/metadata-modules/role/utils/validate-role-mutation-does-not-lock-out-actor.util.ts new file mode 100644 index 0000000000..b66010a746 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/role/utils/validate-role-mutation-does-not-lock-out-actor.util.ts @@ -0,0 +1,64 @@ +import { PermissionFlagType } from 'twenty-shared/constants'; + +import { type FlatRolePermissionFlagMaps } from 'src/engine/metadata-modules/flat-role-permission-flag/types/flat-role-permission-flag-maps.type'; +import { type FlatRole } from 'src/engine/metadata-modules/flat-role/types/flat-role.type'; +import { flatRoleHasPermissionFlag } from 'src/engine/metadata-modules/flat-role/utils/flat-role-has-permission-flag.util'; +import { + PermissionsException, + PermissionsExceptionCode, + PermissionsExceptionMessage, +} from 'src/engine/metadata-modules/permissions/permissions.exception'; + +// Non-editable system roles are rejected downstream with ROLE_NOT_EDITABLE, +// which is the more accurate error there; lockout only concerns editable roles. +export const validateRoleDeletionDoesNotLockOutActorOrThrow = ({ + flatRole, + actingRoleIds, +}: { + flatRole: FlatRole; + actingRoleIds: string[] | undefined; +}): void => { + if (!flatRole.isEditable || !actingRoleIds?.includes(flatRole.id)) { + return; + } + + throw new PermissionsException( + PermissionsExceptionMessage.CANNOT_DELETE_OWN_ROLE, + PermissionsExceptionCode.CANNOT_DELETE_OWN_ROLE, + ); +}; + +// Revoking settings access from a role the actor holds would lock them out of +// role management, unless the role keeps an explicit ROLES permission flag. +export const validateRoleUpdateDoesNotLockOutActorOrThrow = ({ + flatRole, + canUpdateAllSettingsUpdate, + actingRoleIds, + flatRolePermissionFlagMaps, +}: { + flatRole: FlatRole; + canUpdateAllSettingsUpdate: boolean | undefined; + actingRoleIds: string[] | undefined; + flatRolePermissionFlagMaps: FlatRolePermissionFlagMaps; +}): void => { + if (!flatRole.isEditable || !actingRoleIds?.includes(flatRole.id)) { + return; + } + + if (canUpdateAllSettingsUpdate !== false || !flatRole.canUpdateAllSettings) { + return; + } + + const hasExplicitRolesPermissionFlag = flatRoleHasPermissionFlag({ + flatRole, + permissionFlag: PermissionFlagType.ROLES, + flatRolePermissionFlagMaps, + }); + + if (!hasExplicitRolesPermissionFlag) { + throw new PermissionsException( + PermissionsExceptionMessage.CANNOT_REVOKE_OWN_SETTINGS_ACCESS, + PermissionsExceptionCode.CANNOT_REVOKE_OWN_SETTINGS_ACCESS, + ); + } +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/services/row-level-permission-predicate.service.ts b/packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/services/row-level-permission-predicate.service.ts index c907fc869c..c38c281e90 100644 --- a/packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/services/row-level-permission-predicate.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/services/row-level-permission-predicate.service.ts @@ -35,6 +35,8 @@ import { } from 'src/engine/metadata-modules/row-level-permission-predicate/exceptions/row-level-permission-predicate.exception'; import { type FlatRowLevelPermissionPredicateGroup } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate-group.type'; import { type FlatRowLevelPermissionPredicate } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate.type'; +import { validateRowLevelPermissionRuleOwnershipOrThrow } from 'src/engine/metadata-modules/row-level-permission-predicate/utils/validate-row-level-permission-rule-ownership.util'; +import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception'; import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service'; @@ -190,6 +192,19 @@ export class RowLevelPermissionPredicateService { }, ); + validateRowLevelPermissionRuleOwnershipOrThrow({ + roleId, + objectMetadataId, + predicates, + predicateGroups, + flatRowLevelPermissionPredicateMaps, + flatRowLevelPermissionPredicateGroupMaps, + flatFieldMetadataMaps, + workspaceMemberObjectMetadataId: buildObjectIdByNameMaps( + flatObjectMetadataMaps, + ).idByNameSingular.workspaceMember, + }); + const existingPredicates = Object.values( flatRowLevelPermissionPredicateMaps.byUniversalIdentifier, ) diff --git a/packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/utils/__tests__/validate-row-level-permission-rule-ownership.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/utils/__tests__/validate-row-level-permission-rule-ownership.util.spec.ts new file mode 100644 index 0000000000..ad7f96e152 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/utils/__tests__/validate-row-level-permission-rule-ownership.util.spec.ts @@ -0,0 +1,207 @@ +/* @license Enterprise */ + +import { RowLevelPermissionPredicateOperand } from 'twenty-shared/types'; + +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { + type RowLevelPermissionPredicateGroupInput, + type RowLevelPermissionPredicateInput, +} from 'src/engine/metadata-modules/row-level-permission-predicate/dtos/inputs/upsert-row-level-permission-predicates.input'; +import { RowLevelPermissionPredicateException } from 'src/engine/metadata-modules/row-level-permission-predicate/exceptions/row-level-permission-predicate.exception'; +import { type FlatRowLevelPermissionPredicateGroup } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate-group.type'; +import { type FlatRowLevelPermissionPredicate } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate.type'; +import { validateRowLevelPermissionRuleOwnershipOrThrow } from 'src/engine/metadata-modules/row-level-permission-predicate/utils/validate-row-level-permission-rule-ownership.util'; + +const roleId = 'role-id'; +const objectMetadataId = 'object-metadata-id'; +const workspaceMemberObjectMetadataId = 'workspace-member-object-id'; +const ownerFieldMetadataId = 'owner-field-metadata-id'; +const workspaceMemberIdFieldMetadataId = + 'workspace-member-id-field-metadata-id'; + +const createFlatEntityMapsKeyedById = ( + entities: TEntity[], +) => + ({ + byUniversalIdentifier: Object.fromEntries( + entities.map((entity) => [entity.id, entity]), + ), + universalIdentifierById: Object.fromEntries( + entities.map((entity) => [entity.id, entity.id]), + ), + }) as unknown as FlatEntityMaps; + +const defaultFields = [ + { id: ownerFieldMetadataId, name: 'owner', objectMetadataId }, + { + id: workspaceMemberIdFieldMetadataId, + name: 'id', + objectMetadataId: workspaceMemberObjectMetadataId, + }, +]; + +const ownerMatchesCurrentUserPredicate = { + fieldMetadataId: ownerFieldMetadataId, + operand: RowLevelPermissionPredicateOperand.IS, + workspaceMemberFieldMetadataId: workspaceMemberIdFieldMetadataId, +}; + +const validate = ({ + predicates = [], + predicateGroups = [], + existingPredicates = [], + existingGroups = [], + fields = defaultFields, +}: { + predicates?: object[]; + predicateGroups?: object[]; + existingPredicates?: { + id: string; + roleId: string; + objectMetadataId: string; + deletedAt?: string | null; + }[]; + existingGroups?: { + id: string; + roleId: string; + objectMetadataId: string; + deletedAt?: string | null; + }[]; + fields?: { id: string; name: string; objectMetadataId: string }[]; +}) => + validateRowLevelPermissionRuleOwnershipOrThrow({ + roleId, + objectMetadataId, + predicates: predicates as RowLevelPermissionPredicateInput[], + predicateGroups: predicateGroups as RowLevelPermissionPredicateGroupInput[], + flatRowLevelPermissionPredicateMaps: createFlatEntityMapsKeyedById( + existingPredicates.map((predicate) => ({ + deletedAt: null, + ...predicate, + })), + ) as unknown as FlatEntityMaps, + flatRowLevelPermissionPredicateGroupMaps: createFlatEntityMapsKeyedById( + existingGroups.map((group) => ({ deletedAt: null, ...group })), + ) as unknown as FlatEntityMaps, + flatFieldMetadataMaps: createFlatEntityMapsKeyedById( + fields, + ) as unknown as FlatEntityMaps, + workspaceMemberObjectMetadataId, + }); + +describe('validateRowLevelPermissionRuleOwnershipOrThrow', () => { + it('rejects a predicate id owned by another role', () => { + expect(() => + validate({ + predicates: [ + { ...ownerMatchesCurrentUserPredicate, id: 'foreign-predicate-id' }, + ], + existingPredicates: [ + { + id: 'foreign-predicate-id', + roleId: 'another-role-id', + objectMetadataId, + }, + ], + }), + ).toThrow(RowLevelPermissionPredicateException); + }); + + it('rejects a predicate group id owned by another object', () => { + expect(() => + validate({ + predicateGroups: [{ id: 'foreign-group-id', logicalOperator: 'AND' }], + existingGroups: [ + { + id: 'foreign-group-id', + roleId, + objectMetadataId: 'another-object-metadata-id', + }, + ], + }), + ).toThrow(RowLevelPermissionPredicateException); + }); + + it('rejects a predicate referencing a group of another role', () => { + expect(() => + validate({ + predicates: [ + { + ...ownerMatchesCurrentUserPredicate, + rowLevelPermissionPredicateGroupId: 'foreign-group-id', + }, + ], + existingGroups: [ + { + id: 'foreign-group-id', + roleId: 'another-role-id', + objectMetadataId, + }, + ], + }), + ).toThrow(RowLevelPermissionPredicateException); + }); + + it('rejects a field belonging to another object', () => { + expect(() => + validate({ + predicates: [ + { + fieldMetadataId: 'other-object-field-id', + operand: RowLevelPermissionPredicateOperand.IS, + }, + ], + fields: [ + ...defaultFields, + { + id: 'other-object-field-id', + name: 'unrelatedField', + objectMetadataId: 'another-object-metadata-id', + }, + ], + }), + ).toThrow(RowLevelPermissionPredicateException); + }); + + it('rejects a workspaceMemberFieldMetadataId that is not a workspaceMember field', () => { + expect(() => + validate({ + predicates: [ + { + fieldMetadataId: ownerFieldMetadataId, + operand: RowLevelPermissionPredicateOperand.IS, + workspaceMemberFieldMetadataId: ownerFieldMetadataId, + }, + ], + }), + ).toThrow(RowLevelPermissionPredicateException); + }); + + it('allows reusing an existing predicate id owned by the same role and object', () => { + expect(() => + validate({ + predicates: [ + { ...ownerMatchesCurrentUserPredicate, id: 'own-predicate-id' }, + ], + existingPredicates: [ + { id: 'own-predicate-id', roleId, objectMetadataId }, + ], + }), + ).not.toThrow(); + }); + + it('allows a predicate referencing a group declared in the same payload', () => { + expect(() => + validate({ + predicates: [ + { + ...ownerMatchesCurrentUserPredicate, + rowLevelPermissionPredicateGroupId: 'new-group-id', + }, + ], + predicateGroups: [{ id: 'new-group-id', logicalOperator: 'AND' }], + }), + ).not.toThrow(); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/utils/validate-row-level-permission-rule-ownership.util.ts b/packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/utils/validate-row-level-permission-rule-ownership.util.ts new file mode 100644 index 0000000000..52d967cd39 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/utils/validate-row-level-permission-rule-ownership.util.ts @@ -0,0 +1,158 @@ +/* @license Enterprise */ + +import { isDefined } from 'twenty-shared/utils'; + +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { + type RowLevelPermissionPredicateGroupInput, + type RowLevelPermissionPredicateInput, +} from 'src/engine/metadata-modules/row-level-permission-predicate/dtos/inputs/upsert-row-level-permission-predicates.input'; +import { + RowLevelPermissionPredicateException, + RowLevelPermissionPredicateExceptionCode, +} from 'src/engine/metadata-modules/row-level-permission-predicate/exceptions/row-level-permission-predicate.exception'; +import { type FlatRowLevelPermissionPredicateGroup } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate-group.type'; +import { type FlatRowLevelPermissionPredicate } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate.type'; + +// Supplied ids are resolved workspace-wide further down, and the migration +// validators only compare an update against its own stored row, so a predicate +// or group id belonging to another role or object would be silently rewritten +// in place, and a field from another object would build a filter that never +// matches. Scope everything to the requested role and object upfront. +export const validateRowLevelPermissionRuleOwnershipOrThrow = ({ + roleId, + objectMetadataId, + predicates, + predicateGroups, + flatRowLevelPermissionPredicateMaps, + flatRowLevelPermissionPredicateGroupMaps, + flatFieldMetadataMaps, + workspaceMemberObjectMetadataId, +}: { + roleId: string; + objectMetadataId: string; + predicates: RowLevelPermissionPredicateInput[]; + predicateGroups: RowLevelPermissionPredicateGroupInput[]; + flatRowLevelPermissionPredicateMaps: FlatEntityMaps; + flatRowLevelPermissionPredicateGroupMaps: FlatEntityMaps; + flatFieldMetadataMaps: FlatEntityMaps; + workspaceMemberObjectMetadataId?: string; +}): void => { + for (const predicateGroup of predicateGroups) { + if (!isDefined(predicateGroup.id)) { + continue; + } + + const existingGroup = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: predicateGroup.id, + flatEntityMaps: flatRowLevelPermissionPredicateGroupMaps, + }); + + if ( + isDefined(existingGroup) && + existingGroup.deletedAt === null && + (existingGroup.roleId !== roleId || + existingGroup.objectMetadataId !== objectMetadataId) + ) { + throw new RowLevelPermissionPredicateException( + 'Predicate group belongs to a different role or object and cannot be modified here. Omit the id to create a new group.', + RowLevelPermissionPredicateExceptionCode.UNAUTHORIZED_OBJECT_MODIFICATION, + ); + } + } + + const groupIdsInPayload = new Set( + predicateGroups + .map((predicateGroup) => predicateGroup.id) + .filter(isDefined), + ); + + for (const predicate of predicates) { + if (isDefined(predicate.id)) { + const existingPredicate = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: predicate.id, + flatEntityMaps: flatRowLevelPermissionPredicateMaps, + }); + + if ( + isDefined(existingPredicate) && + existingPredicate.deletedAt === null && + (existingPredicate.roleId !== roleId || + existingPredicate.objectMetadataId !== objectMetadataId) + ) { + throw new RowLevelPermissionPredicateException( + 'Predicate belongs to a different role or object and cannot be modified here. Omit the id to create a new predicate.', + RowLevelPermissionPredicateExceptionCode.UNAUTHORIZED_ROLE_MODIFICATION, + ); + } + } + + const groupId = predicate.rowLevelPermissionPredicateGroupId; + + if (isDefined(groupId) && !groupIdsInPayload.has(groupId)) { + const referencedGroup = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: groupId, + flatEntityMaps: flatRowLevelPermissionPredicateGroupMaps, + }); + + if ( + !isDefined(referencedGroup) || + referencedGroup.deletedAt !== null || + referencedGroup.roleId !== roleId || + referencedGroup.objectMetadataId !== objectMetadataId + ) { + throw new RowLevelPermissionPredicateException( + 'Referenced predicate group is not a group of this role and object. Reference a group declared in predicateGroups or an existing group of this role and object.', + RowLevelPermissionPredicateExceptionCode.INVALID_ROW_LEVEL_PERMISSION_PREDICATE_DATA, + ); + } + } + + const fieldMetadata = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: predicate.fieldMetadataId, + flatEntityMaps: flatFieldMetadataMaps, + }); + + if (!isDefined(fieldMetadata)) { + throw new RowLevelPermissionPredicateException( + 'Field metadata not found', + RowLevelPermissionPredicateExceptionCode.FIELD_METADATA_NOT_FOUND, + ); + } + + if (fieldMetadata.objectMetadataId !== objectMetadataId) { + throw new RowLevelPermissionPredicateException( + 'Field belongs to another object and cannot be used in a rule on this object. Rules must filter on a field of the object they restrict.', + RowLevelPermissionPredicateExceptionCode.INVALID_ROW_LEVEL_PERMISSION_PREDICATE_DATA, + ); + } + + const workspaceMemberFieldMetadataId = + predicate.workspaceMemberFieldMetadataId; + + if ( + !isDefined(workspaceMemberFieldMetadataId) || + !isDefined(workspaceMemberObjectMetadataId) + ) { + continue; + } + + const workspaceMemberFieldMetadata = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: workspaceMemberFieldMetadataId, + flatEntityMaps: flatFieldMetadataMaps, + }); + + if ( + !isDefined(workspaceMemberFieldMetadata) || + workspaceMemberFieldMetadata.objectMetadataId !== + workspaceMemberObjectMetadataId + ) { + throw new RowLevelPermissionPredicateException( + 'workspaceMemberFieldMetadataId is not a field of the workspaceMember object. The rule would silently never apply.', + RowLevelPermissionPredicateExceptionCode.INVALID_ROW_LEVEL_PERMISSION_PREDICATE_DATA, + ); + } + } +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/user-role/__tests__/user-role.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/user-role/__tests__/user-role.service.spec.ts new file mode 100644 index 0000000000..1703bba81f --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/user-role/__tests__/user-role.service.spec.ts @@ -0,0 +1,178 @@ +import { type Repository } from 'typeorm'; + +import { type UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { PermissionsExceptionCode } from 'src/engine/metadata-modules/permissions/permissions.exception'; +import { type RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service'; +import { type RoleValidationService } from 'src/engine/metadata-modules/role-validation/services/role-validation.service'; +import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; +import { type GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; +import { type WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; +import { type WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { type RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity'; + +const workspaceId = 'workspace-id'; +const roleId = 'role-id'; +const workspaceMemberId = 'workspace-member-id'; +const targetUserWorkspaceId = 'target-user-workspace-id'; +const actingUserWorkspaceId = 'acting-user-workspace-id'; + +const buildService = (options?: { + workspaceMember?: { id: string; userId: string } | null; + userWorkspace?: { id: string } | null; +}) => { + const workspaceMember = + options?.workspaceMember === undefined + ? { id: workspaceMemberId, userId: 'target-user-id' } + : options.workspaceMember; + const userWorkspace = + options?.userWorkspace === undefined + ? { id: targetUserWorkspaceId } + : options.userWorkspace; + + const roleTargetRepository = { + find: jest.fn().mockResolvedValue([]), + }; + const userWorkspaceRepository = { + find: jest.fn().mockResolvedValue([{ id: targetUserWorkspaceId }]), + findOne: jest.fn().mockResolvedValue(userWorkspace), + }; + const workspaceMemberRepository = { + findOne: jest.fn().mockResolvedValue(workspaceMember), + }; + const globalWorkspaceOrmManager = { + executeInWorkspaceContext: jest.fn((callback: () => unknown) => callback()), + getRepository: jest.fn().mockResolvedValue(workspaceMemberRepository), + }; + const roleTargetService = { + createMany: jest.fn().mockResolvedValue([]), + }; + const workspaceCacheService = { + getOrRecompute: jest.fn().mockResolvedValue({ + userWorkspaceRoleMap: { [targetUserWorkspaceId]: 'previous-role-id' }, + }), + }; + const roleValidationService = { + validateRoleAssignableToUsersOrThrow: jest + .fn() + .mockResolvedValue(undefined), + }; + + const service = new UserRoleService( + roleTargetRepository as unknown as WorkspaceScopedRepository, + userWorkspaceRepository as unknown as Repository, + globalWorkspaceOrmManager as unknown as GlobalWorkspaceOrmManager, + roleTargetService as unknown as RoleTargetService, + workspaceCacheService as unknown as WorkspaceCacheService, + roleValidationService as unknown as RoleValidationService, + ); + + return { + service, + roleTargetService, + roleValidationService, + userWorkspaceRepository, + }; +}; + +describe('UserRoleService', () => { + describe('assignRoleToManyUserWorkspace', () => { + it('rejects assignments that include the acting user workspace', async () => { + const { service, roleTargetService } = buildService(); + + await expect( + service.assignRoleToManyUserWorkspace({ + workspaceId, + userWorkspaceIds: [actingUserWorkspaceId], + roleId, + actingUserWorkspaceId, + }), + ).rejects.toMatchObject({ + code: PermissionsExceptionCode.CANNOT_UPDATE_SELF_ROLE, + }); + + expect(roleTargetService.createMany).not.toHaveBeenCalled(); + }); + }); + + describe('assignRoleToWorkspaceMember', () => { + it('resolves the member, validates assignability and assigns the role', async () => { + const { service, roleTargetService, roleValidationService } = + buildService(); + + const result = await service.assignRoleToWorkspaceMember({ + workspaceId, + workspaceMemberId, + roleId, + actingUserWorkspaceId, + }); + + expect(result.userWorkspaceId).toBe(targetUserWorkspaceId); + expect( + roleValidationService.validateRoleAssignableToUsersOrThrow, + ).toHaveBeenCalledWith(roleId, workspaceId); + expect(roleTargetService.createMany).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId, + createRoleTargetInputs: [ + expect.objectContaining({ + roleId, + targetId: targetUserWorkspaceId, + }), + ], + }), + ); + }); + + it('rejects assigning a role to the acting user themselves', async () => { + const { service, roleTargetService } = buildService({ + userWorkspace: { id: actingUserWorkspaceId }, + }); + + await expect( + service.assignRoleToWorkspaceMember({ + workspaceId, + workspaceMemberId, + roleId, + actingUserWorkspaceId, + }), + ).rejects.toMatchObject({ + code: PermissionsExceptionCode.CANNOT_UPDATE_SELF_ROLE, + }); + + expect(roleTargetService.createMany).not.toHaveBeenCalled(); + }); + + it('throws when the workspace member does not exist', async () => { + const { service } = buildService({ workspaceMember: null }); + + await expect( + service.assignRoleToWorkspaceMember({ + workspaceId, + workspaceMemberId, + roleId, + }), + ).rejects.toMatchObject({ + code: PermissionsExceptionCode.WORKSPACE_MEMBER_NOT_FOUND, + }); + }); + + it('propagates assignability rejections from role validation', async () => { + const { service, roleValidationService, roleTargetService } = + buildService(); + + roleValidationService.validateRoleAssignableToUsersOrThrow.mockRejectedValue( + new Error('Role cannot be assigned to users'), + ); + + await expect( + service.assignRoleToWorkspaceMember({ + workspaceId, + workspaceMemberId, + roleId, + }), + ).rejects.toThrow('Role cannot be assigned to users'); + + expect(roleTargetService.createMany).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/user-role/user-role.module.ts b/packages/twenty-server/src/engine/metadata-modules/user-role/user-role.module.ts index 4f61e0bbde..affc40d914 100644 --- a/packages/twenty-server/src/engine/metadata-modules/user-role/user-role.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/user-role/user-role.module.ts @@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity'; import { RoleTargetModule } from 'src/engine/metadata-modules/role-target/role-target.module'; +import { RoleValidationModule } from 'src/engine/metadata-modules/role-validation/role-validation.module'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository'; @@ -15,6 +16,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache TypeOrmModule.forFeature([UserWorkspaceEntity]), WorkspaceCacheModule, RoleTargetModule, + RoleValidationModule, ], providers: [ UserRoleService, diff --git a/packages/twenty-server/src/engine/metadata-modules/user-role/user-role.service.ts b/packages/twenty-server/src/engine/metadata-modules/user-role/user-role.service.ts index 54e09fba7e..7d233bb396 100644 --- a/packages/twenty-server/src/engine/metadata-modules/user-role/user-role.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/user-role/user-role.service.ts @@ -12,6 +12,7 @@ import { } from 'src/engine/metadata-modules/permissions/permissions.exception'; import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity'; import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service'; +import { RoleValidationService } from 'src/engine/metadata-modules/role-validation/services/role-validation.service'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; @@ -30,21 +31,87 @@ export class UserRoleService { private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, private readonly roleTargetService: RoleTargetService, private readonly workspaceCacheService: WorkspaceCacheService, + private readonly roleValidationService: RoleValidationService, ) {} + // Resolves a workspace member to its user workspace and assigns the role, + // enforcing the self-role-change and assignability rules for every surface + // (GraphQL, AI tools, future REST/CLI). + public async assignRoleToWorkspaceMember({ + workspaceId, + workspaceMemberId, + roleId, + actingUserWorkspaceId, + }: { + workspaceId: string; + workspaceMemberId: string; + roleId: string; + actingUserWorkspaceId?: string; + }): Promise<{ + workspaceMember: WorkspaceMemberWorkspaceEntity; + userWorkspaceId: string; + }> { + const workspaceMember = await this.getWorkspaceMemberByIdOrThrow({ + workspaceMemberId, + workspaceId, + }); + + const userWorkspace = await this.userWorkspaceRepository.findOne({ + where: { + userId: workspaceMember.userId, + workspaceId, + }, + }); + + if (!isDefined(userWorkspace)) { + throw new PermissionsException( + PermissionsExceptionMessage.USER_WORKSPACE_NOT_FOUND, + PermissionsExceptionCode.USER_WORKSPACE_NOT_FOUND, + ); + } + + // Checked before role validation so a self-assignment fails with the + // self-role error even when the supplied role does not exist. + this.validateNotSelfAssignmentOrThrow({ + userWorkspaceIds: [userWorkspace.id], + actingUserWorkspaceId, + }); + + await this.roleValidationService.validateRoleAssignableToUsersOrThrow( + roleId, + workspaceId, + ); + + await this.assignRoleToManyUserWorkspace({ + workspaceId, + userWorkspaceIds: [userWorkspace.id], + roleId, + actingUserWorkspaceId, + }); + + return { workspaceMember, userWorkspaceId: userWorkspace.id }; + } + public async assignRoleToManyUserWorkspace({ workspaceId, userWorkspaceIds, roleId, + actingUserWorkspaceId, }: { workspaceId: string; userWorkspaceIds: string[]; roleId: string; + actingUserWorkspaceId?: string; }): Promise { if (userWorkspaceIds.length === 0) { return; } + this.validateNotSelfAssignmentOrThrow({ + userWorkspaceIds, + actingUserWorkspaceId, + }); + const userWorkspaceIdsToAssign = await this.validateAssignRoleInputsAndGetUserWorkspaceIdsToAssign({ userWorkspaceIds, @@ -177,6 +244,64 @@ export class UserRoleService { ); } + private validateNotSelfAssignmentOrThrow({ + userWorkspaceIds, + actingUserWorkspaceId, + }: { + userWorkspaceIds: string[]; + actingUserWorkspaceId?: string; + }): void { + if ( + isDefined(actingUserWorkspaceId) && + userWorkspaceIds.includes(actingUserWorkspaceId) + ) { + throw new PermissionsException( + PermissionsExceptionMessage.CANNOT_UPDATE_SELF_ROLE, + PermissionsExceptionCode.CANNOT_UPDATE_SELF_ROLE, + { + userFriendlyMessage: msg`You cannot change your own role. Please ask another administrator to update your role.`, + }, + ); + } + } + + private async getWorkspaceMemberByIdOrThrow({ + workspaceMemberId, + workspaceId, + }: { + workspaceMemberId: string; + workspaceId: string; + }): Promise { + const authContext = buildSystemAuthContext(workspaceId); + + return this.globalWorkspaceOrmManager.executeInWorkspaceContext( + async () => { + const workspaceMemberRepository = + await this.globalWorkspaceOrmManager.getRepository( + workspaceId, + 'workspaceMember', + { shouldBypassPermissionChecks: true }, + ); + + const workspaceMember = await workspaceMemberRepository.findOne({ + where: { + id: workspaceMemberId, + }, + }); + + if (!isDefined(workspaceMember)) { + throw new PermissionsException( + 'Workspace member not found', + PermissionsExceptionCode.WORKSPACE_MEMBER_NOT_FOUND, + ); + } + + return workspaceMember; + }, + authContext, + ); + } + public async getUserWorkspaceIdsAssignedToRole( roleId: string, workspaceId: string, diff --git a/packages/twenty-server/test/integration/metadata/suites/row-level-permission-predicate/__snapshots__/failing-row-level-permission-predicate-upsert.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/row-level-permission-predicate/__snapshots__/failing-row-level-permission-predicate-upsert.integration-spec.ts.snap index 9c736ff343..e4859d999a 100644 --- a/packages/twenty-server/test/integration/metadata/suites/row-level-permission-predicate/__snapshots__/failing-row-level-permission-predicate-upsert.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/row-level-permission-predicate/__snapshots__/failing-row-level-permission-predicate-upsert.integration-spec.ts.snap @@ -1,13 +1,13 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +// Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Row Level Permission Predicate upsert should fail when fieldMetadataId in predicate does not exist 1`] = ` { "extensions": { "code": "NOT_FOUND", - "subCode": "RELATION_UNIVERSAL_IDENTIFIER_NOT_FOUND", - "userFriendlyMessage": "An error occurred.", + "subCode": "FIELD_METADATA_NOT_FOUND", + "userFriendlyMessage": "Field metadata not found.", }, - "message": "Could not find fieldMetadata for given fieldMetadataId", + "message": "Field metadata not found", "name": "NotFoundError", } `; @@ -99,11 +99,11 @@ exports[`Row Level Permission Predicate upsert should fail when roleId is not a exports[`Row Level Permission Predicate upsert should fail when rowLevelPermissionPredicateGroupId in predicate does not exist 1`] = ` { "extensions": { - "code": "NOT_FOUND", - "subCode": "RELATION_UNIVERSAL_IDENTIFIER_NOT_FOUND", - "userFriendlyMessage": "An error occurred.", + "code": "BAD_USER_INPUT", + "subCode": "INVALID_ROW_LEVEL_PERMISSION_PREDICATE_DATA", + "userFriendlyMessage": "Invalid row level permission predicate data.", }, - "message": "Could not find rowLevelPermissionPredicateGroup for given rowLevelPermissionPredicateGroupId", - "name": "NotFoundError", + "message": "Referenced predicate group is not a group of this role and object. Reference a group declared in predicateGroups or an existing group of this role and object.", + "name": "UserInputError", } `; diff --git a/packages/twenty-shared/src/ai/constants/tool-category.const.ts b/packages/twenty-shared/src/ai/constants/tool-category.const.ts index f7311b1fef..3868144c81 100644 --- a/packages/twenty-shared/src/ai/constants/tool-category.const.ts +++ b/packages/twenty-shared/src/ai/constants/tool-category.const.ts @@ -8,4 +8,5 @@ export enum ToolCategory { NAVIGATION_MENU_ITEM = 'NAVIGATION_MENU_ITEM', WEBHOOK = 'WEBHOOK', LOGIC_FUNCTION = 'LOGIC_FUNCTION', + ROLE = 'ROLE', }