From 2aa118cb0d0f95c63c7bb0b1f8e4f904ff6303e1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?=
<71827178+bosiraphael@users.noreply.github.com>
Date: Fri, 31 Jul 2026 15:20:43 +0200
Subject: [PATCH] Add a roles standard skill for the AI chat agent (#23636)
Role management tools shipped in #23613, but no skill was added, so the
agent could call them with no guidance.
This adds a `roles` standard skill covering the traps that are easy to
get wrong: `upsert_object_permissions` replaces the role's entire
override list (so omitted objects silently revert to global
permissions), granting write without read is rejected, system-managed
roles like Admin cannot be modified, and the lockout guard rejects
mutations that would strip the acting admin's own access. It also tells
the agent to call `list_roles` first, since every workspace already
ships with Admin and Member, and reuses the confirmation-gate pattern
from `dashboard-building` before any create/update/delete.
New workspaces get the skill from the standard application. There is no
generic sync for existing ones, so this also adds a `2-27` backfill
command that diffs computed standard skills against existing ones and
creates the missing ones.
---
.../2-27-upgrade-version-command.module.ts | 18 +++
...350000-backfill-standard-skills.command.ts | 119 ++++++++++++++++++
.../workspace-command-provider.module.ts | 2 +
.../constants/standard-skill.constant.ts | 3 +
...reate-standard-flat-skill-metadata.util.ts | 100 +++++++++++++++
5 files changed, 242 insertions(+)
create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-27/2-27-upgrade-version-command.module.ts
create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-27/2-27-workspace-command-1785499350000-backfill-standard-skills.command.ts
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-27/2-27-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-27/2-27-upgrade-version-command.module.ts
new file mode 100644
index 0000000000..4d27da4cd0
--- /dev/null
+++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-27/2-27-upgrade-version-command.module.ts
@@ -0,0 +1,18 @@
+import { Module } from '@nestjs/common';
+
+import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
+import { BackfillMissingStandardSkillsCommand } from 'src/database/commands/upgrade-version-command/2-27/2-27-workspace-command-1785499350000-backfill-standard-skills.command';
+import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
+import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
+import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
+
+@Module({
+ imports: [
+ ApplicationModule,
+ WorkspaceCacheModule,
+ WorkspaceIteratorModule,
+ WorkspaceMigrationModule,
+ ],
+ providers: [BackfillMissingStandardSkillsCommand],
+})
+export class V2_27_UpgradeVersionCommandModule {}
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-27/2-27-workspace-command-1785499350000-backfill-standard-skills.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-27/2-27-workspace-command-1785499350000-backfill-standard-skills.command.ts
new file mode 100644
index 0000000000..7bb31727b4
--- /dev/null
+++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-27/2-27-workspace-command-1785499350000-backfill-standard-skills.command.ts
@@ -0,0 +1,119 @@
+import { Command } from 'nest-commander';
+import { isDefined } from 'twenty-shared/utils';
+
+import { ProvisionedWorkspaceCommandRunner } from 'src/database/commands/command-runners/provisioned-workspace.command-runner';
+import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
+import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
+import { ApplicationService } from 'src/engine/core-modules/application/application.service';
+import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
+import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
+import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant';
+import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
+
+@RegisteredWorkspaceCommand('2.27.0', 1785499350000)
+@Command({
+ name: 'upgrade:2-27:backfill-standard-skills',
+ description:
+ 'Backfill standard skills missing from existing workspaces, such as the roles skill',
+})
+export class BackfillMissingStandardSkillsCommand extends ProvisionedWorkspaceCommandRunner {
+ constructor(
+ protected readonly workspaceIteratorService: WorkspaceIteratorService,
+ private readonly applicationService: ApplicationService,
+ private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
+ private readonly workspaceCacheService: WorkspaceCacheService,
+ ) {
+ super(workspaceIteratorService);
+ }
+
+ override async runOnWorkspace({
+ workspaceId,
+ options,
+ }: RunOnWorkspaceArgs): Promise {
+ const isDryRun = options.dryRun ?? false;
+
+ this.logger.log(
+ `${isDryRun ? '[DRY RUN] ' : ''}Checking standard skills for workspace ${workspaceId}`,
+ );
+
+ const { twentyStandardFlatApplication } =
+ await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
+ { workspaceId },
+ );
+
+ const { flatSkillMaps: existingFlatSkillMaps } =
+ await this.workspaceCacheService.getOrRecompute(workspaceId, [
+ 'flatSkillMaps',
+ ]);
+
+ const { allFlatEntityMaps: standardAllFlatEntityMaps } =
+ computeTwentyStandardApplicationAllFlatEntityMaps({
+ now: new Date().toISOString(),
+ workspaceId,
+ twentyStandardApplicationId: twentyStandardFlatApplication.id,
+ });
+
+ const standardSkills = Object.values(
+ standardAllFlatEntityMaps.flatSkillMaps.byUniversalIdentifier,
+ ).filter(isDefined);
+
+ const skillsToCreate = standardSkills.filter(
+ (skill) =>
+ !isDefined(
+ existingFlatSkillMaps.byUniversalIdentifier[
+ skill.universalIdentifier
+ ],
+ ),
+ );
+
+ if (skillsToCreate.length === 0) {
+ this.logger.log(
+ `All standard skills already exist for workspace ${workspaceId}, skipping`,
+ );
+
+ return;
+ }
+
+ this.logger.log(
+ `Found ${skillsToCreate.length} missing standard skill(s) for workspace ${workspaceId}: ${skillsToCreate.map((skill) => skill.name).join(', ')}`,
+ );
+
+ if (isDryRun) {
+ this.logger.log(
+ `[DRY RUN] Would create ${skillsToCreate.length} standard skill(s) for workspace ${workspaceId}`,
+ );
+
+ return;
+ }
+
+ const validateAndBuildResult =
+ await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunLegacyWorkspaceMigration(
+ {
+ allFlatEntityOperationByMetadataName: {
+ skill: {
+ flatEntityToCreate: skillsToCreate,
+ flatEntityToDelete: [],
+ flatEntityToUpdate: [],
+ },
+ },
+ workspaceId,
+ applicationUniversalIdentifier:
+ twentyStandardFlatApplication.universalIdentifier,
+ },
+ );
+
+ if (validateAndBuildResult.status === 'fail') {
+ this.logger.error(
+ `Failed to backfill standard skills:\n${JSON.stringify(validateAndBuildResult, null, 2)}`,
+ );
+
+ throw new Error(
+ `Failed to backfill standard skills for workspace ${workspaceId}`,
+ );
+ }
+
+ this.logger.log(
+ `Successfully created ${skillsToCreate.length} standard skill(s) for workspace ${workspaceId}`,
+ );
+ }
+}
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts
index 5271c3de26..3d742e4da3 100644
--- a/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts
+++ b/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts
@@ -26,6 +26,7 @@ import { V2_22_UpgradeVersionCommandModule } from 'src/database/commands/upgrade
import { V2_23_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-23/2-23-upgrade-version-command.module';
import { V2_25_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-25/2-25-upgrade-version-command.module';
import { V2_26_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-26/2-26-upgrade-version-command.module';
+import { V2_27_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-27/2-27-upgrade-version-command.module';
@Module({
imports: [
@@ -55,6 +56,7 @@ import { V2_26_UpgradeVersionCommandModule } from 'src/database/commands/upgrade
V2_23_UpgradeVersionCommandModule,
V2_25_UpgradeVersionCommandModule,
V2_26_UpgradeVersionCommandModule,
+ V2_27_UpgradeVersionCommandModule,
],
})
export class WorkspaceCommandProviderModule {}
diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-skill.constant.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-skill.constant.ts
index e3bb0663f2..5d341b26a9 100644
--- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-skill.constant.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-skill.constant.ts
@@ -41,6 +41,9 @@ export const STANDARD_SKILL = {
'custom-objects-cleanup': {
universalIdentifier: '20202020-a1d3-4e5f-b6c7-8d9e0f1a2b3c',
},
+ roles: {
+ universalIdentifier: '20202020-3f7c-4d21-9e58-a4b6c8d05e17',
+ },
} as const satisfies Record<
string,
{
diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts
index c766ab6fd9..181d071a3a 100644
--- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts
@@ -1719,6 +1719,106 @@ python /home/user/scripts/pptx/replace.py input.pptx '{"{{company}}": "Acme Corp
isCustom: false,
},
}),
+
+ roles: (args: Omit) =>
+ createStandardSkillFlatMetadata({
+ ...args,
+ context: {
+ skillName: 'roles',
+ name: 'roles',
+ label: 'Roles',
+ description:
+ 'Managing roles and permissions: who can read, edit and delete what',
+ icon: 'IconLockAccess',
+ content: `# Roles Skill
+
+You help users manage roles and permissions in their workspace. Roles live under Settings > Members > Roles in the UI.
+
+## Tools
+
+- list_roles (read-only; pass includeRowLevelPermissionRules to also get row-level rules)
+- create_role, update_role, delete_role
+- assign_role_to_workspace_member
+- upsert_object_permissions (per-object overrides)
+- upsert_row_level_permission_rules (which records are visible; enterprise feature)
+- get_object_metadata / get_field_metadata (resolve object + field IDs)
+
+## ALWAYS call list_roles first
+
+Every workspace already ships with an **Admin** role and a **Member** role, and Member is the workspace default role. Never assume the workspace is empty.
+
+Call \`list_roles\` before proposing anything, then build on what is already there: adjusting an existing role is almost always better than creating a near-duplicate of it. Only create a new role when no existing role can reasonably be adapted.
+
+\`list_roles\` is also the only way to see a role's current per-object overrides, which you need before calling \`upsert_object_permissions\` (see below).
+
+## Confirmation gate (ALWAYS ask before creating, updating, deleting or assigning)
+
+Before calling ANY tool that changes roles or permissions (\`create_role\`, \`update_role\`, \`delete_role\`, \`assign_role_to_workspace_member\`, \`upsert_object_permissions\`, \`upsert_row_level_permission_rules\`), you MUST first present a short plan and get explicit user confirmation.
+
+- Read first: \`list_roles\`, \`get_object_metadata\` and \`get_field_metadata\` are read-only and allowed before confirmation.
+- Then summarize what you intend to do: which role, which permissions change, which objects are affected, who is impacted, and any assumptions or defaults you are making.
+- Ask the user to confirm (or adjust), then STOP and wait for their answer. Do NOT call any mutating tool in the same turn as the plan.
+- Only after the user confirms do you proceed in the next turn.
+- Keep the plan concise — a few bullets, not an essay.
+- **Deleting a role always requires an explicit confirmation**, even if the user seemed to ask for it: say which role is going away and that its members, agents and API keys will be reassigned to the workspace default role.
+
+Permissions decide who can see and change company data, so a wrong guess is expensive. When a request is ambiguous about scope ("make it read-only" — for which objects?), resolve it in the plan and let the user correct you.
+
+## upsert_object_permissions REPLACES the whole override list
+
+\`upsert_object_permissions\` is not incremental. The \`objectPermissions\` array you send becomes the role's complete set of per-object overrides:
+
+- Objects omitted from the array lose their override and fall back to the role's global permissions (\`canReadAllObjectRecords\`, ...).
+- So to add one override you must resend every override the role already has, plus the new one.
+- Always call \`list_roles\` first, take the role's current overrides, and send them back together with your change.
+
+Dropping an override silently widens access. Treat "I only sent the object I changed" as a bug.
+
+\`upsert_row_level_permission_rules\` behaves the same way for a given role + object: the \`predicates\` and \`predicateGroups\` arrays are the complete rule set, anything omitted is deleted, and empty arrays clear all rules.
+
+## Permission rules the API enforces
+
+- **Write without read is rejected.** Never grant update / soft-delete / destroy on an object without also granting read, both on \`create_role\` and on \`upsert_object_permissions\`.
+- **System-managed roles cannot be modified.** Roles with \`isEditable=false\` (like Admin) cannot be updated, deleted, or given overrides. If the user wants "an Admin but without X", create a new role instead of trying to change Admin.
+- The **workspace default role** cannot be deleted, and neither can a role **you are currently assigned to**.
+- You cannot change **your own** role assignment, assign a role whose \`canBeAssignedToUsers\` is false, or remove the admin role from the **last administrator**.
+
+### Lockout guard
+
+Mutations that would strip the acting admin's own access are rejected. Concretely, you cannot delete a role you hold, and you cannot set \`canUpdateAllSettings: false\` on a role you hold unless that role keeps an explicit ROLES permission flag.
+
+If you get \`CANNOT_DELETE_OWN_ROLE\` or \`CANNOT_REVOKE_OWN_SETTINGS_ACCESS\`, this is that guard, not a bug: explain to the user that they would be locking themselves out of role management, and propose doing it from another admin account or on a different role.
+
+## create_role defaults
+
+Only \`label\` is required; \`description\` and \`icon\` are optional.
+
+- All global record permissions (\`canReadAllObjectRecords\`, \`canUpdateAllObjectRecords\`, \`canSoftDeleteAllObjectRecords\`, \`canDestroyAllObjectRecords\`), \`canUpdateAllSettings\` and \`canAccessAllTools\` default to **false**.
+- Assignability (\`canBeAssignedToUsers\`, \`canBeAssignedToAgents\`, \`canBeAssignedToApiKeys\`) defaults to **true**.
+
+So a role created with only a label can see nothing. Set the global flags you want in the same \`create_role\` call, then use \`upsert_object_permissions\` for the exceptions.
+
+## Common shapes
+
+**Broad access with a read-only exception** — set the global flags wide on the role (\`canReadAllObjectRecords: true\`, \`canUpdateAllObjectRecords: true\`), then add one override for the restricted object:
+\`{ objectMetadataId, canReadObjectRecords: true, canUpdateObjectRecords: false, canSoftDeleteObjectRecords: false, canDestroyObjectRecords: false }\`
+
+**Narrow access to a few objects** — leave the global flags false and add one override per allowed object, each granting read (plus write where wanted). Remember every override must be in the same call.
+
+**Members only see their own records** — keep object access as is and use \`upsert_row_level_permission_rules\`: one predicate with \`fieldMetadataId\` = the owner-like relation field on the object, \`operand\` = IS, and \`workspaceMemberFieldMetadataId\` = the \`id\` field of the workspaceMember object, which resolves to the current user at query time. Resolve both field IDs with \`get_field_metadata\` first.
+
+## Assigning roles
+
+\`assign_role_to_workspace_member\` replaces the member's current role; it does not add a second one. You need the workspace member's UUID, which comes from the workspace member records (e.g. \`find_many_workspace_members\`), not from \`list_roles\`.
+
+When the user names a person, resolve them to a workspace member first and restate who you matched in the confirmation plan — assigning the wrong person a powerful role is a security incident.
+
+## After mutating
+
+Report what changed in plain terms: which role, what it can now do, and who is affected. If you changed overrides, restate the objects that are still overridden so the user can see nothing was dropped.`,
+ isCustom: false,
+ },
+ }),
} satisfies {
[P in AllStandardSkillName]: (
args: Omit,