Row level permissions - POC 1 (#16599)
## Context This PR adds the core structure for RLS implementation: - RLS data model - RLS service layer - RLS WorkspaceMigration and Syncable Entity + cache + Validations - RLS resolver layer - ORM layer with RLS Predicate to ORM WHERE clause conversion with workspaceMember record transposition Tests are missing though <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Establishes core row-level permissions infrastructure and enforcement across the stack. > > - Backend: new `rowLevelPermissionPredicate` and `rowLevelPermissionPredicateGroup` entities, TypeORM migration, feature flag `IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED`, flat-entity maps/cache wiring, services and GraphQL resolvers for CRUD, and inclusion of `workspaceMember` in auth context > - ORM: applies row-level permission predicates to SELECT, DELETE, and SOFT DELETE query builders; propagates context through GlobalWorkspaceOrmManager/EntityManager > - GraphQL: generated schema/types/queries/mutations for creating/updating/deleting/fetching predicates and groups > - Frontend: settings page adds a gated "Record-level" section (placeholder) and metadata error handler labels for new entities > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit fe955cc4588a92157afa6795fb574189a4be1e93. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
+220
@@ -0,0 +1,220 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import {
|
||||
Brackets,
|
||||
NotBrackets,
|
||||
type ObjectLiteral,
|
||||
type WhereExpressionBuilder,
|
||||
} from 'typeorm';
|
||||
|
||||
import { type FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface';
|
||||
import { type WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface';
|
||||
|
||||
import { GraphqlQueryFilterFieldParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser';
|
||||
import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { type WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
|
||||
import { buildRowLevelPermissionRecordFilter } from 'src/engine/twenty-orm/utils/build-row-level-permission-record-filter.util';
|
||||
|
||||
type ApplyRowLevelPermissionPredicatesArgs<T extends ObjectLiteral> = {
|
||||
queryBuilder: WorkspaceSelectQueryBuilder<T>;
|
||||
objectMetadata: FlatObjectMetadata;
|
||||
internalContext: WorkspaceInternalContext;
|
||||
authContext: AuthContext;
|
||||
featureFlagMap: FeatureFlagMap;
|
||||
};
|
||||
|
||||
export const applyRowLevelPermissionPredicates = <T extends ObjectLiteral>({
|
||||
queryBuilder,
|
||||
objectMetadata,
|
||||
internalContext,
|
||||
authContext,
|
||||
featureFlagMap,
|
||||
}: ApplyRowLevelPermissionPredicatesArgs<T>): void => {
|
||||
if (
|
||||
featureFlagMap[
|
||||
FeatureFlagKey.IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED
|
||||
] !== true
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const roleId = authContext.userWorkspaceId
|
||||
? internalContext.userWorkspaceRoleMap[authContext.userWorkspaceId]
|
||||
: undefined;
|
||||
|
||||
const recordFilter = buildRowLevelPermissionRecordFilter({
|
||||
flatRowLevelPermissionPredicateMaps:
|
||||
internalContext.flatRowLevelPermissionPredicateMaps,
|
||||
flatRowLevelPermissionPredicateGroupMaps:
|
||||
internalContext.flatRowLevelPermissionPredicateGroupMaps,
|
||||
flatFieldMetadataMaps: internalContext.flatFieldMetadataMaps,
|
||||
objectMetadata,
|
||||
roleId,
|
||||
authContext,
|
||||
});
|
||||
|
||||
if (!recordFilter || Object.keys(recordFilter).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyObjectRecordFilterToQueryBuilder({
|
||||
queryBuilder,
|
||||
objectNameSingular: objectMetadata.nameSingular,
|
||||
recordFilter,
|
||||
fieldParser: new GraphqlQueryFilterFieldParser(
|
||||
objectMetadata,
|
||||
internalContext.flatFieldMetadataMaps,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const applyObjectRecordFilterToQueryBuilder = <T extends ObjectLiteral>({
|
||||
queryBuilder,
|
||||
objectNameSingular,
|
||||
recordFilter,
|
||||
fieldParser,
|
||||
}: {
|
||||
queryBuilder: WorkspaceSelectQueryBuilder<T>;
|
||||
objectNameSingular: string;
|
||||
recordFilter: Record<string, unknown>;
|
||||
fieldParser: GraphqlQueryFilterFieldParser;
|
||||
}): void => {
|
||||
if (!recordFilter || Object.keys(recordFilter).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const whereCondition = new Brackets((qb) => {
|
||||
Object.entries(recordFilter).forEach(([key, value], index) => {
|
||||
parseKeyFilter({
|
||||
queryBuilder: qb,
|
||||
objectNameSingular,
|
||||
key,
|
||||
value,
|
||||
isFirst: index === 0,
|
||||
fieldParser,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (queryBuilder.expressionMap.wheres.length === 0) {
|
||||
queryBuilder.where(whereCondition);
|
||||
} else {
|
||||
queryBuilder.andWhere(whereCondition);
|
||||
}
|
||||
};
|
||||
|
||||
const parseKeyFilter = ({
|
||||
queryBuilder,
|
||||
objectNameSingular,
|
||||
key,
|
||||
value,
|
||||
isFirst,
|
||||
fieldParser,
|
||||
}: {
|
||||
queryBuilder: WhereExpressionBuilder;
|
||||
objectNameSingular: string;
|
||||
key: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
value: any;
|
||||
isFirst: boolean;
|
||||
fieldParser: GraphqlQueryFilterFieldParser;
|
||||
}): void => {
|
||||
switch (key) {
|
||||
case 'and': {
|
||||
const andWhereCondition = new Brackets((qb) => {
|
||||
value.forEach((filter: Record<string, unknown>, index: number) => {
|
||||
const whereCondition = new Brackets((qb2) => {
|
||||
Object.entries(filter).forEach(
|
||||
([subFilterKey, subFilterValue], subIndex) => {
|
||||
parseKeyFilter({
|
||||
queryBuilder: qb2,
|
||||
objectNameSingular,
|
||||
key: subFilterKey,
|
||||
value: subFilterValue,
|
||||
isFirst: subIndex === 0,
|
||||
fieldParser,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if (index === 0) {
|
||||
qb.where(whereCondition);
|
||||
} else {
|
||||
qb.andWhere(whereCondition);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (isFirst) {
|
||||
queryBuilder.where(andWhereCondition);
|
||||
} else {
|
||||
queryBuilder.andWhere(andWhereCondition);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'or': {
|
||||
const orWhereCondition = new Brackets((qb) => {
|
||||
value.forEach((filter: Record<string, unknown>, index: number) => {
|
||||
const whereCondition = new Brackets((qb2) => {
|
||||
Object.entries(filter).forEach(
|
||||
([subFilterKey, subFilterValue], subIndex) => {
|
||||
parseKeyFilter({
|
||||
queryBuilder: qb2,
|
||||
objectNameSingular,
|
||||
key: subFilterKey,
|
||||
value: subFilterValue,
|
||||
isFirst: subIndex === 0,
|
||||
fieldParser,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if (index === 0) {
|
||||
qb.where(whereCondition);
|
||||
} else {
|
||||
qb.orWhere(whereCondition);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (isFirst) {
|
||||
queryBuilder.where(orWhereCondition);
|
||||
} else {
|
||||
queryBuilder.andWhere(orWhereCondition);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case 'not': {
|
||||
const notWhereCondition = new NotBrackets((qb) => {
|
||||
Object.entries(value).forEach(
|
||||
([subFilterKey, subFilterValue], subIndex) => {
|
||||
parseKeyFilter({
|
||||
queryBuilder: qb,
|
||||
objectNameSingular,
|
||||
key: subFilterKey,
|
||||
value: subFilterValue,
|
||||
isFirst: subIndex === 0,
|
||||
fieldParser,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if (isFirst) {
|
||||
queryBuilder.where(notWhereCondition);
|
||||
} else {
|
||||
queryBuilder.andWhere(notWhereCondition);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
fieldParser.parse(queryBuilder, objectNameSingular, key, value, isFirst);
|
||||
break;
|
||||
}
|
||||
};
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import {
|
||||
RecordFilterGroupLogicalOperator,
|
||||
type CompositeFieldSubFieldName,
|
||||
type PartialFieldMetadataItemOption,
|
||||
type RecordGqlOperationFilter,
|
||||
} from 'twenty-shared/types';
|
||||
import {
|
||||
computeRecordGqlOperationFilter,
|
||||
convertViewFilterValueToString,
|
||||
getFilterTypeFromFieldType,
|
||||
isDefined,
|
||||
type RecordFilter,
|
||||
type RecordFilterGroup,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
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,
|
||||
} from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
import { RowLevelPermissionPredicateGroupLogicalOperator } from 'src/engine/metadata-modules/row-level-permission-predicate/enums/row-level-permission-predicate-group-logical-operator.enum';
|
||||
import { type FlatRowLevelPermissionPredicateGroupMaps } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate-group-maps.type';
|
||||
import { type FlatRowLevelPermissionPredicateMaps } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate-maps.type';
|
||||
import { type RowLevelPermissionPredicateValue } from 'src/engine/metadata-modules/row-level-permission-predicate/types/row-level-permission-predicate-value.type';
|
||||
|
||||
type BuildRowLevelPermissionRecordFilterArgs = {
|
||||
flatRowLevelPermissionPredicateMaps: FlatRowLevelPermissionPredicateMaps;
|
||||
flatRowLevelPermissionPredicateGroupMaps: FlatRowLevelPermissionPredicateGroupMaps;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
objectMetadata: FlatObjectMetadata;
|
||||
roleId: string | undefined;
|
||||
authContext: AuthContext;
|
||||
};
|
||||
|
||||
export const buildRowLevelPermissionRecordFilter = ({
|
||||
flatRowLevelPermissionPredicateMaps,
|
||||
flatRowLevelPermissionPredicateGroupMaps,
|
||||
flatFieldMetadataMaps,
|
||||
objectMetadata,
|
||||
roleId,
|
||||
authContext,
|
||||
}: BuildRowLevelPermissionRecordFilterArgs): RecordGqlOperationFilter | null => {
|
||||
if (!isDefined(roleId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const predicates = Object.values(flatRowLevelPermissionPredicateMaps.byId)
|
||||
.filter(isDefined)
|
||||
.filter(
|
||||
(predicate) =>
|
||||
predicate.roleId === roleId &&
|
||||
predicate.objectMetadataId === objectMetadata.id &&
|
||||
!isDefined(predicate.deletedAt),
|
||||
);
|
||||
|
||||
if (predicates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fieldMetadataMapById = flatFieldMetadataMaps.byId;
|
||||
|
||||
const workspaceMember = authContext.workspaceMember;
|
||||
|
||||
const recordFilters = predicates
|
||||
.map((predicate) => {
|
||||
const fieldMetadata = fieldMetadataMapById[predicate.fieldMetadataId];
|
||||
|
||||
if (!isDefined(fieldMetadata)) {
|
||||
throw new PermissionsException(
|
||||
`Field metadata not found for row level predicate ${predicate.id}`,
|
||||
PermissionsExceptionCode.FIELD_METADATA_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const workspaceMemberFieldMetadataId =
|
||||
predicate.workspaceMemberFieldMetadataId;
|
||||
let predicateValue: RowLevelPermissionPredicateValue = predicate.value;
|
||||
|
||||
if (isDefined(workspaceMemberFieldMetadataId)) {
|
||||
const workspaceMemberFieldMetadata =
|
||||
fieldMetadataMapById[workspaceMemberFieldMetadataId];
|
||||
|
||||
if (!isDefined(workspaceMemberFieldMetadata)) {
|
||||
throw new PermissionsException(
|
||||
`Workspace member field metadata not found for row level predicate ${predicate.id}`,
|
||||
PermissionsExceptionCode.FIELD_METADATA_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isDefined(workspaceMember)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawWorkspaceMemberValue = Object.entries(workspaceMember).find(
|
||||
([key]) => key === workspaceMemberFieldMetadata.name,
|
||||
)?.[1];
|
||||
|
||||
const workspaceMemberSubFieldName =
|
||||
predicate.workspaceMemberSubFieldName;
|
||||
|
||||
if (
|
||||
isDefined(workspaceMemberSubFieldName) &&
|
||||
isDefined(rawWorkspaceMemberValue) &&
|
||||
isCompositeFieldMetadataType(workspaceMemberFieldMetadata.type) &&
|
||||
typeof rawWorkspaceMemberValue === 'object'
|
||||
) {
|
||||
predicateValue = rawWorkspaceMemberValue[workspaceMemberSubFieldName];
|
||||
} else {
|
||||
predicateValue = rawWorkspaceMemberValue;
|
||||
}
|
||||
|
||||
if (!isDefined(predicateValue)) {
|
||||
throw new PermissionsException(
|
||||
`Workspace member data missing for field ${workspaceMemberFieldMetadata.name}`,
|
||||
PermissionsExceptionCode.INVALID_ARG,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveSubFieldName = predicate.subFieldName as
|
||||
| CompositeFieldSubFieldName
|
||||
| undefined;
|
||||
|
||||
return {
|
||||
id: predicate.id,
|
||||
fieldMetadataId: predicate.fieldMetadataId,
|
||||
value: convertViewFilterValueToString(predicateValue),
|
||||
type: getFilterTypeFromFieldType(fieldMetadata.type),
|
||||
operand: predicate.operand as unknown as RecordFilter['operand'],
|
||||
recordFilterGroupId: predicate.rowLevelPermissionPredicateGroupId,
|
||||
subFieldName: effectiveSubFieldName,
|
||||
} satisfies RecordFilter;
|
||||
})
|
||||
.filter(isDefined);
|
||||
|
||||
const predicateGroupsById = flatRowLevelPermissionPredicateGroupMaps.byId;
|
||||
|
||||
const relevantGroupIds = new Set<string>();
|
||||
|
||||
for (const predicate of predicates) {
|
||||
if (isDefined(predicate.rowLevelPermissionPredicateGroupId)) {
|
||||
relevantGroupIds.add(predicate.rowLevelPermissionPredicateGroupId);
|
||||
|
||||
let parentGroupId =
|
||||
predicateGroupsById[predicate.rowLevelPermissionPredicateGroupId]
|
||||
?.parentRowLevelPermissionPredicateGroupId;
|
||||
|
||||
while (isDefined(parentGroupId) && !relevantGroupIds.has(parentGroupId)) {
|
||||
relevantGroupIds.add(parentGroupId);
|
||||
parentGroupId =
|
||||
predicateGroupsById[parentGroupId]
|
||||
?.parentRowLevelPermissionPredicateGroupId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const recordFilterGroups: RecordFilterGroup[] = [...relevantGroupIds]
|
||||
.map((groupId) => predicateGroupsById[groupId])
|
||||
.filter(isDefined)
|
||||
.filter(
|
||||
(predicateGroup) =>
|
||||
predicateGroup.roleId === roleId &&
|
||||
!isDefined(predicateGroup.deletedAt),
|
||||
)
|
||||
.map((predicateGroup) => ({
|
||||
id: predicateGroup.id,
|
||||
logicalOperator:
|
||||
predicateGroup.logicalOperator ===
|
||||
RowLevelPermissionPredicateGroupLogicalOperator.OR
|
||||
? RecordFilterGroupLogicalOperator.OR
|
||||
: RecordFilterGroupLogicalOperator.AND,
|
||||
parentRecordFilterGroupId:
|
||||
predicateGroup.parentRowLevelPermissionPredicateGroupId,
|
||||
}));
|
||||
|
||||
const fieldMetadataItems = predicates
|
||||
.map((predicate) => fieldMetadataMapById[predicate.fieldMetadataId])
|
||||
.filter(isDefined)
|
||||
.map((field) => ({
|
||||
id: field.id,
|
||||
name: field.name,
|
||||
type: field.type,
|
||||
label: field.label,
|
||||
options: field.options as PartialFieldMetadataItemOption[],
|
||||
}));
|
||||
|
||||
return computeRecordGqlOperationFilter({
|
||||
recordFilters,
|
||||
recordFilterGroups,
|
||||
fields: fieldMetadataItems,
|
||||
filterValueDependencies: {
|
||||
currentWorkspaceMemberId: authContext.workspaceMemberId,
|
||||
},
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user