This is a follow-up to #23062. These concerns were raised in the follow-up comment(#23062 (comment)), but were not addressed by the fix merged in #23280.
#23280 changes the role-cache precedence so that an explicit object-permission value is retained instead of being overwritten by the system-object default. However, the current code still contains two separate isSystem authorization bypasses.
1. Non-workflow, non-workspace-member system objects still ignore deny-all role defaults
The role cache first initializes CRUD values from the role-wide defaults, then resolves each non-workflow, non-workspace-member object as:
overrideValue ?? (isSystem ? true : defaultValue);
|
let canRead = role.canReadAllObjectRecords; |
|
let canUpdate = role.canUpdateAllObjectRecords; |
|
let canSoftDelete = role.canSoftDeleteAllObjectRecords; |
|
let canDestroy = role.canDestroyAllObjectRecords; |
|
const restrictedFields: RestrictedFieldsPermissions = {}; |
|
|
|
const isWorkspaceMemberObject = |
|
universalIdentifier === WORKSPACE_MEMBER_OBJECT_UNIVERSAL_IDENTIFIER; |
|
const isWorkflowRelatedObject = |
|
WORKFLOW_STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.includes( |
|
universalIdentifier as (typeof WORKFLOW_STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS)[number], |
|
); |
|
|
|
if (isWorkflowRelatedObject) { |
|
const hasWorkflowsPermissions = |
|
this.hasSettingsGatedObjectPermissions( |
|
role, |
|
roleRolePermissionFlags, |
|
PermissionFlagType.WORKFLOWS, |
|
); |
|
|
|
canRead = hasWorkflowsPermissions; |
|
canUpdate = hasWorkflowsPermissions; |
|
canSoftDelete = hasWorkflowsPermissions; |
|
canDestroy = hasWorkflowsPermissions; |
|
} else { |
|
if (isWorkspaceMemberObject) { |
|
const hasWorkspaceMembersPermissions = |
|
this.hasSettingsGatedObjectPermissions( |
|
role, |
|
roleRolePermissionFlags, |
|
PermissionFlagType.WORKSPACE_MEMBERS, |
|
); |
|
|
|
canRead = true; |
|
canUpdate = hasWorkspaceMembersPermissions; |
|
canSoftDelete = hasWorkspaceMembersPermissions; |
|
canDestroy = hasWorkspaceMembersPermissions; |
|
} else { |
|
const objectRecordPermissionsOverride = roleObjectPermissions.find( |
|
(objectPermission) => |
|
objectPermission.objectMetadataId === objectMetadataId, |
|
); |
|
|
|
const getPermissionValue = ( |
|
overrideValue: boolean | undefined, |
|
defaultValue: boolean, |
|
) => overrideValue ?? (isSystem ? true : defaultValue); |
|
|
|
canRead = getPermissionValue( |
|
objectRecordPermissionsOverride?.canReadObjectRecords, |
|
canRead, |
|
); |
|
canUpdate = getPermissionValue( |
|
objectRecordPermissionsOverride?.canUpdateObjectRecords, |
|
canUpdate, |
|
); |
|
canSoftDelete = getPermissionValue( |
|
objectRecordPermissionsOverride?.canSoftDeleteObjectRecords, |
|
canSoftDelete, |
|
); |
|
canDestroy = getPermissionValue( |
|
objectRecordPermissionsOverride?.canDestroyObjectRecords, |
|
canDestroy, |
|
); |
Therefore, when a system object has no explicit object-permission row, isSystem forces canRead, canUpdate, canSoftDelete, and canDestroy to true, even if all corresponding role-wide defaults are false.
For example, a role with:
{
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
objectPermissions: [],
}
still receives full cached CRUD permissions for a non-workflow, non-workspace-member system object such as message.
The regression test added in #23280 explicitly asserts this behavior: it creates a system message object with no override and expects every CRUD permission to be true.
|
describe('system object (message)', () => { |
|
it('should default to full access when no object permission override exists', async () => { |
|
roleRepository.find.mockResolvedValue([ |
|
createBaseRole({ |
|
rolePermissionFlags: [], |
|
objectPermissions: [], |
|
}), |
|
]); |
|
|
|
const result = await service.computeForCache(WORKSPACE_ID); |
|
const messagePermissions = result[ROLE_ID][MESSAGE_OBJECT_METADATA_ID]; |
|
|
|
expect(messagePermissions.canReadObjectRecords).toBe(true); |
|
expect(messagePermissions.canUpdateObjectRecords).toBe(true); |
|
expect(messagePermissions.canSoftDeleteObjectRecords).toBe(true); |
|
expect(messagePermissions.canDestroyObjectRecords).toBe(true); |
|
}); |
The adjacent test confirms that #23280 preserves an explicit false value in the cache:
|
it('should honor an explicit deny override instead of forcing system default', async () => { |
|
objectPermissionRepository.find.mockResolvedValue([ |
|
{ |
|
roleId: ROLE_ID, |
|
objectMetadataId: MESSAGE_OBJECT_METADATA_ID, |
|
canReadObjectRecords: false, |
|
canUpdateObjectRecords: false, |
|
canSoftDeleteObjectRecords: false, |
|
canDestroyObjectRecords: false, |
|
} as ObjectPermissionEntity, |
|
]); |
|
|
|
roleRepository.find.mockResolvedValue([ |
|
createBaseRole({ |
|
rolePermissionFlags: [], |
|
objectPermissions: [], |
|
}), |
|
]); |
|
|
|
const result = await service.computeForCache(WORKSPACE_ID); |
|
const messagePermissions = result[ROLE_ID][MESSAGE_OBJECT_METADATA_ID]; |
|
|
|
expect(messagePermissions.canReadObjectRecords).toBe(false); |
|
expect(messagePermissions.canUpdateObjectRecords).toBe(false); |
|
expect(messagePermissions.canSoftDeleteObjectRecords).toBe(false); |
|
expect(messagePermissions.canDestroyObjectRecords).toBe(false); |
That is a cache-precedence fix, but it does not make deny-all role defaults apply to these system objects.
2. The ORM still bypasses object- and field-permission enforcement for most system objects
validateOperationIsPermittedOrThrow still returns before it reads the object permission map or runs the operation and field checks:
if (objectMetadataIsSystem && !isWorkspaceMemberObject) {
return;
}
|
const objectMetadataIsSystem = objectMetadata.isSystem === true; |
|
const isWorkspaceMemberObject = |
|
objectMetadata.universalIdentifier === |
|
WORKSPACE_MEMBER_OBJECT_UNIVERSAL_IDENTIFIER; |
|
|
|
// TODO: this should be improved, we may have more complex permission configuration for is system objects |
|
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, |
|
entityName, |
|
flatFieldMetadataMaps, |
|
}); |
The return occurs before the select, insert, update, delete, restore, and soft-delete permission checks. It also occurs before validateReadFieldPermissionOrThrow and validateUpdateFieldPermissionOrThrow are called.
Consequently, the explicit-override cache fix in #23280 does not by itself make normal ORM paths enforce an override for these objects: the ORM validator still returns early based solely on isSystem.
Existing trusted-context bypass is already separate
The code already has a specific bypass for SystemAuthContext:
if (isSystemAuthContext(authContext)) {
return { shouldBypassPermissionChecks: true };
}
|
if (isSystemAuthContext(authContext)) { |
|
return { shouldBypassPermissionChecks: true }; |
|
} |
|
|
|
const roleId = resolveRoleIdFromAuthContext({ |
|
authContext, |
|
userWorkspaceRoleMap, |
|
apiKeyRoleMap, |
|
}); |
|
|
|
if (!isDefined(roleId)) { |
|
return null; |
|
} |
|
|
|
return { intersectionOf: [roleId] }; |
User and API-key contexts resolve to role IDs instead:
|
if (isUserAuthContext(authContext)) { |
|
return userWorkspaceRoleMap[authContext.userWorkspaceId]; |
|
} |
|
|
|
if (isApiKeyAuthContext(authContext)) { |
|
return apiKeyRoleMap[authContext.apiKey.id]; |
|
} |
|
|
|
if ( |
|
isApplicationAuthContext(authContext) && |
|
isDefined(authContext.application.defaultRoleId) |
|
) { |
|
return authContext.application.defaultRoleId; |
The generic isSystem bypass therefore remains an additional authorization bypass for normal role-based contexts.
Expected behavior
For normal user and API-key contexts, non-workflow system objects should use the role defaults unless there is an explicit object-level override:
role object permission → row-level permission → field permission
Trusted internal operations may continue to use the existing SystemAuthContext / shouldBypassPermissionChecks bypass.
This is a follow-up to #23062. These concerns were raised in the follow-up comment(#23062 (comment)), but were not addressed by the fix merged in #23280.
#23280 changes the role-cache precedence so that an explicit object-permission value is retained instead of being overwritten by the system-object default. However, the current code still contains two separate
isSystemauthorization bypasses.1. Non-workflow, non-workspace-member system objects still ignore deny-all role defaults
The role cache first initializes CRUD values from the role-wide defaults, then resolves each non-workflow, non-workspace-member object as:
twenty/packages/twenty-server/src/engine/metadata-modules/role/services/workspace-roles-permissions-cache.service.ts
Lines 137 to 201 in a0281f6
Therefore, when a system object has no explicit object-permission row,
isSystemforcescanRead,canUpdate,canSoftDelete, andcanDestroytotrue, even if all corresponding role-wide defaults arefalse.For example, a role with:
still receives full cached CRUD permissions for a non-workflow, non-workspace-member system object such as
message.The regression test added in #23280 explicitly asserts this behavior: it creates a system
messageobject with no override and expects every CRUD permission to betrue.twenty/packages/twenty-server/src/engine/metadata-modules/role/services/__tests__/workspace-roles-permissions-cache.service.spec.ts
Lines 327 to 343 in a0281f6
The adjacent test confirms that #23280 preserves an explicit
falsevalue in the cache:twenty/packages/twenty-server/src/engine/metadata-modules/role/services/__tests__/workspace-roles-permissions-cache.service.spec.ts
Lines 345 to 370 in a0281f6
That is a cache-precedence fix, but it does not make deny-all role defaults apply to these system objects.
2. The ORM still bypasses object- and field-permission enforcement for most system objects
validateOperationIsPermittedOrThrowstill returns before it reads the object permission map or runs the operation and field checks:twenty/packages/twenty-server/src/engine/twenty-orm/repository/permissions.utils.ts
Lines 116 to 149 in a0281f6
The return occurs before the
select,insert,update,delete,restore, andsoft-deletepermission checks. It also occurs beforevalidateReadFieldPermissionOrThrowandvalidateUpdateFieldPermissionOrThroware called.Consequently, the explicit-override cache fix in #23280 does not by itself make normal ORM paths enforce an override for these objects: the ORM validator still returns early based solely on
isSystem.Existing trusted-context bypass is already separate
The code already has a specific bypass for
SystemAuthContext:twenty/packages/twenty-server/src/engine/twenty-orm/utils/resolve-role-permission-config.util.ts
Lines 18 to 32 in a0281f6
User and API-key contexts resolve to role IDs instead:
twenty/packages/twenty-server/src/engine/twenty-orm/utils/resolve-role-id-from-auth-context.util.ts
Lines 18 to 30 in a0281f6
The generic
isSystembypass therefore remains an additional authorization bypass for normal role-based contexts.Expected behavior
For normal user and API-key contexts, non-workflow system objects should use the role defaults unless there is an explicit object-level override:
Trusted internal operations may continue to use the existing
SystemAuthContext/shouldBypassPermissionChecksbypass.