Files
twenty/packages/twenty-server/src/engine/metadata-modules/role/services/workspace-roles-permissions-cache.service.ts
T
Félix Malfait f4ead89956 refactor(twenty-orm): migrate 23 grandfathered entities to WorkspaceScopedRepository (#20987)
## Summary

Follow-up to #20953. Migrates 23 of the 30 entities that were left in
`WORKSPACE_SCOPED_EXEMPTIONS` last time, so the lint rule's
workspaceId-enforcement default now covers most of the core/metadata
schema.

### Migrated (23 entities, 88 files, 22 commits)

| Family | Entities |
|---|---|
| Trivial caches | `NavigationMenuItem`, `Skill`, `DataSource`,
`Webhook`, `CommandMenuItem`, `IndexMetadata` |
| Views | `View`, `ViewField`, `ViewFieldGroup`, `ViewFilter`,
`ViewFilterGroup`, `ViewGroup`, `ViewSort` |
| Layouts | `PageLayout`, `PageLayoutTab`, `PageLayoutWidget` |
| Roles & permissions | `Role`, `RoleTarget`, `PermissionFlag`,
`ObjectPermission`, `FieldPermission`, `RowLevelPermissionPredicate`,
`RowLevelPermissionPredicateGroup` |

For each entity: swap `@InjectRepository(X)` →
`@InjectWorkspaceScopedRepository(X)` (and the field type →
`WorkspaceScopedRepository<X>`); rewrite every call site to pass
`workspaceId` as the first arg (stripped from `where`/criteria — the
wrapper throws if you include it now); register
`provideWorkspaceScopedRepository(X)` in every owning NestJS module;
update affected spec providers to
`getWorkspaceScopedRepositoryToken(X)`.

### Rule update

- `ApplicationRegistrationVariableEntity` was misclassified — moved to
`STRUCTURAL_EXEMPTIONS` (no `workspaceId` column; it's keyed on
`applicationRegistrationId` at the instance level).
- 22 of the 23 migrated entities removed from
`WORKSPACE_SCOPED_EXEMPTIONS` entirely (zero remaining raw
`@InjectRepository` sites).
- `RoleTargetEntity` also removed; one call site in
`user-workspace.service.ts` keeps a raw injection with an
`eslint-disable` + reason because `softRemove(...)` is not on the
wrapper API yet (the migration would require threading `workspaceId`
through `deleteUserWorkspace`'s three callers).

### Still exempted (7 entities, follow-up PRs)

| Entity | Why deferred |
|---|---|
| `ApplicationEntity` | ~50 sites with several cross-workspace lookups
by id (auth, OAuth, file-storage, cleanup) |
| `CalendarChannelEntity` / `MessageChannelEntity` | Use
`.increment(...)` (not on wrapper) and
`repository.manager.transaction(...)` — wrapper needs to grow
`.increment` + the transaction sites need `withManager` or dual-inject |
| `FieldMetadataEntity` / `ObjectMetadataEntity` | The metadata services
`extends TypeOrmQueryService<X>` and `super(rawRepo)` — requires
dual-inject or reworking the inheritance |
| `KeyValuePairEntity` | Allows `workspaceId: IsNull()` for
instance-level config; wrapper rejects null |
| `UpgradeMigrationEntity` | Same — instance-level + cross-workspace
ledger |

## Test plan

- [x] `npx nx typecheck twenty-server` — clean
- [x] `npx nx lint twenty-server` — clean (0/0)
- [x] All 10 affected unit specs pass (115 tests) — api-key, agent-role,
permissions, workspace-roles-permissions-cache, view-filter-group,
workflow-version-step-operations, two-factor-authentication (service +
resolver), user-workspace, file
- [ ] Server integration tests in CI
2026-05-28 20:46:21 +02:00

290 lines
12 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import {
PermissionFlagType,
SystemPermissionFlag,
} from 'twenty-shared/constants';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import {
type ObjectsPermissions,
type ObjectsPermissionsByRoleId,
type RestrictedFieldsPermissions,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { FieldPermissionEntity } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.entity';
import { ObjectPermissionEntity } from 'src/engine/metadata-modules/object-permission/object-permission.entity';
import { RolePermissionFlagEntity } from 'src/engine/metadata-modules/role-permission-flag/role-permission-flag.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { RowLevelPermissionPredicateGroupEntity } from 'src/engine/metadata-modules/row-level-permission-predicate/entities/row-level-permission-predicate-group.entity';
import { RowLevelPermissionPredicateEntity } from 'src/engine/metadata-modules/row-level-permission-predicate/entities/row-level-permission-predicate.entity';
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator';
import { regroupEntitiesByRelatedEntityId } from 'src/engine/workspace-cache/utils/regroup-entities-by-related-entity-id';
const WORKFLOW_STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS = [
STANDARD_OBJECTS.workflow.universalIdentifier,
STANDARD_OBJECTS.workflowRun.universalIdentifier,
STANDARD_OBJECTS.workflowVersion.universalIdentifier,
] as const;
const WORKSPACE_MEMBER_OBJECT_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.workspaceMember.universalIdentifier;
@Injectable()
@WorkspaceCache('rolesPermissions')
export class WorkspaceRolesPermissionsCacheService extends WorkspaceCacheProvider<ObjectsPermissionsByRoleId> {
constructor(
@InjectRepository(ObjectMetadataEntity)
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
@InjectWorkspaceScopedRepository(RoleEntity)
private readonly roleRepository: WorkspaceScopedRepository<RoleEntity>,
@InjectWorkspaceScopedRepository(ObjectPermissionEntity)
private readonly objectPermissionRepository: WorkspaceScopedRepository<ObjectPermissionEntity>,
@InjectRepository(RolePermissionFlagEntity)
private readonly rolePermissionFlagRepository: Repository<RolePermissionFlagEntity>,
@InjectWorkspaceScopedRepository(FieldPermissionEntity)
private readonly fieldPermissionRepository: WorkspaceScopedRepository<FieldPermissionEntity>,
@InjectWorkspaceScopedRepository(RowLevelPermissionPredicateEntity)
private readonly rowLevelPermissionPredicateRepository: WorkspaceScopedRepository<RowLevelPermissionPredicateEntity>,
@InjectWorkspaceScopedRepository(RowLevelPermissionPredicateGroupEntity)
private readonly rowLevelPermissionPredicateGroupRepository: WorkspaceScopedRepository<RowLevelPermissionPredicateGroupEntity>,
) {
super();
}
async computeForCache(
workspaceId: string,
): Promise<ObjectsPermissionsByRoleId> {
const [
roles,
objectPermissions,
rolePermissionFlags,
fieldPermissions,
rowLevelPermissionPredicates,
rowLevelPermissionPredicateGroups,
workspaceObjectMetadataCollection,
] = await Promise.all([
this.roleRepository.find(workspaceId),
this.objectPermissionRepository.find(workspaceId),
this.rolePermissionFlagRepository.find({
where: { workspaceId },
relations: ['permissionFlag'],
}),
this.fieldPermissionRepository.find(workspaceId),
this.rowLevelPermissionPredicateRepository.find(workspaceId, {
where: { deletedAt: IsNull() },
}),
this.rowLevelPermissionPredicateGroupRepository.find(workspaceId, {
where: { deletedAt: IsNull() },
}),
this.getWorkspaceObjectMetadataCollection(workspaceId),
]);
const objectPermissionsByRoleId =
regroupEntitiesByRelatedEntityId<'objectPermission'>({
entities: objectPermissions,
foreignKey: 'roleId',
});
const rolePermissionFlagsByRoleId =
regroupEntitiesByRelatedEntityId<'rolePermissionFlag'>({
entities: rolePermissionFlags,
foreignKey: 'roleId',
});
const fieldPermissionsByRoleId =
regroupEntitiesByRelatedEntityId<'fieldPermission'>({
entities: fieldPermissions,
foreignKey: 'roleId',
});
const rowLevelPermissionPredicatesByRoleId =
regroupEntitiesByRelatedEntityId<'rowLevelPermissionPredicate'>({
entities: rowLevelPermissionPredicates,
foreignKey: 'roleId',
});
const rowLevelPermissionPredicateGroupsByRoleId =
regroupEntitiesByRelatedEntityId<'rowLevelPermissionPredicateGroup'>({
entities: rowLevelPermissionPredicateGroups,
foreignKey: 'roleId',
});
const permissionsByRoleId: ObjectsPermissionsByRoleId = {};
for (const role of roles) {
const roleObjectPermissions =
objectPermissionsByRoleId.get(role.id) ?? [];
const roleRolePermissionFlags =
rolePermissionFlagsByRoleId.get(role.id) ?? [];
const roleFieldPermissions = fieldPermissionsByRoleId.get(role.id) ?? [];
const roleRowLevelPermissionPredicates =
rowLevelPermissionPredicatesByRoleId.get(role.id) ?? [];
const roleRowLevelPermissionPredicateGroups =
rowLevelPermissionPredicateGroupsByRoleId.get(role.id) ?? [];
const objectRecordsPermissions: ObjectsPermissions = {};
for (const objectMetadata of workspaceObjectMetadataCollection) {
const {
id: objectMetadataId,
isSystem,
universalIdentifier,
} = objectMetadata;
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,
) => (isSystem ? true : (overrideValue ?? defaultValue));
canRead = getPermissionValue(
objectRecordPermissionsOverride?.canReadObjectRecords,
canRead,
);
canUpdate = getPermissionValue(
objectRecordPermissionsOverride?.canUpdateObjectRecords,
canUpdate,
);
canSoftDelete = getPermissionValue(
objectRecordPermissionsOverride?.canSoftDeleteObjectRecords,
canSoftDelete,
);
canDestroy = getPermissionValue(
objectRecordPermissionsOverride?.canDestroyObjectRecords,
canDestroy,
);
}
const fieldPermissionsForObject = roleFieldPermissions.filter(
(fieldPermission) =>
fieldPermission.objectMetadataId === objectMetadataId,
);
for (const fieldPermission of fieldPermissionsForObject) {
const isFieldLabelIdentifier =
fieldPermission.fieldMetadataId ===
objectMetadata.labelIdentifierFieldMetadataId;
if (
isDefined(fieldPermission.canReadFieldValue) ||
isDefined(fieldPermission.canUpdateFieldValue)
) {
restrictedFields[fieldPermission.fieldMetadataId] = {
canRead: isFieldLabelIdentifier
? true
: fieldPermission.canReadFieldValue,
canUpdate: fieldPermission.canUpdateFieldValue,
};
}
}
}
objectRecordsPermissions[objectMetadataId] = {
canReadObjectRecords: canRead,
canUpdateObjectRecords: canUpdate,
canSoftDeleteObjectRecords: canSoftDelete,
canDestroyObjectRecords: canDestroy,
restrictedFields,
rowLevelPermissionPredicates: roleRowLevelPermissionPredicates.filter(
(rowLevelPermissionPredicate) =>
rowLevelPermissionPredicate.objectMetadataId === objectMetadataId,
),
rowLevelPermissionPredicateGroups:
roleRowLevelPermissionPredicateGroups.filter(
(rowLevelPermissionPredicateGroup) =>
rowLevelPermissionPredicateGroup.objectMetadataId ===
objectMetadataId,
),
};
}
permissionsByRoleId[role.id] = objectRecordsPermissions;
}
return permissionsByRoleId;
}
private async getWorkspaceObjectMetadataCollection(
workspaceId: string,
): Promise<ObjectMetadataEntity[]> {
const workspaceObjectMetadata = await this.objectMetadataRepository.find({
where: {
workspaceId,
},
select: [
'id',
'isSystem',
'universalIdentifier',
'labelIdentifierFieldMetadataId',
],
});
return workspaceObjectMetadata;
}
private hasSettingsGatedObjectPermissions(
role: RoleEntity,
rolePermissionFlags: RolePermissionFlagEntity[],
permissionFlagType: PermissionFlagType,
): boolean {
const hasPermissionFromRole = role.canUpdateAllSettings;
const permissionFlagUniversalIdentifier =
SystemPermissionFlag[permissionFlagType];
const hasPermissionFromSettingPermissions = isDefined(
rolePermissionFlags.find(
(rolePermissionFlag) =>
rolePermissionFlag.permissionFlag.universalIdentifier ===
permissionFlagUniversalIdentifier,
),
);
return hasPermissionFromRole || hasPermissionFromSettingPermissions;
}
}