[Breaking change] Prepare non-system permission flags (#20847)

# Summary

Replaces the enum-keyed `permissionFlags: PermissionFlag[]` on roles
with `permissionFlagUniversalIdentifiers: string[]`

This unlocks mixing system flags (`SystemPermissionFlag.*`) with
app-defined flags in a role config.

This is a breaking change. Existing app source must switch to the new
field.

# Breaking changes

- `RoleManifest.permissionFlags` removed. Use
`RoleManifest.permissionFlagUniversalIdentifiers: string[]`.
- `RoleConfig.permissionFlags` removed (was `PermissionFlagType[]`). Use
`RoleConfig.permissionFlagUniversalIdentifiers: string[]`.
- `PermissionFlagManifest` type removed from
`twenty-shared/application`.
- `PermissionFlag` re-export removed from `twenty-sdk/define`.
`SystemPermissionFlag` is re-exported in its place.
- Retargeting a permission flag between roles is now classified as
delete + create instead of update

 ### Not in this PR
- definePermissionFlag SDK function and top-level
Manifest.permissionFlags catalog (apps defining their own custom flags).
Until those land, permissionFlagUniversalIdentifiers only accepts
SystemPermissionFlag.* UUIDs; arbitrary UUIDs fail validation.
This commit is contained in:
Weiko
2026-05-22 23:22:28 +02:00
committed by GitHub
parent eda41b4eba
commit 3bda05ea57
18 changed files with 81 additions and 80 deletions
@@ -1,4 +1,4 @@
import { PermissionFlag, defineRole } from 'twenty-sdk/define';
import { SystemPermissionFlag, defineRole } from 'twenty-sdk/define';
import {
CONTENT_FIELD_UNIVERSAL_IDENTIFIER,
POST_CARD_UNIVERSAL_IDENTIFIER,
@@ -36,5 +36,5 @@ export default defineRole({
canUpdateFieldValue: true,
},
],
permissionFlags: [PermissionFlag.APPLICATIONS],
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.APPLICATIONS],
});
@@ -1,4 +1,4 @@
import { PermissionFlag, defineRole } from 'twenty-sdk/define';
import { SystemPermissionFlag, defineRole } from 'twenty-sdk/define';
import {
CONTENT_FIELD_UNIVERSAL_IDENTIFIER,
POST_CARD_UNIVERSAL_IDENTIFIER,
@@ -36,5 +36,5 @@ export default defineRole({
canUpdateFieldValue: false,
},
],
permissionFlags: [PermissionFlag.APPLICATIONS],
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.APPLICATIONS],
});
@@ -15,10 +15,21 @@ import {
type ObjectManifest,
type RoleManifest,
} from 'twenty-shared/application';
import {
type PermissionFlagType,
SystemPermissionFlag,
} from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
import { FieldMetadataType } from '~/generated-metadata/graphql';
const SYSTEM_PERMISSION_FLAG_BY_UNIVERSAL_IDENTIFIER = Object.fromEntries(
Object.entries(SystemPermissionFlag).map(([key, uuid]) => [
uuid,
key as PermissionFlagType,
]),
);
type SettingsApplicationPermissionsTabProps = {
defaultRoleId?: string | null;
marketplaceAppDefaultRole?: RoleManifest;
@@ -121,12 +132,14 @@ const buildSyntheticRole = (
canReadFieldValue: permission.canReadFieldValue,
canUpdateFieldValue: permission.canUpdateFieldValue,
})),
permissionFlags: (defaultRole.permissionFlags ?? []).map(
(permissionFlag) => ({
permissionFlags: (defaultRole.permissionFlagUniversalIdentifiers ?? []).map(
(permissionFlagUniversalIdentifier) => ({
__typename: 'RolePermissionFlag' as const,
id: uuidv4(),
roleId: defaultRole.universalIdentifier,
flag: permissionFlag.flag,
flag: SYSTEM_PERMISSION_FLAG_BY_UNIVERSAL_IDENTIFIER[
permissionFlagUniversalIdentifier
],
}),
),
});
@@ -330,7 +330,7 @@ export const EXPECTED_MANIFEST: Manifest = {
canBeAssignedToApiKeys: false,
fieldPermissions: [],
objectPermissions: [],
permissionFlags: [],
permissionFlagUniversalIdentifiers: [],
},
],
};
@@ -1,6 +1,6 @@
import { FieldType } from '@/sdk/define';
import type { Manifest } from 'twenty-shared/application';
import { PermissionFlagType } from 'twenty-shared/constants';
import { SystemPermissionFlag } from 'twenty-shared/constants';
import {
FieldMetadataType,
NavigationMenuItemType,
@@ -1385,7 +1385,7 @@ export const EXPECTED_MANIFEST: Manifest = {
universalIdentifier: 'c0c1c2c3-c4c5-4000-8000-000000000001',
fieldPermissions: [],
objectPermissions: [],
permissionFlags: [],
permissionFlagUniversalIdentifiers: [],
},
{
canBeAssignedToAgents: false,
@@ -1417,12 +1417,7 @@ export const EXPECTED_MANIFEST: Manifest = {
objectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
},
],
permissionFlags: [
{
universalIdentifier: '01d7865a-7700-5d49-b2aa-62623c2cbac7',
flag: PermissionFlagType.APPLICATIONS,
},
],
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.APPLICATIONS],
universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
},
],
@@ -13,10 +13,10 @@ exports[`stub-twenty-sdk-define plugin > matches the recorded export partition 1
"ObjectRecordGroupByDateGranularity",
"OnDeleteAction",
"PageLayoutTabLayoutMode",
"PermissionFlag",
"RelationType",
"STANDARD_OBJECT",
"STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS",
"SystemPermissionFlag",
"ViewCalendarLayout",
"ViewFilterGroupLogicalOperator",
"ViewFilterOperand",
@@ -28,14 +28,7 @@ export const fromRoleConfigToRoleManifest = (
),
}),
),
permissionFlags: (roleConfig.permissionFlags ?? []).map(
(permissionFlag) => ({
universalIdentifier: uuidv5(
`${roleConfig.universalIdentifier}:${permissionFlag}`,
ROLE_UNIVERSAL_IDENTIFIER_NAMESPACE,
),
flag: permissionFlag,
}),
),
permissionFlagUniversalIdentifiers:
roleConfig.permissionFlagUniversalIdentifiers ?? [],
};
};
+1 -1
View File
@@ -86,7 +86,7 @@ export type {
export { defineApplicationRole } from '@/sdk/define/roles/define-application-role';
export { defineRole } from '@/sdk/define/roles/define-role';
export { PermissionFlag } from '@/sdk/define/roles/permission-flag-type';
export { SystemPermissionFlag } from 'twenty-shared/constants';
export { defineSkill } from '@/sdk/define/skills/define-skill';
@@ -1,4 +1,4 @@
import { defineApplicationRole } from '@/sdk/define';
import { SystemPermissionFlag, defineApplicationRole } from '@/sdk/define';
describe('defineApplicationRole', () => {
const validConfig = {
@@ -32,16 +32,16 @@ describe('defineApplicationRole', () => {
expect(result.config?.canReadAllObjectRecords).toBe(true);
});
it('should accept permissionFlags', () => {
it('should accept permissionFlagUniversalIdentifiers', () => {
const config = {
...validConfig,
permissionFlags: ['UPLOAD_FILE'],
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.UPLOAD_FILE],
};
const result = defineApplicationRole(config as any);
const result = defineApplicationRole(config);
expect(result.success).toBe(true);
expect(result.config?.permissionFlags).toHaveLength(1);
expect(result.config?.permissionFlagUniversalIdentifiers).toHaveLength(1);
});
it('should return error when universalIdentifier is missing', () => {
@@ -1,4 +1,4 @@
import { defineRole } from '@/sdk/define';
import { SystemPermissionFlag, defineRole } from '@/sdk/define';
describe('defineRole', () => {
const validConfig = {
@@ -32,16 +32,19 @@ describe('defineRole', () => {
expect(result.config?.canReadAllObjectRecords).toBe(true);
});
it('should accept permissionFlags', () => {
it('should accept permissionFlagUniversalIdentifiers', () => {
const config = {
...validConfig,
permissionFlags: ['UPLOAD_FILE', 'DOWNLOAD_FILE'],
permissionFlagUniversalIdentifiers: [
SystemPermissionFlag.UPLOAD_FILE,
SystemPermissionFlag.DOWNLOAD_FILE,
],
};
const result = defineRole(config as any);
const result = defineRole(config);
expect(result.success).toBe(true);
expect(result.config?.permissionFlags).toHaveLength(2);
expect(result.config?.permissionFlagUniversalIdentifiers).toHaveLength(2);
});
it('should return error when universalIdentifier is missing', () => {
@@ -3,13 +3,11 @@ import type {
ObjectPermissionManifest,
RoleManifest,
} from 'twenty-shared/application';
import { type PermissionFlagType } from 'twenty-shared/constants';
export type RoleConfig = Omit<
RoleManifest,
'objectPermissions' | 'fieldPermissions' | 'permissionFlags'
'objectPermissions' | 'fieldPermissions'
> & {
objectPermissions?: Omit<ObjectPermissionManifest, 'universalIdentifier'>[];
fieldPermissions?: Omit<FieldPermissionManifest, 'universalIdentifier'>[];
permissionFlags?: PermissionFlagType[];
};
@@ -1,26 +1,45 @@
import { type PermissionFlagManifest } from 'twenty-shared/application';
import { SystemPermissionFlag } from 'twenty-shared/constants';
import {
PermissionFlagType,
SystemPermissionFlag,
} from 'twenty-shared/constants';
import { v5 } from 'uuid';
import { type UniversalFlatRolePermissionFlag } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-role-permission-flag.type';
export const ROLE_PERMISSION_FLAG_UUID_NAMESPACE =
'b9a3b3b3-58a3-4f6c-9c1f-3a4f6c9c1f3a';
const SYSTEM_PERMISSION_FLAG_BY_UNIVERSAL_IDENTIFIER = Object.fromEntries(
Object.entries(SystemPermissionFlag).map(([key, uuid]) => [uuid, key]),
) as Record<string, PermissionFlagType | undefined>;
export const fromPermissionFlagToUniversalFlatRolePermissionFlag = ({
permissionFlag,
permissionFlagUniversalIdentifier,
roleUniversalIdentifier,
applicationUniversalIdentifier,
now,
}: {
permissionFlag: PermissionFlagManifest;
permissionFlagUniversalIdentifier: string;
roleUniversalIdentifier: string;
applicationUniversalIdentifier: string;
now: string;
}): UniversalFlatRolePermissionFlag => {
const universalIdentifier = v5(
`${roleUniversalIdentifier}:${permissionFlagUniversalIdentifier}`,
ROLE_PERMISSION_FLAG_UUID_NAMESPACE,
);
const resolvedFlag =
SYSTEM_PERMISSION_FLAG_BY_UNIVERSAL_IDENTIFIER[
permissionFlagUniversalIdentifier
];
return {
universalIdentifier: permissionFlag.universalIdentifier,
universalIdentifier,
applicationUniversalIdentifier,
roleUniversalIdentifier,
permissionFlagUniversalIdentifier:
SystemPermissionFlag[permissionFlag.flag],
flag: permissionFlag.flag,
permissionFlagUniversalIdentifier,
flag: resolvedFlag as PermissionFlagType,
createdAt: now,
updatedAt: now,
};
@@ -212,11 +212,12 @@ export const computeApplicationManifestAllUniversalFlatEntityMaps = ({
});
}
for (const permissionFlag of roleManifest.permissionFlags ?? []) {
for (const permissionFlagUniversalIdentifier of roleManifest.permissionFlagUniversalIdentifiers ??
[]) {
addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({
universalFlatEntity:
fromPermissionFlagToUniversalFlatRolePermissionFlag({
permissionFlag,
permissionFlagUniversalIdentifier,
roleUniversalIdentifier: roleManifest.universalIdentifier,
applicationUniversalIdentifier,
now,
@@ -105,11 +105,13 @@ exports[`Sync application should fail when retargeting existing permissions to a
},
],
"flatEntityMinimalInformation": {
"permissionFlagUniversalIdentifier": Any<String>,
"roleUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "rolePermissionFlag",
"status": "fail",
"type": "update",
"type": "create",
},
],
},
@@ -1,4 +1,4 @@
import { PermissionFlagType } from 'twenty-shared/constants';
import { SystemPermissionFlag } from 'twenty-shared/constants';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
@@ -10,7 +10,6 @@ import { STANDARD_ROLE } from 'src/engine/workspace-manager/twenty-standard-appl
const TEST_APP_ID = 'a1b2c3d4-0010-4000-a000-000000000010';
const TEST_ROLE_ID = 'a1b2c3d4-0010-4000-a000-000000000011';
const TEST_ROLE_PERMISSION_FLAG_ID = 'a1b2c3d4-0010-4000-a000-000000000012';
const TEST_OBJECT_PERMISSION_ID = 'a1b2c3d4-0010-4000-a000-000000000013';
const TEST_FIELD_PERMISSION_ID = 'a1b2c3d4-0010-4000-a000-000000000014';
const FAKE_OBJECT_ID = 'a1b2c3d4-0010-4000-a000-000000000020';
@@ -48,12 +47,7 @@ describe('Sync application should fail when creating permissions on a standard r
label: 'Stolen Admin Role',
description:
'Attempts to add permissions to the standard admin role',
permissionFlags: [
{
universalIdentifier: TEST_ROLE_PERMISSION_FLAG_ID,
flag: PermissionFlagType.WORKSPACE,
},
],
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.WORKSPACE],
},
],
},
@@ -1,4 +1,4 @@
import { PermissionFlagType } from 'twenty-shared/constants';
import { SystemPermissionFlag } from 'twenty-shared/constants';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
@@ -11,7 +11,6 @@ import { STANDARD_ROLE } from 'src/engine/workspace-manager/twenty-standard-appl
const TEST_APP_ID = 'a1b2c3d4-0020-4000-a000-000000000010';
const TEST_ROLE_ID = 'a1b2c3d4-0020-4000-a000-000000000011';
const TEST_ROLE_PERMISSION_FLAG_ID = 'a1b2c3d4-0020-4000-a000-000000000012';
const TEST_OBJECT_PERMISSION_ID = 'a1b2c3d4-0020-4000-a000-000000000013';
const TEST_FIELD_PERMISSION_ID = 'a1b2c3d4-0020-4000-a000-000000000014';
@@ -45,12 +44,7 @@ describe('Sync application should fail when retargeting existing permissions to
universalIdentifier: TEST_ROLE_ID,
label: 'App Default Role',
description: 'Default role for the test app',
permissionFlags: [
{
universalIdentifier: TEST_ROLE_PERMISSION_FLAG_ID,
flag: PermissionFlagType.WORKSPACE,
},
],
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.WORKSPACE],
},
],
},
@@ -75,12 +69,7 @@ describe('Sync application should fail when retargeting existing permissions to
universalIdentifier: STANDARD_ROLE.admin.universalIdentifier,
label: 'Admin',
description: 'Attempts to retarget permission flag to admin role',
permissionFlags: [
{
universalIdentifier: TEST_ROLE_PERMISSION_FLAG_ID,
flag: PermissionFlagType.WORKSPACE,
},
],
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.WORKSPACE],
},
],
},
@@ -58,7 +58,6 @@ export type { PreInstallLogicFunctionApplicationManifest } from './preInstallLog
export type {
ObjectPermissionManifest,
FieldPermissionManifest,
PermissionFlagManifest,
RoleManifest,
} from './roleManifestType';
export type { ServerVariables } from './server-variables.type';
@@ -1,4 +1,3 @@
import { type PermissionFlagType } from '@/constants';
import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType';
export type ObjectPermissionManifest = SyncableEntityOptions & {
@@ -16,10 +15,6 @@ export type FieldPermissionManifest = SyncableEntityOptions & {
canUpdateFieldValue?: boolean;
};
export type PermissionFlagManifest = SyncableEntityOptions & {
flag: PermissionFlagType;
};
export type RoleManifest = SyncableEntityOptions & {
label: string;
description?: string;
@@ -35,5 +30,5 @@ export type RoleManifest = SyncableEntityOptions & {
canBeAssignedToApiKeys?: boolean;
objectPermissions?: ObjectPermissionManifest[];
fieldPermissions?: FieldPermissionManifest[];
permissionFlags?: PermissionFlagManifest[];
permissionFlagUniversalIdentifiers?: string[];
};