[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
@@ -229,8 +229,11 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
},
"permissionFlag": {
"propertiesToCompare": [
"flag",
"roleUniversalIdentifier",
"key",
"label",
"description",
"icon",
"permissionType",
],
"propertiesToStringify": [],
},
@@ -251,6 +254,14 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
],
"propertiesToStringify": [],
},
"rolePermissionFlag": {
"propertiesToCompare": [
"permissionFlagUniversalIdentifier",
"roleUniversalIdentifier",
"flag",
],
"propertiesToStringify": [],
},
"roleTarget": {
"propertiesToCompare": [
"roleUniversalIdentifier",
@@ -1186,17 +1186,59 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
universalProperty: 'pageLayoutUniversalIdentifier',
},
},
permissionFlag: {
flag: {
rolePermissionFlag: {
permissionFlagId: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
universalProperty: 'permissionFlagUniversalIdentifier',
},
roleId: {
toCompare: true,
toStringify: false,
universalProperty: 'roleUniversalIdentifier',
},
flag: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
},
createdAt: {
toCompare: false,
toStringify: false,
universalProperty: undefined,
},
updatedAt: {
toCompare: false,
toStringify: false,
universalProperty: undefined,
},
},
permissionFlag: {
key: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
},
label: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
},
description: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
},
icon: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
},
permissionType: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
},
createdAt: {
toCompare: false,
toStringify: false,
@@ -171,12 +171,19 @@ export const ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY = {
workspace: null,
application: null,
},
permissionFlag: {
rolePermissionFlag: {
workspace: null,
application: null,
role: {
foreignKey: 'roleId',
},
permissionFlag: {
foreignKey: 'permissionFlagId',
},
},
permissionFlag: {
workspace: null,
application: null,
},
objectPermission: {
workspace: null,
@@ -300,16 +300,27 @@ export const ALL_MANY_TO_ONE_METADATA_RELATIONS = {
workspace: null,
application: null,
},
permissionFlag: {
rolePermissionFlag: {
workspace: null,
application: null,
role: {
metadataName: 'role',
foreignKey: 'roleId',
inverseOneToManyProperty: 'permissionFlags',
inverseOneToManyProperty: 'rolePermissionFlags',
isNullable: false,
universalForeignKey: 'roleUniversalIdentifier',
},
permissionFlag: {
metadataName: 'permissionFlag',
foreignKey: 'permissionFlagId',
inverseOneToManyProperty: 'rolePermissionFlags',
isNullable: true,
universalForeignKey: 'permissionFlagUniversalIdentifier',
},
},
permissionFlag: {
workspace: null,
application: null,
},
objectPermission: {
workspace: null,
@@ -17,6 +17,7 @@ import { PageLayoutTabEntity } from 'src/engine/metadata-modules/page-layout-tab
import { PageLayoutWidgetEntity } from 'src/engine/metadata-modules/page-layout-widget/entities/page-layout-widget.entity';
import { PageLayoutEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout.entity';
import { PermissionFlagEntity } from 'src/engine/metadata-modules/permission-flag/permission-flag.entity';
import { RolePermissionFlagEntity } from 'src/engine/metadata-modules/role-permission-flag/role-permission-flag.entity';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { RowLevelPermissionPredicateGroupEntity } from 'src/engine/metadata-modules/row-level-permission-predicate/entities/row-level-permission-predicate-group.entity';
@@ -56,6 +57,7 @@ export const ALL_METADATA_ENTITY_BY_METADATA_NAME = {
agent: AgentEntity,
commandMenuItem: CommandMenuItemEntity,
navigationMenuItem: NavigationMenuItemEntity,
rolePermissionFlag: RolePermissionFlagEntity,
permissionFlag: PermissionFlagEntity,
webhook: WebhookEntity,
applicationVariable: ApplicationVariableEntity,
@@ -76,9 +76,13 @@ export const ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION = {
view: true,
pageLayout: true,
},
permissionFlag: {
rolePermissionFlag: {
permissionFlag: true,
role: true,
},
permissionFlag: {
rolePermissionFlag: true,
},
objectPermission: {
role: true,
objectMetadata: true,
@@ -33,6 +33,7 @@ export const ALL_METADATA_SERIALIZED_RELATION = {
logicFunction: {},
role: {},
roleTarget: {},
rolePermissionFlag: {},
permissionFlag: {},
objectPermission: {},
fieldPermission: {},
@@ -168,11 +168,11 @@ export const ALL_ONE_TO_MANY_METADATA_RELATIONS = {
universalFlatEntityForeignKeyAggregator:
'objectPermissionUniversalIdentifiers',
},
permissionFlags: {
metadataName: 'permissionFlag',
flatEntityForeignKeyAggregator: 'permissionFlagIds',
rolePermissionFlags: {
metadataName: 'rolePermissionFlag',
flatEntityForeignKeyAggregator: 'rolePermissionFlagIds',
universalFlatEntityForeignKeyAggregator:
'permissionFlagUniversalIdentifiers',
'rolePermissionFlagUniversalIdentifiers',
},
fieldPermissions: {
metadataName: 'fieldPermission',
@@ -194,7 +194,15 @@ export const ALL_ONE_TO_MANY_METADATA_RELATIONS = {
},
},
roleTarget: {},
permissionFlag: {},
rolePermissionFlag: {},
permissionFlag: {
rolePermissionFlags: {
metadataName: 'rolePermissionFlag',
flatEntityForeignKeyAggregator: 'rolePermissionFlagIds',
universalFlatEntityForeignKeyAggregator:
'rolePermissionFlagUniversalIdentifiers',
},
},
objectPermission: {},
fieldPermission: {},
pageLayout: {