[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
@@ -1,7 +1,10 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { PermissionFlagType } from 'twenty-shared/constants';
import {
PermissionFlagType,
SystemPermissionFlag,
} from 'twenty-shared/constants';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
@@ -60,7 +63,7 @@ describe('PermissionsService', () => {
canBeAssignedToUsers: true,
canBeAssignedToAgents: true,
canBeAssignedToApiKeys: true,
permissionFlags: [],
rolePermissionFlags: [],
workspaceId: 'test-workspace-id',
createdAt: new Date(),
updatedAt: new Date(),
@@ -133,7 +136,7 @@ describe('PermissionsService', () => {
canBeAssignedToUsers: true,
canBeAssignedToAgents: true,
canBeAssignedToApiKeys: true,
permissionFlags: [],
rolePermissionFlags: [],
workspaceId: 'test-workspace-id',
createdAt: new Date(),
updatedAt: new Date(),
@@ -184,7 +187,7 @@ describe('PermissionsService', () => {
canBeAssignedToUsers: true,
canBeAssignedToAgents: true,
canBeAssignedToApiKeys: true,
permissionFlags: [],
rolePermissionFlags: [],
roleTargets: [],
objectPermissions: [],
fieldPermissions: [],
@@ -254,7 +257,7 @@ describe('PermissionsService', () => {
canBeAssignedToUsers: true,
canBeAssignedToAgents: true,
canBeAssignedToApiKeys: true,
permissionFlags: [],
rolePermissionFlags: [],
roleTargets: [],
objectPermissions: [],
fieldPermissions: [],
@@ -292,8 +295,8 @@ describe('PermissionsService', () => {
});
});
describe('Granular permissions with permissionFlags', () => {
it('should grant specific tool permission when included in permissionFlags even if canAccessAllTools is false', () => {
describe('Granular permissions with rolePermissionFlags', () => {
it('should grant specific tool permission when included in rolePermissionFlags even if canAccessAllTools is false', () => {
const roleWithSpecificPermission: Partial<RoleEntity> = {
id: 'test-role-id',
label: 'Test Role',
@@ -308,10 +311,13 @@ describe('PermissionsService', () => {
canBeAssignedToUsers: true,
canBeAssignedToAgents: true,
canBeAssignedToApiKeys: true,
permissionFlags: [
rolePermissionFlags: [
{
id: 'permission-1',
flag: PermissionFlagType.UPLOAD_FILE,
permissionFlag: {
key: PermissionFlagType.UPLOAD_FILE,
universalIdentifier: SystemPermissionFlag.UPLOAD_FILE,
},
roleId: 'test-role-id',
workspaceId: 'test-workspace-id',
createdAt: new Date(),
@@ -338,7 +344,7 @@ describe('PermissionsService', () => {
).toBe(false);
});
it('should grant specific settings permission when included in permissionFlags even if canUpdateAllSettings is false', () => {
it('should grant specific settings permission when included in rolePermissionFlags even if canUpdateAllSettings is false', () => {
const roleWithSpecificPermission: Partial<RoleEntity> = {
id: 'test-role-id',
label: 'Test Role',
@@ -353,10 +359,13 @@ describe('PermissionsService', () => {
canBeAssignedToUsers: true,
canBeAssignedToAgents: true,
canBeAssignedToApiKeys: true,
permissionFlags: [
rolePermissionFlags: [
{
id: 'permission-1',
flag: PermissionFlagType.ROLES,
permissionFlag: {
key: PermissionFlagType.ROLES,
universalIdentifier: SystemPermissionFlag.ROLES,
},
roleId: 'test-role-id',
workspaceId: 'test-workspace-id',
createdAt: new Date(),
@@ -400,7 +409,7 @@ describe('PermissionsService', () => {
canBeAssignedToUsers: true,
canBeAssignedToAgents: true,
canBeAssignedToApiKeys: true,
permissionFlags: [],
rolePermissionFlags: [],
roleTargets: [],
objectPermissions: [],
fieldPermissions: [],
@@ -2,7 +2,10 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
import { PermissionFlagType } from 'twenty-shared/constants';
import {
PermissionFlagType,
SystemPermissionFlag,
} from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { In, Repository } from 'typeorm';
@@ -66,7 +69,7 @@ export class PermissionsService {
const defaultSettingsPermissions =
this.getDefaultUserWorkspacePermissions().permissionFlags;
const permissionFlags = Object.keys(PermissionFlagType).reduce(
const permissionFlags = Object.values(PermissionFlagType).reduce(
(acc, feature) => {
const hasBasePermission = this.isToolPermission(feature)
? roleOfUserWorkspace.canAccessAllTools
@@ -76,9 +79,7 @@ export class PermissionsService {
...acc,
[feature]:
hasBasePermission ||
roleOfUserWorkspace.permissionFlags.some(
(permissionFlag) => permissionFlag.flag === feature,
),
this.roleHasPermissionFlag(roleOfUserWorkspace, feature),
};
},
defaultSettingsPermissions,
@@ -150,7 +151,10 @@ export class PermissionsService {
const role = await this.roleRepository.findOne({
where: { id: roleId, workspaceId },
relations: ['permissionFlags'],
relations: [
'rolePermissionFlags',
'rolePermissionFlags.permissionFlag',
],
});
if (!isDefined(role)) {
@@ -203,7 +207,10 @@ export class PermissionsService {
const role = await this.roleRepository.findOne({
where: { id: applicationRoleId, workspaceId },
relations: ['permissionFlags'],
relations: [
'rolePermissionFlags',
'rolePermissionFlags.permissionFlag',
],
});
if (!isDefined(role)) {
@@ -240,10 +247,22 @@ export class PermissionsService {
return true;
}
const permissionFlags = role.permissionFlags ?? [];
return this.roleHasPermissionFlag(role, setting);
}
return permissionFlags.some(
(permissionFlag) => permissionFlag.flag === setting,
private roleHasPermissionFlag(
role: RoleEntity,
flag: PermissionFlagType,
): boolean {
const rolePermissionFlags = role.rolePermissionFlags ?? [];
const permissionFlagUniversalIdentifier = SystemPermissionFlag[flag];
return rolePermissionFlags.some(
(rolePermissionFlag) =>
(rolePermissionFlag.permissionFlag?.universalIdentifier ??
SystemPermissionFlag[rolePermissionFlag.flag]) ===
permissionFlagUniversalIdentifier,
);
}
@@ -292,7 +311,7 @@ export class PermissionsService {
const result = await this.getRolesFromPermissionConfig(
rolePermissionConfig,
workspaceId,
['permissionFlags'],
['rolePermissionFlags', 'rolePermissionFlags.permissionFlag'],
);
if (result === null) {
@@ -318,7 +337,7 @@ export class PermissionsService {
const result = await this.getRolesFromPermissionConfig(
rolePermissionConfig,
workspaceId,
['permissionFlags'],
['rolePermissionFlags', 'rolePermissionFlags.permissionFlag'],
);
if (result === null) {
@@ -332,11 +351,7 @@ export class PermissionsService {
return true;
}
const permissionFlags = role.permissionFlags ?? [];
return permissionFlags.some(
(permissionFlag) => permissionFlag.flag === flag,
);
return this.roleHasPermissionFlag(role, flag);
};
return useIntersection