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,