[1/3] Rename permissionFlag to rolePermissionFlag + add permissionFlag catalog/backfill (#20481)

Split of #20377.

## Summary

This PR separates available permission flags from per-role permission
flag grants.

Previously, `core.permissionFlag` stored the role assignment directly:
`roleId + flag`. This PR renames that legacy grant table to
`core.rolePermissionFlag`, then recreates `core.permissionFlag` as the
catalog of available permission flags.

## What changed

- Rename the existing `core.permissionFlag` grant table to
`core.rolePermissionFlag`.
- Add the new syncable `core.permissionFlag` catalog entity with key,
label, description, icon, permission type, relevance flags, and
custom/standard metadata.
- Add stable `SystemPermissionFlag` universal identifiers for the
built-in `PermissionFlagType` values.
- Seed the standard permission flags for every workspace under the
Twenty standard application.
- Backfill existing role grants:
  - create missing catalog rows for existing grant keys,
  - add `rolePermissionFlag.permissionFlagId`,
- migrate grants from the old string `flag` column to the new catalog
FK,
- replace the old `(flag, roleId)` uniqueness with `(permissionFlagId,
roleId)`.
- Rewire role permission flag caches, permission checks, role DTO
mapping, and `upsertPermissionFlags` to resolve through the catalog.
- Keep the existing public role permission API shape: product/app
surfaces still talk about `permissionFlags` and return `{ id, roleId,
flag }`.
- Update metadata flat-entity machinery, migration builders, validators,
action handlers, snapshots, generated schemas, docs, and app fixtures
for the new `permissionFlag` / `rolePermissionFlag` split.

## Behavior after this PR

- Existing permission flag grants keep working.
- Existing GraphQL role permission flows keep the same public naming.
- Standard permission flags are represented as catalog rows.
- Permission checks now compare grants through catalog universal
identifiers instead of the legacy `flag` column.
- Workspace deletion cleanup now verifies both `permissionFlag` and
`rolePermissionFlag`.

## What is not in this PR

- Public GraphQL CRUD for custom permission flags.
- App manifest support for declaring new custom permission flags.
- Frontend UI for creating or assigning custom permission flags beyond
the existing role permission flow.

---------

Co-authored-by: Weiko <corentin@twenty.com>
This commit is contained in:
nitin
2026-05-18 17:27:47 +05:30
committed by GitHub
parent 01535a3b3e
commit db0547f503
132 changed files with 3663 additions and 719 deletions
@@ -0,0 +1,102 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.6.0', 1778235340020)
export class RenamePermissionFlagToRolePermissionFlagFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
// The legacy permissionFlag table stores per-role grants (`roleId` + `flag`).
// Rename it to preserve those rows before creating the new permissionFlag catalog table.
await queryRunner.query(
`ALTER TABLE "core"."permissionFlag" RENAME TO "rolePermissionFlag"`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" RENAME CONSTRAINT "PK_a02789db60620a1e9f90147b50f" TO "PK_76591adc8035c2e7b0cd6115136"`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" RENAME CONSTRAINT "IDX_PERMISSION_FLAG_FLAG_ROLE_ID_UNIQUE" TO "IDX_ROLE_PERMISSION_FLAG_FLAG_ROLE_ID_UNIQUE"`,
);
await queryRunner.query(
`ALTER INDEX "core"."IDX_PERMISSION_FLAG_ROLE_ID" RENAME TO "IDX_ROLE_PERMISSION_FLAG_ROLE_ID"`,
);
// Re-hash inherited constraints/indexes so TypeORM's schema diff matches
// the renamed table. Original names were derived from "permissionFlag"
// and stay free for the new catalog table created by the next migration.
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" DROP CONSTRAINT "FK_13f8ca9c517976733a1ce4c10eb"`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" DROP CONSTRAINT "FK_835bc9f7ef959debfc5cd268049"`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" DROP CONSTRAINT "FK_b26a9d39a88d0e72373c677c6c5"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_da8ffd3c24b4a819430a861067"`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_e4559ae0dba56e53714137c704" ON "core"."rolePermissionFlag" ("workspaceId", "universalIdentifier")`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" ADD CONSTRAINT "FK_d47b1ebee75d98daa0c870c26e3" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" ADD CONSTRAINT "FK_3835ecc1019327566d35728c8ba" FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" ADD CONSTRAINT "FK_4c6ea38698de230b0ec18fa2110" FOREIGN KEY ("roleId") REFERENCES "core"."role"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" DROP CONSTRAINT "FK_4c6ea38698de230b0ec18fa2110"`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" DROP CONSTRAINT "FK_3835ecc1019327566d35728c8ba"`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" DROP CONSTRAINT "FK_d47b1ebee75d98daa0c870c26e3"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_e4559ae0dba56e53714137c704"`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_da8ffd3c24b4a819430a861067" ON "core"."rolePermissionFlag" ("workspaceId", "universalIdentifier")`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" ADD CONSTRAINT "FK_b26a9d39a88d0e72373c677c6c5" FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" ADD CONSTRAINT "FK_835bc9f7ef959debfc5cd268049" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" ADD CONSTRAINT "FK_13f8ca9c517976733a1ce4c10eb" FOREIGN KEY ("roleId") REFERENCES "core"."role"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER INDEX "core"."IDX_ROLE_PERMISSION_FLAG_ROLE_ID" RENAME TO "IDX_PERMISSION_FLAG_ROLE_ID"`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" RENAME CONSTRAINT "IDX_ROLE_PERMISSION_FLAG_FLAG_ROLE_ID_UNIQUE" TO "IDX_PERMISSION_FLAG_FLAG_ROLE_ID_UNIQUE"`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" RENAME CONSTRAINT "PK_76591adc8035c2e7b0cd6115136" TO "PK_a02789db60620a1e9f90147b50f"`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag" RENAME TO "permissionFlag"`,
);
}
}
@@ -0,0 +1,69 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.6.0', 1778235340021)
export class PermissionFlagSyncableEntityFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "core"."permissionFlag" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"workspaceId" uuid NOT NULL,
"applicationId" uuid NOT NULL,
"universalIdentifier" uuid NOT NULL,
"key" varchar NOT NULL,
"label" varchar NOT NULL,
"description" text,
"icon" varchar,
"permissionType" varchar NOT NULL,
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "IDX_PERMISSION_FLAG_KEY_WORKSPACE_ID_UNIQUE" UNIQUE ("key", "workspaceId"),
CONSTRAINT "PK_a02789db60620a1e9f90147b50f" PRIMARY KEY ("id")
)`,
);
await queryRunner.query(
`CREATE INDEX "IDX_PERMISSION_FLAG_APPLICATION_ID" ON "core"."permissionFlag" ("applicationId")`,
);
// Reuses canonical hash names freed by the rename migration so TypeORM's
// schema diff matches without further intervention.
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_da8ffd3c24b4a819430a861067" ON "core"."permissionFlag" ("workspaceId", "universalIdentifier")`,
);
await queryRunner.query(
`ALTER TABLE "core"."permissionFlag"
ADD CONSTRAINT "FK_835bc9f7ef959debfc5cd268049"
FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id")
ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."permissionFlag"
ADD CONSTRAINT "FK_b26a9d39a88d0e72373c677c6c5"
FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id")
ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."permissionFlag" DROP CONSTRAINT IF EXISTS "FK_b26a9d39a88d0e72373c677c6c5"`,
);
await queryRunner.query(
`ALTER TABLE "core"."permissionFlag" DROP CONSTRAINT IF EXISTS "FK_835bc9f7ef959debfc5cd268049"`,
);
await queryRunner.query(
`DROP INDEX IF EXISTS "core"."IDX_da8ffd3c24b4a819430a861067"`,
);
await queryRunner.query(
`DROP INDEX IF EXISTS "core"."IDX_PERMISSION_FLAG_APPLICATION_ID"`,
);
await queryRunner.query(`DROP TABLE IF EXISTS "core"."permissionFlag"`);
}
}
@@ -0,0 +1,55 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.6.0', 1778235340022)
export class LinkRolePermissionFlagToPermissionFlagFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag"
ADD COLUMN IF NOT EXISTS "permissionFlagId" uuid`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag"
ADD CONSTRAINT "IDX_ROLE_PERMISSION_FLAG_PERMISSION_FLAG_ID_ROLE_ID_UNIQUE"
UNIQUE ("permissionFlagId", "roleId")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_ROLE_PERMISSION_FLAG_PERMISSION_FLAG_ID"
ON "core"."rolePermissionFlag" ("permissionFlagId")`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag"
ADD CONSTRAINT "FK_8724e63323f1331591a3e91b0b3"
FOREIGN KEY ("permissionFlagId") REFERENCES "core"."permissionFlag"("id")
ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag"
DROP CONSTRAINT IF EXISTS "FK_8724e63323f1331591a3e91b0b3"`,
);
await queryRunner.query(
`DROP INDEX IF EXISTS "core"."IDX_ROLE_PERMISSION_FLAG_PERMISSION_FLAG_ID"`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag"
DROP CONSTRAINT IF EXISTS "IDX_ROLE_PERMISSION_FLAG_PERMISSION_FLAG_ID_ROLE_ID_UNIQUE"`,
);
await queryRunner.query(
`ALTER TABLE "core"."rolePermissionFlag"
DROP COLUMN IF EXISTS "permissionFlagId"`,
);
}
}
@@ -0,0 +1,88 @@
import { TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER } from 'twenty-shared/application';
import { PermissionFlagType } from 'twenty-shared/constants';
import { DataSource, QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface';
import { STANDARD_PERMISSION_FLAG_DEFINITIONS } from 'src/engine/metadata-modules/permission-flag/constants/standard-permission-flag-definitions.constant';
const PERMISSION_FLAG_TYPES = Object.values(PermissionFlagType) as string[];
@RegisteredInstanceCommand('2.6.0', 1778235340023, { type: 'slow' })
export class BackfillRolePermissionFlagPermissionFlagIdSlowInstanceCommand
implements SlowInstanceCommand
{
async runDataMigration(dataSource: DataSource): Promise<void> {
const unknownFlagRows: { flag: string }[] = await dataSource.query(
`SELECT DISTINCT "flag" FROM "core"."rolePermissionFlag"
WHERE "flag" <> ALL($1::varchar[])`,
[PERMISSION_FLAG_TYPES],
);
if (unknownFlagRows.length > 0) {
const unknownFlags = unknownFlagRows.map((row) => row.flag).join(', ');
throw new Error(
`Cannot migrate: rolePermissionFlag rows reference unknown flag value(s): ${unknownFlags}`,
);
}
for (const definition of STANDARD_PERMISSION_FLAG_DEFINITIONS) {
await dataSource.query(
`INSERT INTO "core"."permissionFlag" (
"id",
"workspaceId",
"applicationId",
"universalIdentifier",
"key",
"label",
"description",
"icon",
"permissionType",
"createdAt",
"updatedAt"
)
SELECT
uuid_generate_v4(),
workspace."id",
standardApplication."id",
$1::uuid,
$2,
$3,
$4,
$5,
$6,
now(),
now()
FROM "core"."workspace" workspace
INNER JOIN "core"."application" standardApplication
ON standardApplication."workspaceId" = workspace."id"
AND standardApplication."universalIdentifier" = $7
AND standardApplication."deletedAt" IS NULL
ON CONFLICT ("key", "workspaceId") DO NOTHING`,
[
definition.universalIdentifier,
definition.key,
definition.label,
definition.description,
definition.icon,
definition.permissionType,
TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER,
],
);
}
await dataSource.query(
`UPDATE "core"."rolePermissionFlag" rolePermissionFlag
SET "permissionFlagId" = permissionFlag."id"
FROM "core"."permissionFlag" permissionFlag
WHERE permissionFlag."workspaceId" = rolePermissionFlag."workspaceId"
AND permissionFlag."key" = rolePermissionFlag."flag"
AND rolePermissionFlag."permissionFlagId" IS NULL`,
);
}
public async up(_queryRunner: QueryRunner): Promise<void> {}
public async down(_queryRunner: QueryRunner): Promise<void> {}
}
@@ -31,6 +31,10 @@ import { AddToolAndWorkflowActionTriggerSettingsFastInstanceCommand } from 'src/
import { BackfillApplicationVariableUniversalIdentifierSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-slow-1777966965588-backfill-application-variable-universal-identifier';
import { MigrateToolTriggerSettingsSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-slow-1797000002000-migrate-tool-trigger-settings';
import { AddMetadataToBillingPriceFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-4/2-4-instance-command-fast-1777100000000-add-metadata-to-billing-price';
import { RenamePermissionFlagToRolePermissionFlagFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-fast-1778235340020-rename-permission-flag-to-role-permission-flag';
import { PermissionFlagSyncableEntityFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-fast-1778235340021-permission-flag-syncable-entity';
import { LinkRolePermissionFlagToPermissionFlagFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-fast-1778235340022-link-role-permission-flag-to-permission-flag';
import { BackfillRolePermissionFlagPermissionFlagIdSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-slow-1778235340023-backfill-role-permission-flag-permission-flag-id';
import { AddEmailGroupChannelTypeFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-4/2-4-instance-command-fast-1778256809018-add-email-group-channel-type';
import { AddApplicationIdToPublicDomainFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-4/2-4-instance-command-fast-1798000003000-add-application-id-to-public-domain';
import { AddIsInternalMessagesImportEnabledFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1778525104406-add-is-internal-messages-import-enabled';
@@ -77,6 +81,10 @@ export const INSTANCE_COMMANDS = [
RemoveUserDefaultAvatarUrlFastInstanceCommand,
TransformApplicationVariableToSyncableEntityFastInstanceCommand,
BackfillApplicationVariableUniversalIdentifierSlowInstanceCommand,
RenamePermissionFlagToRolePermissionFlagFastInstanceCommand,
PermissionFlagSyncableEntityFastInstanceCommand,
LinkRolePermissionFlagToPermissionFlagFastInstanceCommand,
BackfillRolePermissionFlagPermissionFlagIdSlowInstanceCommand,
AddEmailGroupChannelTypeFastInstanceCommand,
AddApplicationIdToPublicDomainFastInstanceCommand,
AddIsInternalMessagesImportEnabledFastInstanceCommand,