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
@@ -1,8 +1,16 @@
import gql from 'graphql-tag';
import { deleteOneOperationFactory } from 'test/integration/graphql/utils/delete-one-operation-factory.util';
import { makeGraphqlAPIRequestWithMemberRole } from 'test/integration/graphql/utils/make-graphql-api-request-with-member-role.util';
import { updateOneOperationFactory } from 'test/integration/graphql/utils/update-one-operation-factory.util';
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { makeMetadataAPIRequestWithMemberRole } from 'test/integration/metadata/suites/utils/make-metadata-api-request-with-member-role.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { FieldMetadataType } from 'twenty-shared/types';
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception';
import { WORKSPACE_MEMBER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant';
@@ -14,7 +22,108 @@ const WORKSPACE_MEMBER_GQL_FIELDS = `
`;
describe('workspace members permissions', () => {
it('should allow update when user is updating themself (member role)', async () => {
let customFieldId: string;
let customFieldName: string;
let customRelationFieldId: string;
let customRelationJoinColumnName: string;
beforeAll(async () => {
const { objects } = await findManyObjectMetadata({
input: {
filter: {},
paging: {
first: 100,
},
},
gqlFields: `
id
nameSingular
`,
expectToFail: false,
});
const workspaceMemberObjectMetadataId = objects.find(
(objectMetadata) => objectMetadata.nameSingular === 'workspaceMember',
)?.id;
const companyObjectMetadataId = objects.find(
(objectMetadata) => objectMetadata.nameSingular === 'company',
)?.id;
expect(workspaceMemberObjectMetadataId).toBeDefined();
expect(companyObjectMetadataId).toBeDefined();
customFieldName = 'region';
const { data } = await createOneFieldMetadata({
input: {
objectMetadataId: workspaceMemberObjectMetadataId as string,
name: customFieldName,
label: 'In which region the workspace member is based',
type: FieldMetadataType.TEXT,
},
gqlFields: `
id
`,
expectToFail: false,
});
customFieldId = data.createOneField.id;
const customRelationFieldName = `workingWithCompany`;
const { data: customRelationFieldData } = await createOneFieldMetadata({
input: {
objectMetadataId: workspaceMemberObjectMetadataId as string,
name: customRelationFieldName,
label: 'Company the member is working with',
type: FieldMetadataType.RELATION,
relationCreationPayload: {
targetObjectMetadataId: companyObjectMetadataId as string,
targetFieldLabel: 'workspace members working with the company',
targetFieldIcon: 'IconUsers',
type: RelationType.MANY_TO_ONE,
},
},
gqlFields: `
id
settings
`,
expectToFail: false,
});
customRelationFieldId = customRelationFieldData.createOneField.id;
customRelationJoinColumnName = (
customRelationFieldData.createOneField.settings as Record<string, unknown>
).joinColumnName as string;
expect(customRelationJoinColumnName).toBeDefined();
});
afterAll(async () => {
if (!customFieldId) {
return;
}
await deleteOneFieldMetadata({
input: {
idToDelete: customFieldId,
},
expectToFail: false,
});
if (!customRelationFieldId) {
return;
}
await deleteOneFieldMetadata({
input: {
idToDelete: customRelationFieldId,
},
expectToFail: false,
});
});
it('should deny updateOne on /graphql when member updates themself', async () => {
const graphqlOperation = updateOneOperationFactory({
objectMetadataSingularName: 'workspaceMember',
gqlFields: WORKSPACE_MEMBER_GQL_FIELDS,
@@ -29,26 +138,25 @@ describe('workspace members permissions', () => {
const response =
await makeGraphqlAPIRequestWithMemberRole(graphqlOperation);
expect(response.body.errors).not.toBeDefined();
expect(response.body.data).toStrictEqual({
updateWorkspaceMember: {
id: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY,
name: {
firstName: 'Jony',
},
},
});
expect(response.body.errors).toBeUndefined();
expect(response.body.data).toStrictEqual({ updateWorkspaceMember: null });
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
});
it('should throw when user does not have permission (member role)', async () => {
it('should deny update on custom field for another workspace member on /graphql (member role)', async () => {
const customFieldValue = 'Ile-de-france';
const graphqlOperation = updateOneOperationFactory({
objectMetadataSingularName: 'workspaceMember',
gqlFields: WORKSPACE_MEMBER_GQL_FIELDS,
gqlFields: `
id
${customFieldName}
`,
recordId: WORKSPACE_MEMBER_DATA_SEED_IDS.TIM,
data: {
name: {
firstName: 'Not Tim',
},
[customFieldName]: customFieldValue,
},
});
@@ -63,6 +171,249 @@ describe('workspace members permissions', () => {
expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
});
it('should throw when member updates a standard field for another workspace member', async () => {
const graphqlOperation = updateOneOperationFactory({
objectMetadataSingularName: 'workspaceMember',
gqlFields: WORKSPACE_MEMBER_GQL_FIELDS,
recordId: WORKSPACE_MEMBER_DATA_SEED_IDS.TIM,
data: {
timeZone: 'Europe/Paris',
},
});
const response =
await makeGraphqlAPIRequestWithMemberRole(graphqlOperation);
expect(response.body.data).toStrictEqual({ updateWorkspaceMember: null });
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
});
it('should throw when payload only contains updatedBy-managed changes', async () => {
const graphqlOperation = updateOneOperationFactory({
objectMetadataSingularName: 'workspaceMember',
gqlFields: WORKSPACE_MEMBER_GQL_FIELDS,
recordId: WORKSPACE_MEMBER_DATA_SEED_IDS.TIM,
data: {},
});
const response =
await makeGraphqlAPIRequestWithMemberRole(graphqlOperation);
expect(response.body.data).toStrictEqual({ updateWorkspaceMember: null });
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
});
it('should allow self update through dedicated metadata mutation', async () => {
const operation = {
query: gql`
mutation UpdateWorkspaceMemberSettings(
$input: UpdateWorkspaceMemberSettingsInput!
) {
updateWorkspaceMemberSettings(input: $input)
}
`,
variables: {
input: {
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY,
update: {
timeZone: 'Europe/Paris',
},
},
},
};
const response = await makeMetadataAPIRequestWithMemberRole(operation);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.updateWorkspaceMemberSettings).toBe(true);
});
it('should deny updating another workspace member through dedicated metadata mutation (member role - no workspace member settings permission)', async () => {
const operation = {
query: gql`
mutation UpdateWorkspaceMemberSettings(
$input: UpdateWorkspaceMemberSettingsInput!
) {
updateWorkspaceMemberSettings(input: $input)
}
`,
variables: {
input: {
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.TIM,
update: {
timeZone: 'Europe/Paris',
},
},
},
};
const response = await makeMetadataAPIRequestWithMemberRole(operation);
expect(response.body.data).toBeNull();
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
});
it('should allow updating another workspace member through dedicated metadata mutation for admin (has workspace member settings permission)', async () => {
const operation = {
query: gql`
mutation UpdateWorkspaceMemberSettings(
$input: UpdateWorkspaceMemberSettingsInput!
) {
updateWorkspaceMemberSettings(input: $input)
}
`,
variables: {
input: {
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.TIM,
update: {
timeZone: 'Europe/London',
},
},
},
};
const response = await makeMetadataAPIRequest(operation);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.updateWorkspaceMemberSettings).toBe(true);
});
it('should reject custom field updates through dedicated metadata mutation', async () => {
const operation = {
query: gql`
mutation UpdateWorkspaceMemberSettings(
$input: UpdateWorkspaceMemberSettingsInput!
) {
updateWorkspaceMemberSettings(input: $input)
}
`,
variables: {
input: {
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY,
update: {
[customFieldName]: 'custom-value',
},
},
},
};
const response = await makeMetadataAPIRequestWithMemberRole(operation);
expect(response.body.data).toBeNull();
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
`Cannot update custom workspaceMember field via this endpoint: ${customFieldName}`,
);
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.BAD_USER_INPUT,
);
});
it('should reject custom relation join column updates through dedicated metadata mutation', async () => {
const operation = {
query: gql`
mutation UpdateWorkspaceMemberSettings(
$input: UpdateWorkspaceMemberSettingsInput!
) {
updateWorkspaceMemberSettings(input: $input)
}
`,
variables: {
input: {
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY,
update: {
[customRelationJoinColumnName]: null,
},
},
},
};
const response = await makeMetadataAPIRequestWithMemberRole(operation);
expect(response.body.data).toBeNull();
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
`Cannot update custom workspaceMember field via this endpoint: ${customRelationJoinColumnName}`,
);
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.BAD_USER_INPUT,
);
});
it('should reject empty update payload through dedicated metadata mutation', async () => {
const operation = {
query: gql`
mutation UpdateWorkspaceMemberSettings(
$input: UpdateWorkspaceMemberSettingsInput!
) {
updateWorkspaceMemberSettings(input: $input)
}
`,
variables: {
input: {
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY,
update: {},
},
},
};
const response = await makeMetadataAPIRequestWithMemberRole(operation);
expect(response.body.data).toBeNull();
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
'Update payload cannot be empty',
);
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.BAD_USER_INPUT,
);
});
it('should reject unknown top-level keys through dedicated metadata mutation (allowlist)', async () => {
const unknownKey = 'notAStandardWorkspaceMemberField';
const operation = {
query: gql`
mutation UpdateWorkspaceMemberSettings(
$input: UpdateWorkspaceMemberSettingsInput!
) {
updateWorkspaceMemberSettings(input: $input)
}
`,
variables: {
input: {
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY,
update: {
[unknownKey]: 'value',
},
},
},
};
const response = await makeMetadataAPIRequestWithMemberRole(operation);
expect(response.body.data).toBeNull();
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
`Cannot update custom workspaceMember field via this endpoint: ${unknownKey}`,
);
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.BAD_USER_INPUT,
);
});
it('should throw when calling deleteOne ', async () => {
const graphqlOperation = deleteOneOperationFactory({
objectMetadataSingularName: 'workspaceMember',