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.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23636?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:
Raphaël Bosi
2026-07-31 15:20:43 +02:00
committed by GitHub
parent 0f06fccdee
commit 2aa118cb0d
5 changed files with 242 additions and 0 deletions
@@ -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 {}
@@ -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<void> {
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}`,
);
}
}
@@ -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 {}
@@ -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,
{
@@ -1719,6 +1719,106 @@ python /home/user/scripts/pptx/replace.py input.pptx '{"{{company}}": "Acme Corp
isCustom: false,
},
}),
roles: (args: Omit<CreateStandardSkillArgs, 'context'>) =>
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<CreateStandardSkillArgs, 'context'>,