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:
+17
@@ -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,
|
||||
})
|
||||
|
||||
+85
@@ -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}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
+42
@@ -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.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user