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:
+367
-16
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user