feat: multi role permission intersection (#15150)
Implements permission intersection (AND logic) to prevent permission escalation when agents act on behalf of users. ### Changes: - **Permission Intersection**: Operations requiring both user AND agent permissions - **RoleContext Type**: Unified type supporting single `roleId` or multiple `roleIds` for intersection - **CRUD Services**: Updated to accept `roleContext` for granular permission control - **Agent Integration**: Chat agents now use user + agent role intersection for all operations - **ORM Layer**: Enhanced `getRepository` to support multi-role permission checks ### Related: - Part 2 of ["Acting on behalf of user" concept PR](https://github.com/twentyhq/twenty/pull/15103) [Closes #1661](https://github.com/twentyhq/core-team-issues/issues/1661) --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
+340
@@ -0,0 +1,340 @@
|
||||
import { type ObjectsPermissions } from 'twenty-shared/types';
|
||||
|
||||
import { computePermissionIntersection } from 'src/engine/twenty-orm/utils/compute-permission-intersection.util';
|
||||
|
||||
describe('computePermissionIntersection', () => {
|
||||
const objectMetadataId1 = 'object-1';
|
||||
const objectMetadataId2 = 'object-2';
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return empty object for empty array', () => {
|
||||
const result = computePermissionIntersection([]);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should return same permissions for single role', () => {
|
||||
const permissions: ObjectsPermissions = {
|
||||
[objectMetadataId1]: {
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: false,
|
||||
restrictedFields: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result = computePermissionIntersection([permissions]);
|
||||
|
||||
expect(result).toEqual(permissions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('intersection logic (AND)', () => {
|
||||
it('should require ALL roles to have permission (true AND true = true)', () => {
|
||||
const role1Permissions: ObjectsPermissions = {
|
||||
[objectMetadataId1]: {
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: true,
|
||||
canDestroyObjectRecords: true,
|
||||
restrictedFields: {},
|
||||
},
|
||||
};
|
||||
|
||||
const role2Permissions: ObjectsPermissions = {
|
||||
[objectMetadataId1]: {
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: true,
|
||||
canDestroyObjectRecords: true,
|
||||
restrictedFields: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result = computePermissionIntersection([
|
||||
role1Permissions,
|
||||
role2Permissions,
|
||||
]);
|
||||
|
||||
expect(result[objectMetadataId1]).toEqual({
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: true,
|
||||
canDestroyObjectRecords: true,
|
||||
restrictedFields: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should deny if ANY role lacks permission (true AND false = false)', () => {
|
||||
const role1Permissions: ObjectsPermissions = {
|
||||
[objectMetadataId1]: {
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: true,
|
||||
canDestroyObjectRecords: true,
|
||||
restrictedFields: {},
|
||||
},
|
||||
};
|
||||
|
||||
const role2Permissions: ObjectsPermissions = {
|
||||
[objectMetadataId1]: {
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: false,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: true,
|
||||
restrictedFields: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result = computePermissionIntersection([
|
||||
role1Permissions,
|
||||
role2Permissions,
|
||||
]);
|
||||
|
||||
expect(result[objectMetadataId1]).toEqual({
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: false,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: true,
|
||||
restrictedFields: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should deny all permissions if role lacks access to object entirely', () => {
|
||||
const role1Permissions: ObjectsPermissions = {
|
||||
[objectMetadataId1]: {
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: true,
|
||||
canDestroyObjectRecords: true,
|
||||
restrictedFields: {},
|
||||
},
|
||||
};
|
||||
|
||||
const role2Permissions: ObjectsPermissions = {};
|
||||
|
||||
const result = computePermissionIntersection([
|
||||
role1Permissions,
|
||||
role2Permissions,
|
||||
]);
|
||||
|
||||
expect(result[objectMetadataId1]).toEqual({
|
||||
canReadObjectRecords: false,
|
||||
canUpdateObjectRecords: false,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: false,
|
||||
restrictedFields: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple objects', () => {
|
||||
it('should compute intersection independently for each object', () => {
|
||||
const role1Permissions: ObjectsPermissions = {
|
||||
[objectMetadataId1]: {
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: false,
|
||||
restrictedFields: {},
|
||||
},
|
||||
[objectMetadataId2]: {
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: false,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: false,
|
||||
restrictedFields: {},
|
||||
},
|
||||
};
|
||||
|
||||
const role2Permissions: ObjectsPermissions = {
|
||||
[objectMetadataId1]: {
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: false,
|
||||
canSoftDeleteObjectRecords: true,
|
||||
canDestroyObjectRecords: false,
|
||||
restrictedFields: {},
|
||||
},
|
||||
[objectMetadataId2]: {
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: false,
|
||||
restrictedFields: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result = computePermissionIntersection([
|
||||
role1Permissions,
|
||||
role2Permissions,
|
||||
]);
|
||||
|
||||
expect(result[objectMetadataId1]).toEqual({
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: false,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: false,
|
||||
restrictedFields: {},
|
||||
});
|
||||
|
||||
expect(result[objectMetadataId2]).toEqual({
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: false,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: false,
|
||||
restrictedFields: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('restricted fields', () => {
|
||||
it('should compute intersection for restricted fields', () => {
|
||||
const role1Permissions: ObjectsPermissions = {
|
||||
[objectMetadataId1]: {
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: true,
|
||||
canDestroyObjectRecords: true,
|
||||
restrictedFields: {
|
||||
email: {
|
||||
canRead: null,
|
||||
canUpdate: false,
|
||||
},
|
||||
salary: {
|
||||
canRead: false,
|
||||
canUpdate: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const role2Permissions: ObjectsPermissions = {
|
||||
[objectMetadataId1]: {
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: true,
|
||||
canDestroyObjectRecords: true,
|
||||
restrictedFields: {
|
||||
email: {
|
||||
canRead: null,
|
||||
canUpdate: null,
|
||||
},
|
||||
salary: {
|
||||
canRead: null,
|
||||
canUpdate: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = computePermissionIntersection([
|
||||
role1Permissions,
|
||||
role2Permissions,
|
||||
]);
|
||||
|
||||
expect(result[objectMetadataId1].restrictedFields).toEqual({
|
||||
email: {
|
||||
canRead: null,
|
||||
canUpdate: false,
|
||||
},
|
||||
salary: {
|
||||
canRead: false,
|
||||
canUpdate: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle fields that only exist in some roles', () => {
|
||||
const role1Permissions: ObjectsPermissions = {
|
||||
[objectMetadataId1]: {
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: true,
|
||||
canDestroyObjectRecords: true,
|
||||
restrictedFields: {
|
||||
email: {
|
||||
canRead: false,
|
||||
canUpdate: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const role2Permissions: ObjectsPermissions = {
|
||||
[objectMetadataId1]: {
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: true,
|
||||
canDestroyObjectRecords: true,
|
||||
restrictedFields: {
|
||||
salary: {
|
||||
canRead: false,
|
||||
canUpdate: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = computePermissionIntersection([
|
||||
role1Permissions,
|
||||
role2Permissions,
|
||||
]);
|
||||
|
||||
expect(result[objectMetadataId1].restrictedFields).toEqual({
|
||||
email: {
|
||||
canRead: false,
|
||||
canUpdate: false,
|
||||
},
|
||||
salary: {
|
||||
canRead: false,
|
||||
canUpdate: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('three or more roles', () => {
|
||||
it('should compute intersection across all roles', () => {
|
||||
const role1: ObjectsPermissions = {
|
||||
[objectMetadataId1]: {
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: true,
|
||||
canDestroyObjectRecords: true,
|
||||
restrictedFields: {},
|
||||
},
|
||||
};
|
||||
|
||||
const role2: ObjectsPermissions = {
|
||||
[objectMetadataId1]: {
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: true,
|
||||
restrictedFields: {},
|
||||
},
|
||||
};
|
||||
|
||||
const role3: ObjectsPermissions = {
|
||||
[objectMetadataId1]: {
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: false,
|
||||
canSoftDeleteObjectRecords: true,
|
||||
canDestroyObjectRecords: true,
|
||||
restrictedFields: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result = computePermissionIntersection([role1, role2, role3]);
|
||||
|
||||
expect(result[objectMetadataId1]).toEqual({
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: false,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: true,
|
||||
restrictedFields: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
type ObjectsPermissions,
|
||||
type RestrictedFieldPermissions,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
export const computePermissionIntersection = (
|
||||
permissionsArray: ObjectsPermissions[],
|
||||
): ObjectsPermissions => {
|
||||
if (permissionsArray.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (permissionsArray.length === 1) {
|
||||
return permissionsArray[0];
|
||||
}
|
||||
|
||||
const result: ObjectsPermissions = {};
|
||||
|
||||
const allObjectMetadataIds = new Set<string>();
|
||||
|
||||
for (const permissions of permissionsArray) {
|
||||
for (const id of Object.keys(permissions)) {
|
||||
allObjectMetadataIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const objectMetadataId of allObjectMetadataIds) {
|
||||
let canReadObjectRecords = true;
|
||||
let canUpdateObjectRecords = true;
|
||||
let canSoftDeleteObjectRecords = true;
|
||||
let canDestroyObjectRecords = true;
|
||||
const restrictedFields: Record<string, RestrictedFieldPermissions> = {};
|
||||
|
||||
for (const permissions of permissionsArray) {
|
||||
const objPerm = permissions[objectMetadataId];
|
||||
|
||||
if (!objPerm) {
|
||||
canReadObjectRecords = false;
|
||||
canUpdateObjectRecords = false;
|
||||
canSoftDeleteObjectRecords = false;
|
||||
canDestroyObjectRecords = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
canReadObjectRecords =
|
||||
canReadObjectRecords && objPerm.canReadObjectRecords === true;
|
||||
canUpdateObjectRecords =
|
||||
canUpdateObjectRecords && objPerm.canUpdateObjectRecords === true;
|
||||
canSoftDeleteObjectRecords =
|
||||
canSoftDeleteObjectRecords &&
|
||||
objPerm.canSoftDeleteObjectRecords === true;
|
||||
canDestroyObjectRecords =
|
||||
canDestroyObjectRecords && objPerm.canDestroyObjectRecords === true;
|
||||
|
||||
if (objPerm.restrictedFields) {
|
||||
for (const [fieldName, fieldPerm] of Object.entries(
|
||||
objPerm.restrictedFields,
|
||||
)) {
|
||||
if (!restrictedFields[fieldName]) {
|
||||
restrictedFields[fieldName] = {
|
||||
canRead: null,
|
||||
canUpdate: null,
|
||||
};
|
||||
}
|
||||
|
||||
const current = restrictedFields[fieldName];
|
||||
|
||||
restrictedFields[fieldName] = {
|
||||
canRead:
|
||||
current.canRead === false || fieldPerm.canRead === false
|
||||
? false
|
||||
: null,
|
||||
canUpdate:
|
||||
current.canUpdate === false || fieldPerm.canUpdate === false
|
||||
? false
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result[objectMetadataId] = {
|
||||
canReadObjectRecords,
|
||||
canUpdateObjectRecords,
|
||||
canSoftDeleteObjectRecords,
|
||||
canDestroyObjectRecords,
|
||||
restrictedFields,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
Reference in New Issue
Block a user