Introduce updateWorkspaceMemberSettings and clarify product (#19441)

## Summary

Introduces a dedicated **metadata** mutation to update **standard
(non-custom)** workspace member settings, moves profile-related UI to
use it, and aligns **workspace member** record permissions with the rest
of the CRM so users cannot escalate visibility via RLS by editing their
own member record.

## Product behaviour

### Profile and appearance (standard fields)

- Users can still update **their own** standard workspace member fields
that the product exposes in **Settings / Profile** (e.g. name, locale,
color scheme, avatar flow) via the new
**`updateWorkspaceMemberSettings`** mutation.
- The mutation returns a **boolean**; the app **merges** the updated
fields into local state so the UI stays in sync without refetching the
full workspace member record.
- **Locale** changes also keep **`userWorkspace`** in sync when a locale
is present in the payload (including from the workspace `updateOne` path
when applicable).

### Custom fields on workspace members

- The dedicated metadata mutation **rejects** any **custom** workspace
member field (and unknown keys). Those updates must go through the
normal **object** `updateOne` pipeline, which is subject to **object-
and field-level** permissions like other records. But since we don't
have object- and field-level permission configuration for system objects
yet, this permission is derived from Workspace member settings
permission.
- **Workspace member** is no longer exempt from ORM permission
validation for updates merely because it is a **system** object. Users
who **do not** have workspace member access (e.g. no **Workspace
members** settings permission and no equivalent broad settings access on
the role) **cannot** use `updateOne` on `workspaceMember` to change
**custom** (or other) fields on their own row—even though that row is
used for RLS predicates.
- This closes a path where someone could widen what they can see by
writing to fields that drive row-level rules.

### Who can change another member

- Updating **another** user’s workspace member still requires
**Workspace members** (or equivalent) settings permission, consistent
with admin tooling.
This commit is contained in:
Marie
2026-04-14 18:29:00 +02:00
committed by GitHub
parent 42f452311b
commit bc28e1557c
58 changed files with 1986 additions and 478 deletions
@@ -3,19 +3,19 @@ import { Args, Mutation, Query } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ApprovedAccessDomainExceptionFilter } from 'src/engine/core-modules/approved-access-domain/approved-access-domain-exception-filter';
import { ApprovedAccessDomainDTO } from 'src/engine/core-modules/approved-access-domain/dtos/approved-access-domain.dto';
import { CreateApprovedAccessDomainInput } from 'src/engine/core-modules/approved-access-domain/dtos/create-approved-access.domain.input';
import { DeleteApprovedAccessDomainInput } from 'src/engine/core-modules/approved-access-domain/dtos/delete-approved-access-domain.input';
import { ValidateApprovedAccessDomainInput } from 'src/engine/core-modules/approved-access-domain/dtos/validate-approved-access-domain.input';
import { ApprovedAccessDomainService } from 'src/engine/core-modules/approved-access-domain/services/approved-access-domain.service';
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
@@ -34,6 +34,7 @@ export class UpdateSubscriptionQuantityJob {
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
data.workspaceId,
'workspaceMember',
{ shouldBypassPermissionChecks: true },
);
const workspaceMembersCount = await workspaceMemberRepository.count();
@@ -4,6 +4,7 @@ import { NestjsQueryGraphQLModule } from '@ptc-org/nestjs-query-graphql';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module';
import { ApprovedAccessDomainModule } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
@@ -11,7 +12,6 @@ import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
import { UploadProfilePicturePermissionGuard } from 'src/engine/core-modules/user-workspace/guards/upload-profile-picture-permission.guard';
import { UserWorkspaceEntityCacheProviderService } from 'src/engine/core-modules/user-workspace/services/user-workspace-entity-cache-provider.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
@@ -21,6 +21,7 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { RoleValidationModule } from 'src/engine/metadata-modules/role-validation/role-validation.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
@@ -35,6 +36,7 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works
UserWorkspaceEntity,
WorkspaceEntity,
RoleTargetEntity,
RoleEntity,
]),
RoleValidationModule,
NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity]),
@@ -51,15 +53,12 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works
OnboardingModule,
EnterpriseModule,
FeatureFlagModule,
CoreEntityCacheModule,
],
services: [UserWorkspaceService],
}),
],
exports: [UserWorkspaceService],
providers: [
UserWorkspaceService,
UserWorkspaceEntityCacheProviderService,
UploadProfilePicturePermissionGuard,
],
providers: [UserWorkspaceService, UserWorkspaceEntityCacheProviderService],
})
export class UserWorkspaceModule {}
@@ -5,6 +5,7 @@ import { type DataSource, type Repository } from 'typeorm';
import { type ApprovedAccessDomainEntity } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity';
import { ApprovedAccessDomainService } from 'src/engine/core-modules/approved-access-domain/services/approved-access-domain.service';
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
@@ -138,6 +139,12 @@ describe('UserWorkspaceService', () => {
setOnboardingCreateProfilePending: jest.fn(),
},
},
{
provide: CoreEntityCacheService,
useValue: {
invalidate: jest.fn().mockResolvedValue(undefined),
},
},
{
provide: FeatureFlagService,
useValue: {
@@ -7,6 +7,7 @@ import { FileFolder } from 'twenty-shared/types';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { IsNull, Not, type QueryRunner, type Repository } from 'typeorm';
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
import { type AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
@@ -62,10 +63,37 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
private readonly fileCorePictureService: FileCorePictureService,
private readonly fileUrlService: FileUrlService,
private readonly onboardingService: OnboardingService,
private readonly coreEntityCacheService: CoreEntityCacheService,
) {
super(userWorkspaceRepository);
}
async updateUserWorkspaceLocaleForUserWorkspace({
locale,
userWorkspaceId,
}: {
locale: UserWorkspaceEntity['locale'];
userWorkspaceId: string;
}): Promise<void> {
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: {
id: userWorkspaceId,
},
});
if (!isDefined(userWorkspace)) {
return;
}
userWorkspace.locale = locale;
await this.userWorkspaceRepository.save(userWorkspace);
await this.coreEntityCacheService.invalidate(
'userWorkspaceEntity',
userWorkspaceId,
);
}
async create(
{
userId,
@@ -0,0 +1,17 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsObject, IsUUID } from 'class-validator';
import GraphQLJSON from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class UpdateWorkspaceMemberSettingsInput {
@Field(() => UUIDScalarType)
@IsUUID()
workspaceMemberId: string;
@Field(() => GraphQLJSON)
@IsObject()
update: Record<string, unknown>;
}
@@ -5,6 +5,7 @@ import { NestjsQueryGraphQLModule } from '@ptc-org/nestjs-query-graphql';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
import { EmailVerificationModule } from 'src/engine/core-modules/email-verification/email-verification.module';
@@ -25,7 +26,6 @@ import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.mod
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { userAutoResolverOpts } from './user.auto-resolver-opts';
@@ -20,6 +20,7 @@ import {
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { AvailableWorkspaces } from 'src/engine/core-modules/auth/dto/available-workspaces.dto';
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
import { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum';
import {
OnboardingService,
@@ -31,6 +32,7 @@ import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { DeletedWorkspaceMemberDTO } from 'src/engine/core-modules/user/dtos/deleted-workspace-member.dto';
import { UpdateUserEmailInput } from 'src/engine/core-modules/user/dtos/update-user-email.input';
import { UpdateWorkspaceMemberSettingsInput } from 'src/engine/core-modules/user/dtos/update-workspace-member-settings.input';
import { WorkspaceMemberDTO } from 'src/engine/core-modules/user/dtos/workspace-member.dto';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import {
@@ -38,15 +40,16 @@ import {
WorkspaceMemberTranspiler,
} from 'src/engine/core-modules/user/services/workspace-member-transpiler.service';
import { UserVarsService } from 'src/engine/core-modules/user/user-vars/services/user-vars.service';
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { assertWorkspaceMemberUpdateUsesNonCustomFieldsOnly } from 'src/engine/core-modules/user/utils/assert-workspace-member-update-non-custom-fields.util';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthApiKey } from 'src/engine/decorators/auth/auth-api-key.decorator';
import { AuthProvider } from 'src/engine/decorators/auth/auth-provider.decorator';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspaceMemberId } from 'src/engine/decorators/auth/auth-workspace-member-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { CustomPermissionGuard } from 'src/engine/guards/custom-permission.guard';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
@@ -437,6 +440,106 @@ export class UserResolver {
});
}
@Mutation(() => Boolean)
@UseGuards(WorkspaceAuthGuard, CustomPermissionGuard)
async updateWorkspaceMemberSettings(
@Args('input') input: UpdateWorkspaceMemberSettingsInput,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId({ allowUndefined: true }) userWorkspaceId?: string,
@AuthApiKey() apiKey?: ApiKeyEntity,
@AuthWorkspaceMemberId() authenticatedWorkspaceMemberId?: string,
@AuthUser({ allowUndefined: true }) user?: AuthContextUser | null,
): Promise<boolean> {
let isUpdatingSelf =
isDefined(authenticatedWorkspaceMemberId) &&
authenticatedWorkspaceMemberId === input.workspaceMemberId;
// for signup
if (!isUpdatingSelf && isDefined(user?.id) && !isDefined(apiKey)) {
const targetWorkspaceMember =
await this.userWorkspaceService.getWorkspaceMemberOrThrow({
workspaceMemberId: input.workspaceMemberId,
workspaceId: workspace.id,
});
isUpdatingSelf = targetWorkspaceMember.userId === user.id;
}
const canUpdateWorkspaceMember =
isUpdatingSelf ||
(await this.permissionsService.userHasWorkspaceSettingPermission({
userWorkspaceId,
workspaceId: workspace.id,
setting: PermissionFlagType.WORKSPACE_MEMBERS,
apiKeyId: apiKey?.id,
}));
if (!canUpdateWorkspaceMember) {
throw new PermissionsException(
PermissionsExceptionMessage.PERMISSION_DENIED,
PermissionsExceptionCode.PERMISSION_DENIED,
{
userFriendlyMessage: msg`You do not have permission to update this workspace member.`,
},
);
}
assertWorkspaceMemberUpdateUsesNonCustomFieldsOnly({
update: input.update,
});
const workspaceMemberRepository =
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () =>
this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
workspace.id,
'workspaceMember',
{
shouldBypassPermissionChecks: true,
},
),
);
const workspaceMember =
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(() =>
workspaceMemberRepository.findOne({
where: {
id: input.workspaceMemberId,
},
}),
);
if (!isDefined(workspaceMember)) {
throw new BadRequestException('Workspace member not found');
}
const workspaceMemberUpdatePayload: Partial<WorkspaceMemberWorkspaceEntity> =
{
id: workspaceMember.id,
...(input.update as Partial<WorkspaceMemberWorkspaceEntity>),
};
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(() =>
workspaceMemberRepository.save(workspaceMemberUpdatePayload),
);
if (isDefined(input.update.locale)) {
const targetUserWorkspace =
await this.userWorkspaceService.getUserWorkspaceForUserOrThrow({
userId: workspaceMember.userId,
workspaceId: workspace.id,
});
await this.userWorkspaceService.updateUserWorkspaceLocaleForUserWorkspace(
{
locale: input.update.locale as UserWorkspaceEntity['locale'],
userWorkspaceId: targetUserWorkspace.id,
},
);
}
return true;
}
@ResolveField(() => OnboardingStatus, {
nullable: true,
})
@@ -0,0 +1,85 @@
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { assertWorkspaceMemberUpdateUsesNonCustomFieldsOnly } from 'src/engine/core-modules/user/utils/assert-workspace-member-update-non-custom-fields.util';
describe('assertWorkspaceMemberUpdateUsesNonCustomFieldsOnly', () => {
it('should throw when the update payload is empty', () => {
expect(() =>
assertWorkspaceMemberUpdateUsesNonCustomFieldsOnly({ update: {} }),
).toThrow(UserInputError);
expect(() =>
assertWorkspaceMemberUpdateUsesNonCustomFieldsOnly({ update: {} }),
).toThrow('Update payload cannot be empty');
});
it('should not throw when all top-level keys are standard workspaceMember fields', () => {
expect(() =>
assertWorkspaceMemberUpdateUsesNonCustomFieldsOnly({
update: {
timeZone: 'Europe/Paris',
locale: 'en',
},
}),
).not.toThrow();
});
it.each(['userId', 'id'] as const)(
'should throw when the update includes %s',
(fieldName) => {
expect(() =>
assertWorkspaceMemberUpdateUsesNonCustomFieldsOnly({
update: {
[fieldName]: 'value',
},
}),
).toThrow(UserInputError);
expect(() =>
assertWorkspaceMemberUpdateUsesNonCustomFieldsOnly({
update: {
[fieldName]: 'value',
},
}),
).toThrow(
`Cannot update custom workspaceMember field via this endpoint: ${fieldName}`,
);
},
);
it('should throw when a top-level key is not in the standard field allowlist', () => {
const unknownKey = 'notAWorkspaceMemberField';
expect(() =>
assertWorkspaceMemberUpdateUsesNonCustomFieldsOnly({
update: {
[unknownKey]: 'value',
},
}),
).toThrow(UserInputError);
expect(() =>
assertWorkspaceMemberUpdateUsesNonCustomFieldsOnly({
update: {
[unknownKey]: 'value',
},
}),
).toThrow(
`Cannot update custom workspaceMember field via this endpoint: ${unknownKey}`,
);
});
it('should reject the payload when a disallowed key is mixed with allowed keys', () => {
const unknownKey = 'typoTimeZone';
expect(() =>
assertWorkspaceMemberUpdateUsesNonCustomFieldsOnly({
update: {
timeZone: 'Europe/Paris',
[unknownKey]: 'x',
},
}),
).toThrow(
`Cannot update custom workspaceMember field via this endpoint: ${unknownKey}`,
);
});
});
@@ -0,0 +1,42 @@
import { msg } from '@lingui/core/macro';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
const WORKSPACE_MEMBER_UPDATE_DISALLOWED_FIELD_NAMES = new Set([
'id',
'userId',
]);
const WORKSPACE_MEMBER_NON_CUSTOM_UPDATE_FIELD_ALLOWLIST = new Set<string>(
Object.keys(STANDARD_OBJECTS.workspaceMember.fields).filter(
(fieldName) =>
!WORKSPACE_MEMBER_UPDATE_DISALLOWED_FIELD_NAMES.has(fieldName),
),
);
export const assertWorkspaceMemberUpdateUsesNonCustomFieldsOnly = ({
update,
}: {
update: Record<string, unknown>;
}): void => {
const updateKeys = Object.keys(update);
if (updateKeys.length === 0) {
throw new UserInputError('Update payload cannot be empty', {
userFriendlyMessage: msg`Add at least one field to update.`,
});
}
for (const payloadKey of updateKeys) {
if (!WORKSPACE_MEMBER_NON_CUSTOM_UPDATE_FIELD_ALLOWLIST.has(payloadKey)) {
throw new UserInputError(
`Cannot update custom workspaceMember field via this endpoint: ${payloadKey}`,
{
userFriendlyMessage: msg`"${payloadKey}" is not a valid workspace member field.`,
},
);
}
}
};
@@ -0,0 +1,302 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { PermissionFlagType } from 'twenty-shared/constants';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { type Repository } from 'typeorm';
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 { PermissionFlagEntity } from 'src/engine/metadata-modules/permission-flag/permission-flag.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { WorkspaceRolesPermissionsCacheService } from 'src/engine/metadata-modules/role/services/workspace-roles-permissions-cache.service';
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';
const WORKSPACE_ID = '20202020-0000-4000-8000-000000000000';
const ROLE_ID = '11111111-1111-4111-8111-111111111111';
const WORKSPACE_MEMBER_OBJECT_METADATA_ID =
'22222222-2222-4222-8222-222222222222';
const WORKFLOW_OBJECT_METADATA_ID = '33333333-3333-4333-8333-333333333333';
const PERSON_OBJECT_METADATA_ID = '44444444-4444-4444-8444-444444444444';
const createBaseRole = (
overrides: Partial<RoleEntity> &
Pick<RoleEntity, 'permissionFlags' | 'objectPermissions'>,
): RoleEntity =>
({
id: ROLE_ID,
label: 'Test role',
workspaceId: WORKSPACE_ID,
canUpdateAllSettings: false,
canAccessAllTools: false,
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
description: null,
icon: null,
isEditable: true,
canBeAssignedToUsers: true,
canBeAssignedToAgents: true,
canBeAssignedToApiKeys: true,
fieldPermissions: [],
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
...overrides,
}) as RoleEntity;
describe('WorkspaceRolesPermissionsCacheService', () => {
let service: WorkspaceRolesPermissionsCacheService;
let roleRepository: jest.Mocked<Pick<Repository<RoleEntity>, 'find'>>;
let objectMetadataRepository: jest.Mocked<
Pick<Repository<ObjectMetadataEntity>, 'find'>
>;
let objectPermissionRepository: jest.Mocked<
Pick<Repository<ObjectPermissionEntity>, 'find'>
>;
let permissionFlagRepository: jest.Mocked<
Pick<Repository<PermissionFlagEntity>, 'find'>
>;
const workspaceObjectMetadataFixture: ObjectMetadataEntity[] = [
{
id: WORKSPACE_MEMBER_OBJECT_METADATA_ID,
isSystem: true,
universalIdentifier: STANDARD_OBJECTS.workspaceMember.universalIdentifier,
labelIdentifierFieldMetadataId: null,
} as ObjectMetadataEntity,
{
id: WORKFLOW_OBJECT_METADATA_ID,
isSystem: true,
universalIdentifier: STANDARD_OBJECTS.workflow.universalIdentifier,
labelIdentifierFieldMetadataId: null,
} as ObjectMetadataEntity,
{
id: PERSON_OBJECT_METADATA_ID,
isSystem: false,
universalIdentifier: STANDARD_OBJECTS.person.universalIdentifier,
labelIdentifierFieldMetadataId: null,
} as ObjectMetadataEntity,
];
beforeEach(async () => {
roleRepository = {
find: jest.fn(),
};
objectMetadataRepository = {
find: jest.fn().mockResolvedValue(workspaceObjectMetadataFixture),
};
objectPermissionRepository = {
find: jest.fn().mockResolvedValue([]),
};
permissionFlagRepository = {
find: jest.fn().mockResolvedValue([]),
};
const fieldPermissionRepository = {
find: jest.fn().mockResolvedValue([]),
};
const rowLevelPermissionPredicateRepository = {
find: jest.fn().mockResolvedValue([]),
};
const rowLevelPermissionPredicateGroupRepository = {
find: jest.fn().mockResolvedValue([]),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
WorkspaceRolesPermissionsCacheService,
{
provide: getRepositoryToken(ObjectMetadataEntity),
useValue: objectMetadataRepository,
},
{
provide: getRepositoryToken(RoleEntity),
useValue: roleRepository,
},
{
provide: getRepositoryToken(ObjectPermissionEntity),
useValue: objectPermissionRepository,
},
{
provide: getRepositoryToken(PermissionFlagEntity),
useValue: permissionFlagRepository,
},
{
provide: getRepositoryToken(FieldPermissionEntity),
useValue: fieldPermissionRepository,
},
{
provide: getRepositoryToken(RowLevelPermissionPredicateEntity),
useValue: rowLevelPermissionPredicateRepository,
},
{
provide: getRepositoryToken(RowLevelPermissionPredicateGroupEntity),
useValue: rowLevelPermissionPredicateGroupRepository,
},
],
}).compile();
service = module.get(WorkspaceRolesPermissionsCacheService);
});
describe('workspaceMember object', () => {
it('should deny all record permissions when role has neither workspace members access nor update-all-settings', async () => {
roleRepository.find.mockResolvedValue([
createBaseRole({
permissionFlags: [],
objectPermissions: [],
}),
]);
const result = await service.computeForCache(WORKSPACE_ID);
const workspaceMemberPermissions =
result[ROLE_ID][WORKSPACE_MEMBER_OBJECT_METADATA_ID];
expect(workspaceMemberPermissions.canReadObjectRecords).toBe(true);
expect(workspaceMemberPermissions.canUpdateObjectRecords).toBe(false);
expect(workspaceMemberPermissions.canSoftDeleteObjectRecords).toBe(false);
expect(workspaceMemberPermissions.canDestroyObjectRecords).toBe(false);
});
it('should grant all record permissions when role has WORKSPACE_MEMBERS permission flag', async () => {
permissionFlagRepository.find.mockResolvedValue([
{
roleId: ROLE_ID,
flag: PermissionFlagType.WORKSPACE_MEMBERS,
} as PermissionFlagEntity,
]);
roleRepository.find.mockResolvedValue([
createBaseRole({
permissionFlags: [],
objectPermissions: [],
}),
]);
const result = await service.computeForCache(WORKSPACE_ID);
const workspaceMemberPermissions =
result[ROLE_ID][WORKSPACE_MEMBER_OBJECT_METADATA_ID];
expect(workspaceMemberPermissions.canReadObjectRecords).toBe(true);
expect(workspaceMemberPermissions.canUpdateObjectRecords).toBe(true);
expect(workspaceMemberPermissions.canSoftDeleteObjectRecords).toBe(true);
expect(workspaceMemberPermissions.canDestroyObjectRecords).toBe(true);
});
it('should grant all record permissions when role has canUpdateAllSettings', async () => {
roleRepository.find.mockResolvedValue([
createBaseRole({
canUpdateAllSettings: true,
permissionFlags: [],
objectPermissions: [],
}),
]);
const result = await service.computeForCache(WORKSPACE_ID);
const workspaceMemberPermissions =
result[ROLE_ID][WORKSPACE_MEMBER_OBJECT_METADATA_ID];
expect(workspaceMemberPermissions.canReadObjectRecords).toBe(true);
expect(workspaceMemberPermissions.canUpdateObjectRecords).toBe(true);
});
});
describe('workflow object', () => {
it('should deny all record permissions when role has neither workflows access nor update-all-settings', async () => {
roleRepository.find.mockResolvedValue([
createBaseRole({
permissionFlags: [],
objectPermissions: [],
}),
]);
const result = await service.computeForCache(WORKSPACE_ID);
const workflowPermissions = result[ROLE_ID][WORKFLOW_OBJECT_METADATA_ID];
expect(workflowPermissions.canReadObjectRecords).toBe(false);
expect(workflowPermissions.canUpdateObjectRecords).toBe(false);
expect(workflowPermissions.canSoftDeleteObjectRecords).toBe(false);
expect(workflowPermissions.canDestroyObjectRecords).toBe(false);
});
it('should grant all record permissions when role has WORKFLOWS permission flag', async () => {
permissionFlagRepository.find.mockResolvedValue([
{
roleId: ROLE_ID,
flag: PermissionFlagType.WORKFLOWS,
} as PermissionFlagEntity,
]);
roleRepository.find.mockResolvedValue([
createBaseRole({
permissionFlags: [],
objectPermissions: [],
}),
]);
const result = await service.computeForCache(WORKSPACE_ID);
const workflowPermissions = result[ROLE_ID][WORKFLOW_OBJECT_METADATA_ID];
expect(workflowPermissions.canReadObjectRecords).toBe(true);
expect(workflowPermissions.canUpdateObjectRecords).toBe(true);
expect(workflowPermissions.canSoftDeleteObjectRecords).toBe(true);
expect(workflowPermissions.canDestroyObjectRecords).toBe(true);
});
});
describe('regular object (person)', () => {
it('should apply object permission overrides when object is not system', async () => {
objectPermissionRepository.find.mockResolvedValue([
{
roleId: ROLE_ID,
objectMetadataId: PERSON_OBJECT_METADATA_ID,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
} as ObjectPermissionEntity,
]);
roleRepository.find.mockResolvedValue([
createBaseRole({
permissionFlags: [],
objectPermissions: [],
}),
]);
const result = await service.computeForCache(WORKSPACE_ID);
const personPermissions = result[ROLE_ID][PERSON_OBJECT_METADATA_ID];
expect(personPermissions.canReadObjectRecords).toBe(true);
expect(personPermissions.canUpdateObjectRecords).toBe(false);
expect(personPermissions.canSoftDeleteObjectRecords).toBe(false);
expect(personPermissions.canDestroyObjectRecords).toBe(false);
});
it('should use role-wide CRUD defaults when no object permission row exists', async () => {
roleRepository.find.mockResolvedValue([
createBaseRole({
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: true,
permissionFlags: [],
objectPermissions: [],
}),
]);
const result = await service.computeForCache(WORKSPACE_ID);
const personPermissions = result[ROLE_ID][PERSON_OBJECT_METADATA_ID];
expect(personPermissions.canReadObjectRecords).toBe(true);
expect(personPermissions.canUpdateObjectRecords).toBe(true);
expect(personPermissions.canSoftDeleteObjectRecords).toBe(true);
expect(personPermissions.canDestroyObjectRecords).toBe(true);
});
});
});
@@ -28,6 +28,8 @@ const WORKFLOW_STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS = [
STANDARD_OBJECTS.workflowRun.universalIdentifier,
STANDARD_OBJECTS.workflowVersion.universalIdentifier,
] as const;
const WORKSPACE_MEMBER_OBJECT_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.workspaceMember.universalIdentifier;
@Injectable()
@WorkspaceCache('rolesPermissions')
@@ -137,47 +139,66 @@ export class WorkspaceRolesPermissionsCacheService extends WorkspaceCacheProvide
let canDestroy = role.canDestroyAllObjectRecords;
const restrictedFields: RestrictedFieldsPermissions = {};
if (
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],
)
) {
const hasWorkflowsPermissions = this.hasWorkflowsPermissions(
role,
rolePermissionFlags,
);
if (isWorkflowRelatedObject) {
const hasWorkflowsPermissions =
this.hasSettingsGatedObjectPermissions(
role,
rolePermissionFlags,
PermissionFlagType.WORKFLOWS,
);
canRead = hasWorkflowsPermissions;
canUpdate = hasWorkflowsPermissions;
canSoftDelete = hasWorkflowsPermissions;
canDestroy = hasWorkflowsPermissions;
} else {
const objectRecordPermissionsOverride = roleObjectPermissions.find(
(objectPermission) =>
objectPermission.objectMetadataId === objectMetadataId,
);
if (isWorkspaceMemberObject) {
const hasWorkspaceMembersPermissions =
this.hasSettingsGatedObjectPermissions(
role,
rolePermissionFlags,
PermissionFlagType.WORKSPACE_MEMBERS,
);
const getPermissionValue = (
overrideValue: boolean | undefined,
defaultValue: boolean,
) => (isSystem ? true : (overrideValue ?? defaultValue));
canRead = true;
canUpdate = hasWorkspaceMembersPermissions;
canSoftDelete = hasWorkspaceMembersPermissions;
canDestroy = hasWorkspaceMembersPermissions;
} else {
const objectRecordPermissionsOverride = roleObjectPermissions.find(
(objectPermission) =>
objectPermission.objectMetadataId === objectMetadataId,
);
canRead = getPermissionValue(
objectRecordPermissionsOverride?.canReadObjectRecords,
canRead,
);
canUpdate = getPermissionValue(
objectRecordPermissionsOverride?.canUpdateObjectRecords,
canUpdate,
);
canSoftDelete = getPermissionValue(
objectRecordPermissionsOverride?.canSoftDeleteObjectRecords,
canSoftDelete,
);
canDestroy = getPermissionValue(
objectRecordPermissionsOverride?.canDestroyObjectRecords,
canDestroy,
);
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) =>
@@ -246,21 +267,18 @@ export class WorkspaceRolesPermissionsCacheService extends WorkspaceCacheProvide
return workspaceObjectMetadata;
}
private hasWorkflowsPermissions(
private hasSettingsGatedObjectPermissions(
role: RoleEntity,
permissionFlags: PermissionFlagEntity[],
permissionFlagType: PermissionFlagType,
): boolean {
const hasWorkflowsPermissionFromRole = role.canUpdateAllSettings;
const hasWorkflowsPermissionsFromSettingPermissions = isDefined(
const hasPermissionFromRole = role.canUpdateAllSettings;
const hasPermissionFromSettingPermissions = isDefined(
permissionFlags.find(
(permissionFlag) =>
permissionFlag.flag === PermissionFlagType.WORKFLOWS,
(permissionFlag) => permissionFlag.flag === permissionFlagType,
),
);
return (
hasWorkflowsPermissionFromRole ||
hasWorkflowsPermissionsFromSettingPermissions
);
return hasPermissionFromRole || hasPermissionFromSettingPermissions;
}
}
@@ -177,6 +177,7 @@ export class ViewQueryParamsService {
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember',
{ shouldBypassPermissionChecks: true },
);
const workspaceMember = await workspaceMemberRepository.findOne({
@@ -1,5 +1,6 @@
import { isNonEmptyString } from '@sniptt/guards';
import isEmpty from 'lodash.isempty';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import {
type ObjectsPermissions,
type RestrictedFieldsPermissions,
@@ -20,6 +21,9 @@ import {
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { getColumnNameToFieldMetadataIdMap } from 'src/engine/twenty-orm/utils/get-column-name-to-field-metadata-id.util';
const WORKSPACE_MEMBER_OBJECT_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.workspaceMember.universalIdentifier;
const getTargetEntityAndOperationType = (
expressionMap: QueryExpressionMap,
):
@@ -110,8 +114,11 @@ export const validateOperationIsPermittedOrThrow = ({
}
const objectMetadataIsSystem = objectMetadata.isSystem === true;
const isWorkspaceMemberObject =
objectMetadata.universalIdentifier ===
WORKSPACE_MEMBER_OBJECT_UNIVERSAL_IDENTIFIER;
if (objectMetadataIsSystem) {
if (objectMetadataIsSystem && !isWorkspaceMemberObject) {
return;
}