bc28e1557c
## Summary Introduces a dedicated **metadata** mutation to update **standard (non-custom)** workspace member settings, moves profile-related UI to use it, and aligns **workspace member** record permissions with the rest of the CRM so users cannot escalate visibility via RLS by editing their own member record. ## Product behaviour ### Profile and appearance (standard fields) - Users can still update **their own** standard workspace member fields that the product exposes in **Settings / Profile** (e.g. name, locale, color scheme, avatar flow) via the new **`updateWorkspaceMemberSettings`** mutation. - The mutation returns a **boolean**; the app **merges** the updated fields into local state so the UI stays in sync without refetching the full workspace member record. - **Locale** changes also keep **`userWorkspace`** in sync when a locale is present in the payload (including from the workspace `updateOne` path when applicable). ### Custom fields on workspace members - The dedicated metadata mutation **rejects** any **custom** workspace member field (and unknown keys). Those updates must go through the normal **object** `updateOne` pipeline, which is subject to **object- and field-level** permissions like other records. But since we don't have object- and field-level permission configuration for system objects yet, this permission is derived from Workspace member settings permission. - **Workspace member** is no longer exempt from ORM permission validation for updates merely because it is a **system** object. Users who **do not** have workspace member access (e.g. no **Workspace members** settings permission and no equivalent broad settings access on the role) **cannot** use `updateOne` on `workspaceMember` to change **custom** (or other) fields on their own row—even though that row is used for RLS predicates. - This closes a path where someone could widen what they can see by writing to fields that drive row-level rules. ### Who can change another member - Updating **another** user’s workspace member still requires **Workspace members** (or equivalent) settings permission, consistent with admin tooling.
527 lines
15 KiB
TypeScript
527 lines
15 KiB
TypeScript
import { isNonEmptyString } from '@sniptt/guards';
|
|
import isEmpty from 'lodash.isempty';
|
|
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
|
|
import {
|
|
type ObjectsPermissions,
|
|
type RestrictedFieldsPermissions,
|
|
} from 'twenty-shared/types';
|
|
import { isDefined } from 'twenty-shared/utils';
|
|
import { type 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 { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
|
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
|
|
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
|
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
|
import {
|
|
PermissionsException,
|
|
PermissionsExceptionCode,
|
|
PermissionsExceptionMessage,
|
|
} from 'src/engine/metadata-modules/permissions/permissions.exception';
|
|
import { getColumnNameToFieldMetadataIdMap } from 'src/engine/twenty-orm/utils/get-column-name-to-field-metadata-id.util';
|
|
|
|
const WORKSPACE_MEMBER_OBJECT_UNIVERSAL_IDENTIFIER =
|
|
STANDARD_OBJECTS.workspaceMember.universalIdentifier;
|
|
|
|
const getTargetEntityAndOperationType = (
|
|
expressionMap: QueryExpressionMap,
|
|
):
|
|
| {
|
|
isSubQuery: true;
|
|
mainEntity?: undefined;
|
|
operationType?: undefined;
|
|
}
|
|
| {
|
|
isSubQuery?: undefined;
|
|
mainEntity: string;
|
|
operationType:
|
|
| 'select'
|
|
| 'insert'
|
|
| 'update'
|
|
| 'delete'
|
|
| 'restore'
|
|
| 'soft-delete'
|
|
| 'relation';
|
|
} => {
|
|
const isSubQuery = expressionMap.aliases[0].subQuery;
|
|
|
|
if (isSubQuery) {
|
|
return {
|
|
isSubQuery: true, // will bypass permission checks because subQuery permissions will be evaluated when it is executed. This is valid for groupBy with records usecase. If your usecase is different, make sure permission checks are run.
|
|
};
|
|
}
|
|
|
|
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: ObjectsPermissions;
|
|
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
|
|
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
|
objectIdByNameSingular: Record<string, string>;
|
|
selectedColumns: string[] | '*';
|
|
allFieldsSelected: boolean;
|
|
updatedColumns: string[];
|
|
};
|
|
|
|
export const validateOperationIsPermittedOrThrow = ({
|
|
entityName,
|
|
operationType,
|
|
objectsPermissions,
|
|
flatObjectMetadataMaps,
|
|
flatFieldMetadataMaps,
|
|
objectIdByNameSingular,
|
|
selectedColumns,
|
|
allFieldsSelected,
|
|
updatedColumns,
|
|
}: ValidateOperationIsPermittedOrThrowArgs) => {
|
|
const objectMetadataIdForEntity = objectIdByNameSingular[entityName];
|
|
|
|
if (!isNonEmptyString(objectMetadataIdForEntity)) {
|
|
throw new PermissionsException(
|
|
PermissionsExceptionMessage.PERMISSION_DENIED,
|
|
PermissionsExceptionCode.PERMISSION_DENIED,
|
|
);
|
|
}
|
|
|
|
const objectMetadata = findFlatEntityByIdInFlatEntityMaps({
|
|
flatEntityId: objectMetadataIdForEntity,
|
|
flatEntityMaps: flatObjectMetadataMaps,
|
|
});
|
|
|
|
if (!isDefined(objectMetadata)) {
|
|
throw new PermissionsException(
|
|
PermissionsExceptionMessage.PERMISSION_DENIED,
|
|
PermissionsExceptionCode.PERMISSION_DENIED,
|
|
);
|
|
}
|
|
|
|
const objectMetadataIsSystem = objectMetadata.isSystem === true;
|
|
const isWorkspaceMemberObject =
|
|
objectMetadata.universalIdentifier ===
|
|
WORKSPACE_MEMBER_OBJECT_UNIVERSAL_IDENTIFIER;
|
|
|
|
if (objectMetadataIsSystem && !isWorkspaceMemberObject) {
|
|
return;
|
|
}
|
|
|
|
const columnNameToFieldMetadataIdMap = getColumnNameToFieldMetadataIdMap(
|
|
objectMetadata,
|
|
flatFieldMetadataMaps,
|
|
);
|
|
|
|
const permissionsForEntity = objectsPermissions[objectMetadataIdForEntity];
|
|
|
|
switch (operationType) {
|
|
case 'select':
|
|
if (!permissionsForEntity?.canReadObjectRecords) {
|
|
throw new PermissionsException(
|
|
PermissionsExceptionMessage.PERMISSION_DENIED,
|
|
PermissionsExceptionCode.PERMISSION_DENIED,
|
|
);
|
|
}
|
|
|
|
validateReadFieldPermissionOrThrow({
|
|
restrictedFields: permissionsForEntity.restrictedFields,
|
|
selectedColumns,
|
|
columnNameToFieldMetadataIdMap,
|
|
allFieldsSelected,
|
|
});
|
|
break;
|
|
case 'insert':
|
|
if (!permissionsForEntity?.canUpdateObjectRecords) {
|
|
throw new PermissionsException(
|
|
PermissionsExceptionMessage.PERMISSION_DENIED,
|
|
PermissionsExceptionCode.PERMISSION_DENIED,
|
|
);
|
|
}
|
|
|
|
validateReadFieldPermissionOrThrow({
|
|
restrictedFields: permissionsForEntity.restrictedFields,
|
|
selectedColumns,
|
|
columnNameToFieldMetadataIdMap,
|
|
});
|
|
|
|
if (updatedColumns.length > 0) {
|
|
const rlsFieldMetadataIds = new Set(
|
|
permissionsForEntity.rowLevelPermissionPredicates.map(
|
|
(predicate) => predicate.fieldMetadataId,
|
|
),
|
|
);
|
|
|
|
const updatedColumnsWithoutRlsFields = updatedColumns.filter(
|
|
(column) =>
|
|
!rlsFieldMetadataIds.has(columnNameToFieldMetadataIdMap[column]),
|
|
);
|
|
|
|
if (updatedColumnsWithoutRlsFields.length > 0) {
|
|
validateUpdateFieldPermissionOrThrow({
|
|
restrictedFields: permissionsForEntity.restrictedFields,
|
|
updatedColumns: updatedColumnsWithoutRlsFields,
|
|
columnNameToFieldMetadataIdMap,
|
|
});
|
|
}
|
|
}
|
|
break;
|
|
case 'update':
|
|
if (!permissionsForEntity?.canUpdateObjectRecords) {
|
|
throw new PermissionsException(
|
|
PermissionsExceptionMessage.PERMISSION_DENIED,
|
|
PermissionsExceptionCode.PERMISSION_DENIED,
|
|
);
|
|
}
|
|
|
|
validateReadFieldPermissionOrThrow({
|
|
restrictedFields: permissionsForEntity.restrictedFields,
|
|
selectedColumns,
|
|
columnNameToFieldMetadataIdMap,
|
|
});
|
|
|
|
if (updatedColumns.length > 0) {
|
|
validateUpdateFieldPermissionOrThrow({
|
|
restrictedFields: permissionsForEntity.restrictedFields,
|
|
updatedColumns,
|
|
columnNameToFieldMetadataIdMap,
|
|
});
|
|
}
|
|
break;
|
|
case 'delete':
|
|
if (!permissionsForEntity?.canDestroyObjectRecords) {
|
|
throw new PermissionsException(
|
|
PermissionsExceptionMessage.PERMISSION_DENIED,
|
|
PermissionsExceptionCode.PERMISSION_DENIED,
|
|
);
|
|
}
|
|
|
|
validateReadFieldPermissionOrThrow({
|
|
restrictedFields: permissionsForEntity.restrictedFields,
|
|
selectedColumns,
|
|
columnNameToFieldMetadataIdMap,
|
|
});
|
|
break;
|
|
case 'restore':
|
|
case 'soft-delete':
|
|
if (!permissionsForEntity?.canSoftDeleteObjectRecords) {
|
|
throw new PermissionsException(
|
|
PermissionsExceptionMessage.PERMISSION_DENIED,
|
|
PermissionsExceptionCode.PERMISSION_DENIED,
|
|
);
|
|
}
|
|
|
|
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: ObjectsPermissions;
|
|
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
|
|
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
|
objectIdByNameSingular: Record<string, string>;
|
|
shouldBypassPermissionChecks: boolean;
|
|
};
|
|
|
|
export const validateQueryIsPermittedOrThrow = ({
|
|
expressionMap,
|
|
objectsPermissions,
|
|
flatObjectMetadataMaps,
|
|
flatFieldMetadataMaps,
|
|
objectIdByNameSingular,
|
|
shouldBypassPermissionChecks,
|
|
}: ValidateQueryIsPermittedOrThrowArgs) => {
|
|
if (shouldBypassPermissionChecks) {
|
|
return;
|
|
}
|
|
|
|
const { mainEntity, operationType, isSubQuery } =
|
|
getTargetEntityAndOperationType(expressionMap);
|
|
|
|
if (isSubQuery) {
|
|
return;
|
|
}
|
|
|
|
let expressionMapSelectsOnMainEntity = expressionMap.selects;
|
|
|
|
if (!isEmpty(expressionMap.joinAttributes)) {
|
|
const { selectsWithoutJoinedAliases } =
|
|
validatePermissionsForJoinsAndReturnSelectsWithoutJoins({
|
|
expressionMap,
|
|
objectsPermissions,
|
|
flatObjectMetadataMaps,
|
|
flatFieldMetadataMaps,
|
|
objectIdByNameSingular,
|
|
});
|
|
|
|
expressionMapSelectsOnMainEntity = selectsWithoutJoinedAliases;
|
|
}
|
|
|
|
const allFieldsSelected = expressionMapSelectsOnMainEntity.some(
|
|
(select) => select.selection === mainEntity,
|
|
);
|
|
|
|
let selectedColumns: string[] | '*' = [];
|
|
let updatedColumns: string[] = [];
|
|
|
|
selectedColumns = getSelectedColumnsFromExpressionMap({
|
|
operationType,
|
|
expressionMapReturning: expressionMap.returning,
|
|
expressionMapSelects: expressionMapSelectsOnMainEntity,
|
|
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,
|
|
flatObjectMetadataMaps,
|
|
flatFieldMetadataMaps,
|
|
objectIdByNameSingular,
|
|
selectedColumns,
|
|
allFieldsSelected,
|
|
updatedColumns,
|
|
});
|
|
};
|
|
|
|
const validatePermissionsForJoinsAndReturnSelectsWithoutJoins = ({
|
|
expressionMap,
|
|
objectsPermissions,
|
|
flatObjectMetadataMaps,
|
|
flatFieldMetadataMaps,
|
|
objectIdByNameSingular,
|
|
}: {
|
|
expressionMap: QueryExpressionMap;
|
|
objectsPermissions: ObjectsPermissions;
|
|
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
|
|
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
|
objectIdByNameSingular: Record<string, string>;
|
|
}) => {
|
|
const joinAttributesAliases = new Set(
|
|
expressionMap.joinAttributes.map((join) => join.alias.name),
|
|
);
|
|
|
|
const indexesOfSelectsForJoinedAlias: number[] = [];
|
|
|
|
for (const [_index, joinedAlias] of joinAttributesAliases.entries()) {
|
|
const entity = expressionMap.aliases.find(
|
|
(alias) => alias.type === 'join' && alias.name === joinedAlias,
|
|
)?.metadata;
|
|
|
|
if (isDefined(entity)) {
|
|
for (const [index, select] of expressionMap.selects.entries()) {
|
|
const regex = /"(\w+)"\."(\w+)"/;
|
|
const extractedAlias = select.selection.match(regex)?.[1]; // "person"."name" -> "person"
|
|
|
|
if (isDefined(extractedAlias) && extractedAlias === joinedAlias) {
|
|
indexesOfSelectsForJoinedAlias.push(index);
|
|
|
|
const selectedColumns = getSelectedColumnsFromExpressionMap({
|
|
operationType: 'select',
|
|
expressionMapSelects: expressionMap.selects.filter(
|
|
(_select, indexOfSelect) => indexOfSelect === index,
|
|
),
|
|
allFieldsSelected: false,
|
|
});
|
|
|
|
validateOperationIsPermittedOrThrow({
|
|
entityName: entity.name,
|
|
operationType: 'select' as OperationType,
|
|
objectsPermissions,
|
|
flatObjectMetadataMaps,
|
|
flatFieldMetadataMaps,
|
|
objectIdByNameSingular,
|
|
selectedColumns,
|
|
allFieldsSelected: false,
|
|
updatedColumns: [],
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const selectsWithoutJoinedAliases = expressionMap.selects.filter(
|
|
(_select, index) => !indexesOfSelectsForJoinedAlias.includes(index),
|
|
);
|
|
|
|
return { selectsWithoutJoinedAliases };
|
|
};
|
|
|
|
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,
|
|
expressionMapReturning,
|
|
expressionMapSelects,
|
|
allFieldsSelected,
|
|
}: {
|
|
operationType: string;
|
|
expressionMapSelects: { selection: string }[];
|
|
allFieldsSelected: boolean;
|
|
expressionMapReturning?: string | string[];
|
|
}) => {
|
|
let selectedColumns: string[] | '*' = [];
|
|
|
|
if (
|
|
['update', 'insert', 'delete', 'soft-delete', 'restore'].includes(
|
|
operationType,
|
|
)
|
|
) {
|
|
if (!isDefined(expressionMapReturning)) {
|
|
throw new InternalServerError(
|
|
'Returning columns are not set for update query',
|
|
);
|
|
}
|
|
selectedColumns =
|
|
expressionMapReturning === '*' ? '*' : [expressionMapReturning].flat();
|
|
} else if (!allFieldsSelected) {
|
|
selectedColumns =
|
|
getSelectedColumnsFromExpressionMapSelects(expressionMapSelects);
|
|
}
|
|
|
|
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();
|
|
};
|