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:
+99
@@ -0,0 +1,99 @@
|
||||
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { type FlatRolePermissionFlag } from 'src/engine/metadata-modules/flat-role-permission-flag/types/flat-role-permission-flag.type';
|
||||
import { validatePermissionFlagNotInUseCrossEntity } from 'src/engine/metadata-modules/flat-permission-flag/validators/utils/validate-permission-flag-not-in-use-cross-entity.util';
|
||||
import { PermissionFlagExceptionCode } from 'src/engine/metadata-modules/permission-flag/permission-flag.exception';
|
||||
|
||||
const FLAG_UNIVERSAL_IDENTIFIER = '00000000-0000-0000-0000-000000000001';
|
||||
|
||||
const buildRolePermissionFlag = (
|
||||
overrides: Partial<FlatRolePermissionFlag> = {},
|
||||
): FlatRolePermissionFlag =>
|
||||
({
|
||||
id: '00000000-0000-0000-0000-000000000101',
|
||||
universalIdentifier: '00000000-0000-0000-0000-000000000101',
|
||||
permissionFlagId: FLAG_UNIVERSAL_IDENTIFIER,
|
||||
permissionFlagUniversalIdentifier: FLAG_UNIVERSAL_IDENTIFIER,
|
||||
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 buildRolePermissionFlagMaps = (
|
||||
rolePermissionFlags: FlatRolePermissionFlag[] = [],
|
||||
): FlatEntityMaps<FlatRolePermissionFlag> => {
|
||||
const maps =
|
||||
createEmptyFlatEntityMaps() as unknown as FlatEntityMaps<FlatRolePermissionFlag>;
|
||||
|
||||
for (const rolePermissionFlag of rolePermissionFlags) {
|
||||
maps.byUniversalIdentifier[rolePermissionFlag.universalIdentifier] =
|
||||
rolePermissionFlag;
|
||||
}
|
||||
|
||||
return maps;
|
||||
};
|
||||
|
||||
const buildArgs = (args: {
|
||||
rolePermissionFlags: FlatRolePermissionFlag[];
|
||||
deletedFlagUniversalIdentifiers: string[];
|
||||
}) =>
|
||||
({
|
||||
optimisticUniversalFlatMaps: {
|
||||
flatRolePermissionFlagMaps: buildRolePermissionFlagMaps(
|
||||
args.rolePermissionFlags,
|
||||
),
|
||||
},
|
||||
deletedPermissionFlagActions: args.deletedFlagUniversalIdentifiers.map(
|
||||
(universalIdentifier) => ({
|
||||
type: 'delete',
|
||||
metadataName: 'permissionFlag',
|
||||
universalIdentifier,
|
||||
flatEntity: { universalIdentifier, key: 'MANAGE_INVOICES' },
|
||||
}),
|
||||
),
|
||||
}) as unknown as Parameters<
|
||||
typeof validatePermissionFlagNotInUseCrossEntity
|
||||
>[0];
|
||||
|
||||
describe('validatePermissionFlagNotInUseCrossEntity', () => {
|
||||
it('returns no error when no permission flag is being deleted', () => {
|
||||
const result = validatePermissionFlagNotInUseCrossEntity(
|
||||
buildArgs({
|
||||
rolePermissionFlags: [buildRolePermissionFlag()],
|
||||
deletedFlagUniversalIdentifiers: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.permissionFlag).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects deleting a flag still referenced by a role in the target state', () => {
|
||||
const result = validatePermissionFlagNotInUseCrossEntity(
|
||||
buildArgs({
|
||||
rolePermissionFlags: [buildRolePermissionFlag()],
|
||||
deletedFlagUniversalIdentifiers: [FLAG_UNIVERSAL_IDENTIFIER],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
result.permissionFlag.flatMap((failure) =>
|
||||
failure.errors.map((error) => error.code),
|
||||
),
|
||||
).toEqual([PermissionFlagExceptionCode.PERMISSION_FLAG_IN_USE]);
|
||||
});
|
||||
|
||||
it('allows deleting a flag whose assignments are deleted in the same migration', () => {
|
||||
const result = validatePermissionFlagNotInUseCrossEntity(
|
||||
buildArgs({
|
||||
rolePermissionFlags: [],
|
||||
deletedFlagUniversalIdentifiers: [FLAG_UNIVERSAL_IDENTIFIER],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.permissionFlag).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import { msg, t } from '@lingui/core/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { PermissionFlagExceptionCode } from 'src/engine/metadata-modules/permission-flag/permission-flag.exception';
|
||||
import { type OrchestratorFailureReport } from 'src/engine/workspace-manager/workspace-migration/types/workspace-migration-orchestrator.type';
|
||||
import { type AllUniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/all-universal-flat-entity-maps.type';
|
||||
import { type UniversalDeletePermissionFlagAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/permission-flag/types/workspace-migration-permission-flag-action.type';
|
||||
import { getEmptyFlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/utils/get-flat-entity-validation-error.util';
|
||||
|
||||
export const validatePermissionFlagNotInUseCrossEntity = ({
|
||||
optimisticUniversalFlatMaps,
|
||||
deletedPermissionFlagActions,
|
||||
}: {
|
||||
optimisticUniversalFlatMaps: Pick<
|
||||
AllUniversalFlatEntityMaps,
|
||||
'flatRolePermissionFlagMaps'
|
||||
>;
|
||||
deletedPermissionFlagActions: UniversalDeletePermissionFlagAction[];
|
||||
}): Pick<OrchestratorFailureReport, 'permissionFlag'> => {
|
||||
const validationErrors: Pick<OrchestratorFailureReport, 'permissionFlag'> = {
|
||||
permissionFlag: [],
|
||||
};
|
||||
|
||||
if (deletedPermissionFlagActions.length === 0) {
|
||||
return validationErrors;
|
||||
}
|
||||
|
||||
const survivingRolePermissionFlags = Object.values(
|
||||
optimisticUniversalFlatMaps.flatRolePermissionFlagMaps
|
||||
.byUniversalIdentifier,
|
||||
).filter(isDefined);
|
||||
|
||||
for (const deleteAction of deletedPermissionFlagActions) {
|
||||
const isStillReferenced = survivingRolePermissionFlags.some(
|
||||
(rolePermissionFlag) =>
|
||||
rolePermissionFlag.permissionFlagUniversalIdentifier ===
|
||||
deleteAction.universalIdentifier,
|
||||
);
|
||||
|
||||
if (!isStillReferenced) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const flagKey =
|
||||
deleteAction.flatEntity?.key ?? deleteAction.universalIdentifier;
|
||||
|
||||
const failedValidation = getEmptyFlatEntityValidationError({
|
||||
flatEntityMinimalInformation: {
|
||||
universalIdentifier: deleteAction.universalIdentifier,
|
||||
key: deleteAction.flatEntity?.key,
|
||||
},
|
||||
metadataName: 'permissionFlag',
|
||||
type: 'delete',
|
||||
});
|
||||
|
||||
failedValidation.errors.push({
|
||||
code: PermissionFlagExceptionCode.PERMISSION_FLAG_IN_USE,
|
||||
message: t`Permission flag definition with key ${flagKey} is still assigned to a role`,
|
||||
userFriendlyMessage: msg`Remove this permission from all roles before deleting it`,
|
||||
});
|
||||
|
||||
validationErrors.permissionFlag.push(failedValidation);
|
||||
}
|
||||
|
||||
return validationErrors;
|
||||
};
|
||||
Reference in New Issue
Block a user