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:
Abdul Rahman
2025-10-19 13:00:05 +05:30
committed by GitHub
parent f6d133f285
commit 3bec43696f
75 changed files with 909 additions and 489 deletions
@@ -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: {},
});
});
});
});
@@ -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;
};