Forbid other app role extension (#19783)

# Introduction
Even though this would not possible through API at the moment, from
neither API metadata or manifest ( as manifest `permissionsFlag`
declarations etc are done from within a declared role )
Prevent any app to create permissions entities over another app role
from the validation engine itself

## `isEditable`
We might wanna deprecate this column at some point from the entity it
self as now the grain would rather be `what app owns that role ?`

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Paul Rastoin
2026-04-17 14:00:37 +02:00
committed by GitHub
parent a93f23a150
commit bb464b2ffb
11 changed files with 451 additions and 18 deletions

View File

@@ -49,6 +49,7 @@ export enum PermissionsExceptionCode {
ROLE_MUST_HAVE_AT_LEAST_ONE_TARGET = 'ROLE_MUST_HAVE_AT_LEAST_ONE_TARGET',
ROLE_CANNOT_BE_ASSIGNED_TO_USERS = 'ROLE_CANNOT_BE_ASSIGNED_TO_USERS',
APPLICATION_ROLE_NOT_FOUND = 'APPLICATION_ROLE_NOT_FOUND',
ROLE_BELONGS_TO_ANOTHER_APPLICATION = 'ROLE_BELONGS_TO_ANOTHER_APPLICATION',
}
const getPermissionsExceptionUserFriendlyMessage = (
@@ -143,6 +144,8 @@ const getPermissionsExceptionUserFriendlyMessage = (
return msg`This role cannot be assigned to users.`;
case PermissionsExceptionCode.APPLICATION_ROLE_NOT_FOUND:
return msg`No role assigned to the application.`;
case PermissionsExceptionCode.ROLE_BELONGS_TO_ANOTHER_APPLICATION:
return msg`Cannot modify permissions on a role owned by another application.`;
default:
assertUnreachable(code);
}

View File

@@ -71,6 +71,7 @@ export const permissionGraphqlApiExceptionHandler = (
case PermissionsExceptionCode.COMPOSITE_TYPE_NOT_FOUND:
case PermissionsExceptionCode.USER_WORKSPACE_NOT_FOUND:
case PermissionsExceptionCode.APPLICATION_ROLE_NOT_FOUND:
case PermissionsExceptionCode.ROLE_BELONGS_TO_ANOTHER_APPLICATION:
throw error;
default: {
return assertUnreachable(error.code);

View File

@@ -10,6 +10,7 @@ import { FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspa
import { getEmptyFlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/utils/get-flat-entity-validation-error.util';
import { FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-update-validation-args.type';
import { UniversalFlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-validation-args.type';
import { validateRoleBelongsToCallerApplication } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/utils/validate-role-belongs-to-caller-application.util';
@Injectable()
export class FlatFieldPermissionValidatorService {
@@ -21,6 +22,7 @@ export class FlatFieldPermissionValidatorService {
flatObjectMetadataMaps,
flatFieldMetadataMaps,
},
buildOptions,
}: UniversalFlatEntityValidationArgs<
typeof ALL_METADATA_NAME.fieldPermission
>): FailedFlatEntityValidation<'fieldPermission', 'create'> {
@@ -63,12 +65,21 @@ export class FlatFieldPermissionValidatorService {
message: t`Role not found`,
userFriendlyMessage: msg`Role not found`,
});
} else if (!referencedRole.isEditable) {
validationResult.errors.push({
code: PermissionsExceptionCode.ROLE_NOT_EDITABLE,
message: t`Role is not editable`,
userFriendlyMessage: msg`This role cannot be modified because it is a system role. Only custom roles can be edited.`,
});
} else {
validationResult.errors.push(
...validateRoleBelongsToCallerApplication({
referencedRole,
buildOptions,
}),
);
if (!referencedRole.isEditable) {
validationResult.errors.push({
code: PermissionsExceptionCode.ROLE_NOT_EDITABLE,
message: t`Role is not editable`,
userFriendlyMessage: msg`This role cannot be modified because it is a system role. Only custom roles can be edited.`,
});
}
}
const referencedObjectMetadata = findFlatEntityByUniversalIdentifier({

View File

@@ -10,6 +10,7 @@ import { FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspa
import { getEmptyFlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/utils/get-flat-entity-validation-error.util';
import { FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-update-validation-args.type';
import { UniversalFlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-validation-args.type';
import { validateRoleBelongsToCallerApplication } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/utils/validate-role-belongs-to-caller-application.util';
@Injectable()
export class FlatObjectPermissionValidatorService {
@@ -20,6 +21,7 @@ export class FlatObjectPermissionValidatorService {
flatRoleMaps,
flatObjectMetadataMaps,
},
buildOptions,
}: UniversalFlatEntityValidationArgs<
typeof ALL_METADATA_NAME.objectPermission
>): FailedFlatEntityValidation<'objectPermission', 'create'> {
@@ -60,12 +62,21 @@ export class FlatObjectPermissionValidatorService {
message: t`Role not found`,
userFriendlyMessage: msg`Role not found`,
});
} else if (!referencedRole.isEditable) {
validationResult.errors.push({
code: PermissionsExceptionCode.ROLE_NOT_EDITABLE,
message: t`Role is not editable`,
userFriendlyMessage: msg`This role cannot be modified because it is a system role. Only custom roles can be edited.`,
});
} else {
validationResult.errors.push(
...validateRoleBelongsToCallerApplication({
referencedRole,
buildOptions,
}),
);
if (!referencedRole.isEditable) {
validationResult.errors.push({
code: PermissionsExceptionCode.ROLE_NOT_EDITABLE,
message: t`Role is not editable`,
userFriendlyMessage: msg`This role cannot be modified because it is a system role. Only custom roles can be edited.`,
});
}
}
const referencedObjectMetadata = findFlatEntityByUniversalIdentifier({

View File

@@ -11,6 +11,7 @@ import { FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspa
import { getEmptyFlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/utils/get-flat-entity-validation-error.util';
import { FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-update-validation-args.type';
import { UniversalFlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-validation-args.type';
import { validateRoleBelongsToCallerApplication } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/utils/validate-role-belongs-to-caller-application.util';
@Injectable()
export class FlatPermissionFlagValidatorService {
@@ -20,6 +21,7 @@ export class FlatPermissionFlagValidatorService {
flatPermissionFlagMaps: optimisticFlatPermissionFlagMaps,
flatRoleMaps,
},
buildOptions,
}: UniversalFlatEntityValidationArgs<
typeof ALL_METADATA_NAME.permissionFlag
>): FailedFlatEntityValidation<'permissionFlag', 'create'> {
@@ -57,12 +59,21 @@ export class FlatPermissionFlagValidatorService {
message: t`Role not found`,
userFriendlyMessage: msg`Role not found`,
});
} else if (!referencedRole.isEditable) {
validationResult.errors.push({
code: PermissionsExceptionCode.ROLE_NOT_EDITABLE,
message: t`Role is not editable`,
userFriendlyMessage: msg`This role cannot be modified because it is a system role. Only custom roles can be edited.`,
});
} else {
validationResult.errors.push(
...validateRoleBelongsToCallerApplication({
referencedRole,
buildOptions,
}),
);
if (!referencedRole.isEditable) {
validationResult.errors.push({
code: PermissionsExceptionCode.ROLE_NOT_EDITABLE,
message: t`Role is not editable`,
userFriendlyMessage: msg`This role cannot be modified because it is a system role. Only custom roles can be edited.`,
});
}
}
const isValidFlag =

View File

@@ -0,0 +1,29 @@
import { msg, t } from '@lingui/core/macro';
import { PermissionsExceptionCode } from 'src/engine/metadata-modules/permissions/permissions.exception';
import { type UniversalFlatRole } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-role.type';
import { type FlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type';
import { type WorkspaceMigrationBuilderOptions } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-builder-options.type';
export const validateRoleBelongsToCallerApplication = ({
referencedRole,
buildOptions,
}: {
referencedRole: UniversalFlatRole;
buildOptions: WorkspaceMigrationBuilderOptions;
}): FlatEntityValidationError[] => {
if (
referencedRole.applicationUniversalIdentifier !==
buildOptions.applicationUniversalIdentifier
) {
return [
{
code: PermissionsExceptionCode.ROLE_BELONGS_TO_ANOTHER_APPLICATION,
message: t`Cannot modify permissions on a role owned by another application`,
userFriendlyMessage: msg`Cannot modify permissions on a role owned by another application.`,
},
];
}
return [];
};

View File

@@ -0,0 +1,193 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Sync application should fail when creating permissions on a standard role from another app should fail when adding a field permission under the standard admin role 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"fieldPermission": [
{
"errors": [
{
"code": "ROLE_BELONGS_TO_ANOTHER_APPLICATION",
"message": "Cannot modify permissions on a role owned by another application",
"userFriendlyMessage": "Cannot modify permissions on a role owned by another application.",
},
{
"code": "ROLE_NOT_EDITABLE",
"message": "Role is not editable",
"userFriendlyMessage": "This role cannot be modified because it is a system role. Only custom roles can be edited.",
},
{
"code": "OBJECT_METADATA_NOT_FOUND_PERMISSIONS",
"message": "Object metadata not found",
"userFriendlyMessage": "Object metadata not found",
},
{
"code": "FIELD_METADATA_NOT_FOUND",
"message": "Field metadata not found",
"userFriendlyMessage": "Field metadata not found",
},
],
"flatEntityMinimalInformation": {
"fieldMetadataUniversalIdentifier": Any<String>,
"objectMetadataUniversalIdentifier": Any<String>,
"roleUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "fieldPermission",
"status": "fail",
"type": "create",
},
],
"role": [
{
"errors": [
{
"code": "ENTITY_ALREADY_EXISTS",
"message": "Cannot create role: universalIdentifier "20202020-02c2-43f2-b94d-cab1f2b532eb" already exists in role maps from application "20202020-64aa-4b6f-b003-9c74b97cee20"",
},
],
"flatEntityMinimalInformation": {
"universalIdentifier": Any<String>,
},
"metadataName": "role",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 role, 1 fieldPermission",
"summary": {
"fieldPermission": 1,
"role": 1,
"totalErrors": 2,
},
"userFriendlyMessage": "Metadata validation failed",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should fail when creating permissions on a standard role from another app should fail when adding a permission flag under the standard admin role 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"permissionFlag": [
{
"errors": [
{
"code": "ROLE_BELONGS_TO_ANOTHER_APPLICATION",
"message": "Cannot modify permissions on a role owned by another application",
"userFriendlyMessage": "Cannot modify permissions on a role owned by another application.",
},
{
"code": "ROLE_NOT_EDITABLE",
"message": "Role is not editable",
"userFriendlyMessage": "This role cannot be modified because it is a system role. Only custom roles can be edited.",
},
],
"flatEntityMinimalInformation": {
"roleUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "permissionFlag",
"status": "fail",
"type": "create",
},
],
"role": [
{
"errors": [
{
"code": "ENTITY_ALREADY_EXISTS",
"message": "Cannot create role: universalIdentifier "20202020-02c2-43f2-b94d-cab1f2b532eb" already exists in role maps from application "20202020-64aa-4b6f-b003-9c74b97cee20"",
},
],
"flatEntityMinimalInformation": {
"universalIdentifier": Any<String>,
},
"metadataName": "role",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 role, 1 permissionFlag",
"summary": {
"permissionFlag": 1,
"role": 1,
"totalErrors": 2,
},
"userFriendlyMessage": "Metadata validation failed",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;
exports[`Sync application should fail when creating permissions on a standard role from another app should fail when adding an object permission under the standard admin role 1`] = `
{
"extensions": {
"code": "METADATA_VALIDATION_FAILED",
"errors": {
"objectPermission": [
{
"errors": [
{
"code": "ROLE_BELONGS_TO_ANOTHER_APPLICATION",
"message": "Cannot modify permissions on a role owned by another application",
"userFriendlyMessage": "Cannot modify permissions on a role owned by another application.",
},
{
"code": "ROLE_NOT_EDITABLE",
"message": "Role is not editable",
"userFriendlyMessage": "This role cannot be modified because it is a system role. Only custom roles can be edited.",
},
{
"code": "OBJECT_METADATA_NOT_FOUND_PERMISSIONS",
"message": "Object metadata not found",
"userFriendlyMessage": "Object metadata not found",
},
],
"flatEntityMinimalInformation": {
"objectMetadataUniversalIdentifier": Any<String>,
"roleUniversalIdentifier": Any<String>,
"universalIdentifier": Any<String>,
},
"metadataName": "objectPermission",
"status": "fail",
"type": "create",
},
],
"role": [
{
"errors": [
{
"code": "ENTITY_ALREADY_EXISTS",
"message": "Cannot create role: universalIdentifier "20202020-02c2-43f2-b94d-cab1f2b532eb" already exists in role maps from application "20202020-64aa-4b6f-b003-9c74b97cee20"",
},
],
"flatEntityMinimalInformation": {
"universalIdentifier": Any<String>,
},
"metadataName": "role",
"status": "fail",
"type": "create",
},
],
},
"message": "Validation failed for 1 role, 1 objectPermission",
"summary": {
"objectPermission": 1,
"role": 1,
"totalErrors": 2,
},
"userFriendlyMessage": "Metadata validation failed",
},
"message": "Validation errors occurred while syncing application manifest metadata",
"name": "GraphQLError",
}
`;

View File

@@ -0,0 +1,159 @@
import { PermissionFlagType } 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';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { STANDARD_ROLE } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-role.constant';
const TEST_APP_ID = 'a1b2c3d4-0010-4000-a000-000000000010';
const TEST_ROLE_ID = 'a1b2c3d4-0010-4000-a000-000000000011';
const TEST_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';
const FAKE_FIELD_ID = 'a1b2c3d4-0010-4000-a000-000000000021';
describe('Sync application should fail when creating permissions on a standard role from another app', () => {
beforeAll(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Cross App Permission App',
description: 'App for testing cross-app permission flag on standard role',
sourcePath: 'test-cross-app-permission',
});
}, 60000);
afterAll(async () => {
await globalThis.testDataSource.query(
`DELETE FROM core."role" WHERE "universalIdentifier" = $1`,
[TEST_ROLE_ID],
);
await globalThis.testDataSource.query(
`DELETE FROM core."file" WHERE "applicationId" IN (
SELECT id FROM core."application" WHERE "universalIdentifier" = $1
)`,
[TEST_APP_ID],
);
await globalThis.testDataSource.query(
`DELETE FROM core."application"
WHERE "universalIdentifier" = $1`,
[TEST_APP_ID],
);
await globalThis.testDataSource.query(
`DELETE FROM core."applicationRegistration"
WHERE "universalIdentifier" = $1`,
[TEST_APP_ID],
);
});
it('should fail when adding a permission flag under the standard admin role', async () => {
const manifest = buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'App Default Role',
description: 'Default role for the test app',
},
{
universalIdentifier: STANDARD_ROLE.admin.universalIdentifier,
label: 'Stolen Admin Role',
description:
'Attempts to add permissions to the standard admin role',
permissionFlags: [
{
universalIdentifier: TEST_PERMISSION_FLAG_ID,
flag: PermissionFlagType.WORKSPACE,
},
],
},
],
},
});
const { errors } = await syncApplication({
manifest,
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
}, 60000);
it('should fail when adding an object permission under the standard admin role', async () => {
const manifest = buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'App Default Role',
description: 'Default role for the test app',
},
{
universalIdentifier: STANDARD_ROLE.admin.universalIdentifier,
label: 'Stolen Admin Role',
description:
'Attempts to add object permission to the standard admin role',
objectPermissions: [
{
universalIdentifier: TEST_OBJECT_PERMISSION_ID,
objectUniversalIdentifier: FAKE_OBJECT_ID,
},
],
},
],
},
});
const { errors } = await syncApplication({
manifest,
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
}, 60000);
it('should fail when adding a field permission under the standard admin role', async () => {
const manifest = buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'App Default Role',
description: 'Default role for the test app',
},
{
universalIdentifier: STANDARD_ROLE.admin.universalIdentifier,
label: 'Stolen Admin Role',
description:
'Attempts to add field permission to the standard admin role',
fieldPermissions: [
{
universalIdentifier: TEST_FIELD_PERMISSION_ID,
objectUniversalIdentifier: FAKE_OBJECT_ID,
fieldUniversalIdentifier: FAKE_FIELD_ID,
},
],
},
],
},
});
const { errors } = await syncApplication({
manifest,
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
}, 60000);
});

View File

@@ -68,6 +68,11 @@ exports[`Field permission upsert should fail when role is not editable (system r
"fieldPermission": [
{
"errors": [
{
"code": "ROLE_BELONGS_TO_ANOTHER_APPLICATION",
"message": "Cannot modify permissions on a role owned by another application",
"userFriendlyMessage": "Cannot modify permissions on a role owned by another application.",
},
{
"code": "ROLE_NOT_EDITABLE",
"message": "Role is not editable",

View File

@@ -44,6 +44,11 @@ exports[`Object permission upsert should fail when role is not editable (system
"objectPermission": [
{
"errors": [
{
"code": "ROLE_BELONGS_TO_ANOTHER_APPLICATION",
"message": "Cannot modify permissions on a role owned by another application",
"userFriendlyMessage": "Cannot modify permissions on a role owned by another application.",
},
{
"code": "ROLE_NOT_EDITABLE",
"message": "Role is not editable",

View File

@@ -22,6 +22,11 @@ exports[`Permission flag upsert should fail when role is not editable (system ro
"permissionFlag": [
{
"errors": [
{
"code": "ROLE_BELONGS_TO_ANOTHER_APPLICATION",
"message": "Cannot modify permissions on a role owned by another application",
"userFriendlyMessage": "Cannot modify permissions on a role owned by another application.",
},
{
"code": "ROLE_NOT_EDITABLE",
"message": "Role is not editable",