Skip to content

System objects still bypass role defaults and ORM permission checks #23533

Description

@remihuigen

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.

Metadata

Metadata

Assignees

Labels

Type

Fields

Priority

None yet

Dev status

None yet

Start date

None yet

Target date

None yet

Quarter

None yet

Projects

Status
In project

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions