fix(server): allow app-defined permission flags to be referenced by a role in the same sync (#21742)

## Context

When an application defined custom permission flags and a role
referencing them in the same sync, installation failed, first at
validation (Permission flag not found) and then at execution (Migration
action 'create' for 'rolePermissionFlag' failed).

Root cause: both the migration builder order and the runner execution
order processed rolePermissionFlag before permissionFlag, so the role's
flag assignments were validated/inserted before the flags they reference
existed.

## Changes

- Builder order: run the permissionFlag builder before
rolePermissionFlag so newly created flags are visible in the optimistic
maps when assignments are validated.
- Execution order: order the permission-flag actions so definitions are
created before assignments, and assignments deleted before definitions,
keeping the FK satisfied in both directions.
- In-use check: move the "flag still assigned to a role" guard out of
the per-entity deletion validator (order-dependent, false-positived when
a flag and its assignments were deleted together) into a new
order-independent validatePermissionFlagNotInUseCrossEntity (aligned
with existing validateObjectMetadataCrossEntity,
validateViewFieldLabelIdentifierCrossEntity, ...), run after all
builders against the migration's final state.

This fixes both the create path (define flag + reference it in one sync)
and the teardown path (delete flag + its assignments in one sync).

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21742?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Weiko
2026-06-17 17:44:01 +02:00
committed by GitHub
parent d4e0b78d20
commit 607d9ee6e5
7 changed files with 180 additions and 83 deletions
@@ -228,14 +228,14 @@ export class WorkspaceMigrationBuildOrchestratorService {
ALL_METADATA_NAME.fieldPermission,
workspaceMigrationFieldPermissionActionsBuilderService,
),
createEntityActionsBuilderTask(
ALL_METADATA_NAME.rolePermissionFlag,
workspaceMigrationRolePermissionFlagActionsBuilderService,
),
createEntityActionsBuilderTask(
ALL_METADATA_NAME.permissionFlag,
workspaceMigrationPermissionFlagActionsBuilderService,
),
createEntityActionsBuilderTask(
ALL_METADATA_NAME.rolePermissionFlag,
workspaceMigrationRolePermissionFlagActionsBuilderService,
),
createEntityActionsBuilderTask(
ALL_METADATA_NAME.roleTarget,
workspaceMigrationRoleTargetActionsBuilderService,
@@ -71,16 +71,13 @@ export const computeOrderedMigrationActions = (
...aggregatedOrchestratorActionsReport.fieldPermission.update,
///
// Permission flags
// Permission flag definitions and their role assignments.
...aggregatedOrchestratorActionsReport.rolePermissionFlag.delete,
...aggregatedOrchestratorActionsReport.rolePermissionFlag.create,
...aggregatedOrchestratorActionsReport.rolePermissionFlag.update,
///
// Permission flag definitions
...aggregatedOrchestratorActionsReport.permissionFlag.delete,
...aggregatedOrchestratorActionsReport.permissionFlag.create,
...aggregatedOrchestratorActionsReport.rolePermissionFlag.create,
...aggregatedOrchestratorActionsReport.permissionFlag.update,
...aggregatedOrchestratorActionsReport.rolePermissionFlag.update,
///
// Agents
@@ -1,4 +1,5 @@
import { validateObjectMetadataCrossEntity } from 'src/engine/metadata-modules/flat-object-metadata/validators/utils/validate-object-metadata-cross-entity.util';
import { validatePermissionFlagNotInUseCrossEntity } from 'src/engine/metadata-modules/flat-permission-flag/validators/utils/validate-permission-flag-not-in-use-cross-entity.util';
import { validateViewFieldLabelIdentifierCrossEntity } from 'src/engine/metadata-modules/flat-view-field/validators/utils/validate-view-field-label-identifier-cross-entity.util';
import {
type OrchestratorActionsReport,
@@ -32,8 +33,15 @@ export const crossEntityTransversalValidation = ({
preDeletionFlatViewFieldMaps,
});
const { permissionFlag } = validatePermissionFlagNotInUseCrossEntity({
optimisticUniversalFlatMaps,
deletedPermissionFlagActions:
orchestratorActionsReport.permissionFlag.delete,
});
crossEntityFailureReport.objectMetadata.push(...objectMetadata);
crossEntityFailureReport.viewField.push(...viewField);
crossEntityFailureReport.permissionFlag.push(...permissionFlag);
validateUniversalIdentifierCrossEntityUniquenessThroughReportMutation({
optimisticUniversalFlatMaps,
@@ -1,6 +1,5 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { PermissionFlagType } from 'twenty-shared/constants';
import { ALL_METADATA_NAME } from 'twenty-shared/metadata';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
@@ -38,23 +37,6 @@ const buildEmptyRolePermissionFlagMaps =
(): FlatEntityMaps<FlatRolePermissionFlag> =>
createEmptyFlatEntityMaps() as unknown as FlatEntityMaps<FlatRolePermissionFlag>;
const buildFlatRolePermissionFlag = (
overrides: Partial<FlatRolePermissionFlag> = {},
): FlatRolePermissionFlag =>
({
id: '00000000-0000-0000-0000-000000000101',
universalIdentifier: '00000000-0000-0000-0000-000000000101',
permissionFlagId: '00000000-0000-0000-0000-000000000001',
permissionFlagUniversalIdentifier: '00000000-0000-0000-0000-000000000001',
roleUniversalIdentifier: '00000000-0000-0000-0000-000000000201',
workspaceId: 'workspace-id',
applicationId: '00000000-0000-0000-0000-000000000aaa',
applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000aaa',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
}) as unknown as FlatRolePermissionFlag;
const buildArgs = (
flatEntityToValidate: FlatPermissionFlag,
optimisticMaps: FlatEntityMaps<FlatPermissionFlag> = buildEmptyMaps(),
@@ -335,43 +317,6 @@ describe('FlatPermissionFlagValidatorService', () => {
PermissionFlagExceptionCode.PERMISSION_FLAG_IS_STANDARD,
]);
});
it('rejects deleting a definition while roles still grant its key', () => {
const existing = buildFlatDefinition({
key: PermissionFlagType.WORKSPACE,
});
const optimisticMaps = buildEmptyMaps();
optimisticMaps.byUniversalIdentifier[existing.universalIdentifier] =
existing;
const rolePermissionFlagMaps = buildEmptyRolePermissionFlagMaps();
const rolePermissionFlag = buildFlatRolePermissionFlag({
permissionFlagId: existing.id,
permissionFlagUniversalIdentifier: existing.universalIdentifier,
});
rolePermissionFlagMaps.byUniversalIdentifier[
rolePermissionFlag.universalIdentifier
] = rolePermissionFlag;
const result = service.validateFlatPermissionFlagDeletion({
flatEntityToValidate: existing,
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
flatPermissionFlagMaps: optimisticMaps,
flatRolePermissionFlagMaps: rolePermissionFlagMaps,
},
buildOptions: {
isSystemBuild: false,
applicationUniversalIdentifier:
'00000000-0000-0000-0000-000000000aaa',
},
} as unknown as Parameters<
FlatPermissionFlagValidatorService['validateFlatPermissionFlagDeletion']
>[0]);
expect(result.errors.map((error) => error.code)).toEqual([
PermissionFlagExceptionCode.PERMISSION_FLAG_IN_USE,
]);
});
});
// ALL_METADATA_NAME is referenced as a sanity check the metadata name exists
@@ -166,7 +166,6 @@ export class FlatPermissionFlagValidatorService {
flatEntityToValidate: { universalIdentifier },
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
flatPermissionFlagMaps: optimisticFlatPermissionFlagMaps,
flatRolePermissionFlagMaps: optimisticFlatRolePermissionFlagMaps,
},
buildOptions,
}: UniversalFlatEntityValidationArgs<
@@ -209,23 +208,6 @@ export class FlatPermissionFlagValidatorService {
});
}
const isPermissionFlagInUse = Object.values(
optimisticFlatRolePermissionFlagMaps.byUniversalIdentifier,
).some(
(rolePermissionFlag) =>
isDefined(rolePermissionFlag) &&
rolePermissionFlag.permissionFlagUniversalIdentifier ===
existing.universalIdentifier,
);
if (isPermissionFlagInUse) {
validationResult.errors.push({
code: PermissionFlagExceptionCode.PERMISSION_FLAG_IN_USE,
message: t`Permission flag definition with key ${existing.key} is still assigned to a role`,
userFriendlyMessage: msg`Remove this permission from all roles before deleting it`,
});
}
return validationResult;
}
}