feat(server): add role management tools for AI chat (#23613)

Adds role management tools to the AI chat so the agent can create and
configure roles, including row-level permissions.

## What

A `RoleToolProvider` in `tool-provider/providers/`, mirroring
`webhook-tool.provider.ts`, registered in `tool-provider.module.ts` and
gated behind `PermissionFlagType.ROLES` via
`PermissionsService.checkRolesPermissions` (same pattern as the
VIEWS/WORKFLOWS gating). Tools:

- `list_roles` — global record permissions, settings access, per-object
overrides, permission flags, assignability; optionally includes
row-level rules
- `create_role`, `update_role`, `delete_role`
- `assign_role_to_workspace_member` — via
`UserRoleService.assignRoleToManyUserWorkspace`, which goes through
role-target
- `upsert_object_permissions` — per-object overrides, e.g. read-only on
a given object
- `upsert_row_level_permission_rules` — reuses
`RowLevelPermissionPredicateService.upsertRowLevelPermissionPredicates`
and the predicate-group service, so the agent can express rules like
"members with this role only see records where the owner field matches
the current user" (a predicate with `workspaceMemberFieldMetadataId`
pointing at the workspaceMember `id` field, resolved to the current user
at query time)

Everything routes through the existing role services and DTOs
(`RoleService`, `ObjectPermissionService`, `UserRoleService`, the
row-level predicate services) rather than reimplementing them. A new
`ToolCategory.ROLE` is added to `twenty-shared`, along with its label in
the exhaustive switch in `build-tool-catalog-section.util.ts`.

## Safeguards

- Any mutation on a role with `isEditable: false` is rejected. That
covers the Admin role, which is created non-editable, and matches what
Settings blocks.
- Deleting the role the caller is currently acting under is rejected,
since deletion would rebind them to the workspace default role.
- Setting `canUpdateAllSettings: false` on the caller's own role is
rejected unless that role keeps an explicit ROLES permission flag.
- Changing your own role via `assign_role_to_workspace_member` is
rejected, checked both by workspace member id and by resolved user
workspace id.

The tool-layer checks are deliberate pre-checks: the migration
validators and services enforce the same rules downstream
(`validate-role-is-editable.util.ts`, default-role deletion, last-admin
unassignment, write-without-read consistency), but catching them early
gives the model a named, actionable message instead of a build failure
report. Where the deeper layer does reject, `formatValidationErrors`
expands the migration exception so the underlying per-entity errors
reach the model rather than a generic summary.

Worth flagging for reviewers: the self-lockout protection currently
lives only at the tool layer. A human admin can still strip settings
access from their own role through Settings/GraphQL. Closing that would
mean changing `RoleService`/`UserRoleService` behavior for the human
path, which felt like a separate decision than what this change is
scoped to.

## Notes

`ToolCategory.ROLE` is intentionally left out of
`WORKFLOW_AGENT_REGISTRY_TOOL_CATEGORIES`, so workflow agents don't get
these tools; only the chat surface and the MCP/tool-index paths that
share the registry do.

## Testing

- 24 unit tests in `providers/__tests__/role-tool.provider.spec.ts`,
covering permission gating, descriptor exposure, each safeguard, the
N+1-free list path, and validation-error surfacing
- 333 tests pass across the tool-provider, role, object-permission and
ai suites
- `npx nx lint:diff-with-main twenty-server` and `npx nx typecheck
twenty-server` are clean


---
_Generated by [Claude
Code](https://claude.ai/code/session_0131sLKVsRuaDoaKCxFM8g4Z)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23613?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Félix Malfait
2026-07-31 13:49:23 +02:00
committed by GitHub
parent f8ed432e2a
commit 5d88cf7f2f
33 changed files with 2368 additions and 52 deletions
@@ -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',
@@ -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:
@@ -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:
@@ -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,
],
@@ -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<WorkspaceMemberDTO> {
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<RoleDTO> {
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<string> {
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<string[] | undefined> {
if (!isDefined(actingUserWorkspaceId)) {
return undefined;
}
return [
await this.userRoleService.getRoleIdForUserWorkspace({
workspaceId,
userWorkspaceId: actingUserWorkspaceId,
}),
];
}
@Mutation(() => [ObjectPermissionDTO])
async upsertObjectPermissions(
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -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<RoleDTO> {
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<RoleDTO> {
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<RoleDTO[]> {
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,
@@ -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<RoleToolDependencies, 'userRoleService'>,
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,
};
}
},
});
@@ -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<typeof createRoleSchema>;
export const createCreateRoleTool = (
deps: Pick<RoleToolDependencies, 'roleService' | 'applicationService'>,
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,
};
}
},
});
@@ -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<typeof deleteRoleSchema>;
export const createDeleteRoleTool = (
deps: Pick<RoleToolDependencies, 'roleService'>,
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,
};
}
},
});
@@ -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<typeof listRolesSchema>;
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,
};
}
},
});
@@ -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'),
});
@@ -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,
};
}
}
@@ -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;
};
@@ -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;
};
@@ -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<typeof updateRoleSchema>;
export const createUpdateRoleTool = (
deps: Pick<RoleToolDependencies, 'roleService'>,
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,
};
}
},
});
@@ -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<RoleToolDependencies, 'objectPermissionService'>,
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,
};
}
},
});
@@ -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<RoleToolDependencies, 'rowLevelPermissionPredicateService'>,
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,
};
}
},
});
@@ -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,
),
});
@@ -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);
};
@@ -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>): 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();
});
});
@@ -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,
);
}
};
@@ -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,
)
@@ -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 = <TEntity extends { id: string }>(
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<never>;
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<FlatRowLevelPermissionPredicate>,
flatRowLevelPermissionPredicateGroupMaps: createFlatEntityMapsKeyedById(
existingGroups.map((group) => ({ deletedAt: null, ...group })),
) as unknown as FlatEntityMaps<FlatRowLevelPermissionPredicateGroup>,
flatFieldMetadataMaps: createFlatEntityMapsKeyedById(
fields,
) as unknown as FlatEntityMaps<FlatFieldMetadata>,
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();
});
});
@@ -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<FlatRowLevelPermissionPredicate>;
flatRowLevelPermissionPredicateGroupMaps: FlatEntityMaps<FlatRowLevelPermissionPredicateGroup>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
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,
);
}
}
};
@@ -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<RoleTargetEntity>,
userWorkspaceRepository as unknown as Repository<UserWorkspaceEntity>,
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();
});
});
});
@@ -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,
@@ -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<void> {
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<WorkspaceMemberWorkspaceEntity> {
const authContext = buildSystemAuthContext(workspaceId);
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const workspaceMemberRepository =
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
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,