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
@@ -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');
});
});
});
@@ -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<boolean> {
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<string, unknown>,
context: ToolProviderContext,
): Promise<ToolOutput> {
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,
});
}
}
@@ -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,
@@ -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);
}
@@ -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,
@@ -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",
}
`;
@@ -8,4 +8,5 @@ export enum ToolCategory {
NAVIGATION_MENU_ITEM = 'NAVIGATION_MENU_ITEM',
WEBHOOK = 'WEBHOOK',
LOGIC_FUNCTION = 'LOGIC_FUNCTION',
ROLE = 'ROLE',
}