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
@@ -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.`,
},
);
}
}
};