Files
twenty/packages/twenty-server/src/engine/twenty-orm/repository/permissions.utils.ts
T
Marie ee19ee5119 [permissions] QA fixes (#13698)
In this PR

- Introduction of readableFields and updatableFields in
objectMetadataItem selector to ease filtering from a developer
experience perspective ( + to help developers think to do it). In
discussion @lucasbordeau @charlesBochet
- Remove non-updatable field from CSV import process (@etiennejouan)
- QA fix / Non-readable fields should not show on show page
- QA fix / It should not be offered to create a kanban view on a
non-readable field
- QA fix / It should not be offered to create view groups on a
non-readable field
- QA fix / Rating field should have a readonly mode

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2025-08-07 18:21:59 +02:00

374 lines
10 KiB
TypeScript

import { isNonEmptyString } from '@sniptt/guards';
import isEmpty from 'lodash.isempty';
import {
ObjectsPermissionsDeprecated,
RestrictedFieldsPermissions,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { QueryExpressionMap } from 'typeorm/query-builder/QueryExpressionMap';
import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper';
import { InternalServerError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
PermissionsException,
PermissionsExceptionCode,
PermissionsExceptionMessage,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { getColumnNameToFieldMetadataIdMap } from 'src/engine/twenty-orm/utils/get-column-name-to-field-metadata-id.util';
const getTargetEntityAndOperationType = (expressionMap: QueryExpressionMap) => {
const mainEntity = expressionMap.aliases[0].metadata.name;
const operationType = expressionMap.queryType;
return {
mainEntity,
operationType,
};
};
export type OperationType =
| 'select'
| 'insert'
| 'update'
| 'delete'
| 'restore'
| 'soft-delete';
type ValidateOperationIsPermittedOrThrowArgs = {
entityName: string;
operationType: OperationType;
objectsPermissions: ObjectsPermissionsDeprecated;
objectMetadataMaps: ObjectMetadataMaps;
selectedColumns: string[] | '*';
isFieldPermissionsEnabled?: boolean;
allFieldsSelected: boolean;
updatedColumns: string[];
};
export const validateOperationIsPermittedOrThrow = ({
entityName,
operationType,
objectsPermissions,
objectMetadataMaps,
selectedColumns,
isFieldPermissionsEnabled,
allFieldsSelected,
updatedColumns,
}: ValidateOperationIsPermittedOrThrowArgs) => {
const objectMetadataIdForEntity =
objectMetadataMaps.idByNameSingular[entityName];
if (!isNonEmptyString(objectMetadataIdForEntity)) {
throw new PermissionsException(
PermissionsExceptionMessage.PERMISSION_DENIED,
PermissionsExceptionCode.PERMISSION_DENIED,
);
}
const objectMetadata = objectMetadataMaps.byId[objectMetadataIdForEntity];
if (!isDefined(objectMetadata)) {
throw new PermissionsException(
PermissionsExceptionMessage.PERMISSION_DENIED,
PermissionsExceptionCode.PERMISSION_DENIED,
);
}
const objectMetadataIsSystem = objectMetadata.isSystem === true;
if (objectMetadataIsSystem) {
return;
}
const columnNameToFieldMetadataIdMap = isFieldPermissionsEnabled
? getColumnNameToFieldMetadataIdMap(objectMetadata)
: {};
const permissionsForEntity = objectsPermissions[objectMetadataIdForEntity];
switch (operationType) {
case 'select':
if (!permissionsForEntity?.canRead) {
throw new PermissionsException(
PermissionsExceptionMessage.PERMISSION_DENIED,
PermissionsExceptionCode.PERMISSION_DENIED,
);
}
if (isFieldPermissionsEnabled) {
validateReadFieldPermissionOrThrow({
restrictedFields: permissionsForEntity.restrictedFields,
selectedColumns,
columnNameToFieldMetadataIdMap,
allFieldsSelected,
});
}
break;
case 'insert':
case 'update':
if (!permissionsForEntity?.canUpdate) {
throw new PermissionsException(
PermissionsExceptionMessage.PERMISSION_DENIED,
PermissionsExceptionCode.PERMISSION_DENIED,
);
}
if (isFieldPermissionsEnabled) {
validateReadFieldPermissionOrThrow({
restrictedFields: permissionsForEntity.restrictedFields,
selectedColumns,
columnNameToFieldMetadataIdMap,
});
}
if (isFieldPermissionsEnabled && updatedColumns.length > 0) {
validateUpdateFieldPermissionOrThrow({
restrictedFields: permissionsForEntity.restrictedFields,
updatedColumns,
columnNameToFieldMetadataIdMap,
});
}
break;
case 'delete':
if (!permissionsForEntity?.canDestroy) {
throw new PermissionsException(
PermissionsExceptionMessage.PERMISSION_DENIED,
PermissionsExceptionCode.PERMISSION_DENIED,
);
}
if (isFieldPermissionsEnabled) {
validateReadFieldPermissionOrThrow({
restrictedFields: permissionsForEntity.restrictedFields,
selectedColumns,
columnNameToFieldMetadataIdMap,
});
}
break;
case 'restore':
case 'soft-delete':
if (!permissionsForEntity?.canSoftDelete) {
throw new PermissionsException(
PermissionsExceptionMessage.PERMISSION_DENIED,
PermissionsExceptionCode.PERMISSION_DENIED,
);
}
if (isFieldPermissionsEnabled) {
validateReadFieldPermissionOrThrow({
restrictedFields: permissionsForEntity.restrictedFields,
selectedColumns,
columnNameToFieldMetadataIdMap,
});
}
break;
default:
throw new PermissionsException(
PermissionsExceptionMessage.UNKNOWN_OPERATION_NAME,
PermissionsExceptionCode.UNKNOWN_OPERATION_NAME,
);
}
if (isEmpty(permissionsForEntity.restrictedFields)) {
return;
}
};
type ValidateQueryIsPermittedOrThrowArgs = {
expressionMap: QueryExpressionMap;
objectsPermissions: ObjectsPermissionsDeprecated;
objectMetadataMaps: ObjectMetadataMaps;
shouldBypassPermissionChecks: boolean;
isFieldPermissionsEnabled?: boolean;
};
export const validateQueryIsPermittedOrThrow = ({
expressionMap,
objectsPermissions,
objectMetadataMaps,
shouldBypassPermissionChecks,
isFieldPermissionsEnabled,
}: ValidateQueryIsPermittedOrThrowArgs) => {
if (shouldBypassPermissionChecks) {
return;
}
const { mainEntity, operationType } =
getTargetEntityAndOperationType(expressionMap);
const allFieldsSelected = expressionMap.selects.some(
(select) => select.selection === mainEntity,
);
let selectedColumns: string[] | '*' = [];
let updatedColumns: string[] = [];
if (isFieldPermissionsEnabled) {
selectedColumns = getSelectedColumnsFromExpressionMap({
operationType,
expressionMap,
allFieldsSelected,
});
if (operationType !== 'select') {
const valuesSet = expressionMap.valuesSet;
if (Array.isArray(valuesSet)) {
updatedColumns = valuesSet.reduce((acc, value) => {
const keys = Object.keys(value);
keys.forEach((key) => {
if (!acc.includes(key)) {
acc.push(key);
}
});
return acc;
}, []);
} else {
updatedColumns = Object.keys(valuesSet ?? {});
}
}
}
validateOperationIsPermittedOrThrow({
entityName: mainEntity,
operationType: operationType as OperationType,
objectsPermissions,
objectMetadataMaps,
selectedColumns,
isFieldPermissionsEnabled,
allFieldsSelected,
updatedColumns,
});
};
const validateReadFieldPermissionOrThrow = ({
restrictedFields,
selectedColumns,
columnNameToFieldMetadataIdMap,
allFieldsSelected,
}: {
restrictedFields: RestrictedFieldsPermissions;
selectedColumns: string[] | '*';
columnNameToFieldMetadataIdMap: Record<string, string>;
allFieldsSelected?: boolean;
}) => {
const noReadRestrictions =
isEmpty(restrictedFields) ||
Object.values(restrictedFields).every((field) => field.canRead !== false);
if (noReadRestrictions) {
return;
}
if (allFieldsSelected || selectedColumns === '*') {
throw new PermissionsException(
PermissionsExceptionMessage.PERMISSION_DENIED,
PermissionsExceptionCode.PERMISSION_DENIED,
);
}
for (const column of selectedColumns) {
const fieldMetadataId = columnNameToFieldMetadataIdMap[column];
if (!fieldMetadataId) {
throw new InternalServerError(
`Field metadata id not found for column name ${column}`,
);
}
if (restrictedFields[fieldMetadataId]?.canRead === false) {
throw new PermissionsException(
PermissionsExceptionMessage.PERMISSION_DENIED,
PermissionsExceptionCode.PERMISSION_DENIED,
);
}
}
};
const validateUpdateFieldPermissionOrThrow = ({
restrictedFields,
updatedColumns,
columnNameToFieldMetadataIdMap,
}: {
restrictedFields: RestrictedFieldsPermissions;
updatedColumns: string[];
columnNameToFieldMetadataIdMap: Record<string, string>;
}) => {
if (isEmpty(restrictedFields)) {
return;
}
for (const column of updatedColumns) {
const fieldMetadataId = columnNameToFieldMetadataIdMap[column];
if (!fieldMetadataId) {
throw new InternalServerError(
`Field metadata id not found for column name ${column}`,
);
}
if (restrictedFields[fieldMetadataId]?.canUpdate === false) {
throw new PermissionsException(
PermissionsExceptionMessage.PERMISSION_DENIED,
PermissionsExceptionCode.PERMISSION_DENIED,
);
}
}
};
const getSelectedColumnsFromExpressionMap = ({
operationType,
expressionMap,
allFieldsSelected,
}: {
operationType: string;
expressionMap: QueryExpressionMap;
allFieldsSelected: boolean;
}) => {
let selectedColumns: string[] | '*' = [];
if (
['update', 'insert', 'delete', 'soft-delete', 'restore'].includes(
operationType,
)
) {
if (!isDefined(expressionMap.returning)) {
throw new InternalServerError(
'Returning columns are not set for update query',
);
}
selectedColumns =
expressionMap.returning === '*' ? '*' : [expressionMap.returning].flat();
} else if (!allFieldsSelected) {
selectedColumns = getSelectedColumnsFromExpressionMapSelects(
expressionMap.selects,
);
}
return selectedColumns;
};
const getSelectedColumnsFromExpressionMapSelects = (
selects: { selection: string }[],
) => {
return selects
?.map((select) => {
const columnsFromAggregateExpression =
ProcessAggregateHelper.extractColumnNamesFromAggregateExpression(
select.selection,
);
if (columnsFromAggregateExpression) {
return columnsFromAggregateExpression;
}
const parts = select.selection.split('.');
return parts[parts.length - 1];
})
.flat();
};