fix(server): workspace member permissions and profile onboarding (#19786)

## Summary

Aligns **workspace member** editing and **onboarding** with how the
product is actually used: profile and other “settings” fields go through
**`updateWorkspaceMemberSettings`**, while **`/graphql`** record APIs
follow **object-level** permissions for the `workspaceMember` object.

## Product behaviour

### Completing “Create profile” onboarding

Users who must create a profile (empty name at sign-up) get
`ONBOARDING_CREATE_PROFILE_PENDING` set. The onboarding UI saves the
name with **`updateWorkspaceMemberSettings`**, not with a workspace
record **`updateOne`**.

**Before:** The server only cleared the pending flag on
**`workspaceMember.updateOne`**, so the flag could stay set and
onboarding appeared stuck.

**After:** Clearing the profile step runs when
**`updateWorkspaceMemberSettings`** persists an update that includes a
**name** (same rules as before: non-empty name parts). Onboarding can
advance normally after **Continue** on Create profile.

### Two ways to change workspace member data

| Path | Typical use | Who can change what |
|------|----------------|---------------------|
| **`updateWorkspaceMemberSettings`** (metadata API) | Standard member
fields the app treats as “my profile / preferences” (name,
avatar-related settings, locale, time zone, etc.) | **Always** your
**own** workspace member. Changing **another** member still requires
**Workspace members** in role settings (`WORKSPACE_MEMBERS`). Custom
fields are **not** allowed on this endpoint (unchanged). |
| **`/graphql`** record mutations on **`workspaceMember`** | Custom
fields, integrations, anything that goes through the generic record API
| **`WorkspaceMember`** is special-cased in permissions: **read** stays
**on** for everyone, but **update / create / delete** require
**`WORKSPACE_MEMBERS`**, including updating **your own** row via
`/graphql`. So a **Member** without that permission cannot fix their
name through **`updateWorkspaceMember`**; they use **Settings** /
**`updateWorkspaceMemberSettings`** instead. |

This matches **`WorkspaceRolesPermissionsCacheService`**: for the
workspace member object, `canReadObjectRecords` is always true;
`canUpdateObjectRecords` (and delete-related flags) follow
**`WORKSPACE_MEMBERS`**.

### Hooks and delete side-effects

- Removed **`workspaceMember.updateOne`** pre-query hook and
**`WorkspaceMemberPreQueryHookService`**: they duplicated the same rules
the permission cache already enforces for `/graphql`.
- **`WorkspaceMember.deleteOne`** pre-hook still tells users to remove
members via the dedicated flow; the post-hook only runs the
**`deleteUserWorkspace`** side-effect when a member row is actually
removed—**no** extra settings-permission check there, since only callers
that already passed **object** delete permission can remove the row.

## Tests

- **`workspace-members.integration-spec.ts`**: clarifies and extends
coverage so **`/graphql`** **`updateOne`** is denied for **own** record
on a **standard** name field and on a **custom** field when the role
lacks **`WORKSPACE_MEMBERS`**.

## Implementation notes

- **`OnboardingService.completeOnboardingProfileStepIfNameProvided`**
centralises the “clear profile pending if name present” logic;
**`UserResolver.updateWorkspaceMemberSettings`** calls it after save,
using the typed update payload’s **`name`** (no cast).
- **`UserWorkspaceService.updateUserWorkspaceLocaleForUserWorkspace`**:
drops a redundant **`coreEntityCacheService.invalidate`**;
**`updateWorkspaceMemberSettings`** still invalidates the user-workspace
cache after the mutation.
This commit is contained in:
Marie
2026-04-17 11:58:34 +02:00
committed by GitHub
parent ba1195d92e
commit b31f84fbb8
11 changed files with 800 additions and 653 deletions
@@ -7,8 +7,6 @@ import { type WorkspacePostQueryHookInstance } from 'src/engine/api/graphql/work
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
import { WorkspaceQueryHookType } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type';
import { isApiKeyAuthContext } from 'src/engine/core-modules/auth/guards/is-api-key-auth-context.guard';
import { isUserAuthContext } from 'src/engine/core-modules/auth/guards/is-user-auth-context.guard';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
@@ -18,7 +16,6 @@ import {
PermissionsExceptionCode,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceMemberPreQueryHookService } from 'src/modules/workspace-member/query-hooks/workspace-member-pre-query-hook.service';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@WorkspaceQueryHook({
@@ -32,7 +29,6 @@ export class WorkspaceMemberDeleteOnePostQueryHook
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
private readonly workspaceMemberPreQueryHookService: WorkspaceMemberPreQueryHookService,
private readonly userWorkspaceService: UserWorkspaceService,
) {}
@@ -52,22 +48,6 @@ export class WorkspaceMemberDeleteOnePostQueryHook
assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError);
await this.workspaceMemberPreQueryHookService.validateWorkspaceMemberUpdatePermissionOrThrow(
{
userWorkspaceId: isUserAuthContext(authContext)
? authContext.userWorkspaceId
: undefined,
workspaceMemberId: isUserAuthContext(authContext)
? authContext.workspaceMemberId
: undefined,
targettedWorkspaceMemberId,
workspaceId: workspace.id,
apiKey: isApiKeyAuthContext(authContext)
? authContext.apiKey
: undefined,
},
);
const workspaceMember =
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
@@ -1,99 +0,0 @@
import { Injectable } from '@nestjs/common';
import { PermissionFlagType } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import {
PermissionsException,
PermissionsExceptionCode,
PermissionsExceptionMessage,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
@Injectable()
export class WorkspaceMemberPreQueryHookService {
constructor(
private readonly permissionsService: PermissionsService,
private readonly onboardingService: OnboardingService,
) {}
async validateWorkspaceMemberUpdatePermissionOrThrow({
userWorkspaceId,
workspaceMemberId,
targettedWorkspaceMemberId,
workspaceId,
apiKey,
}: {
userWorkspaceId?: string;
workspaceMemberId?: string;
targettedWorkspaceMemberId?: string;
workspaceId: string;
apiKey?: FlatApiKey | null;
}) {
if (isDefined(apiKey)) {
return;
}
if (!userWorkspaceId) {
throw new PermissionsException(
PermissionsExceptionMessage.USER_WORKSPACE_NOT_FOUND,
PermissionsExceptionCode.USER_WORKSPACE_NOT_FOUND,
);
}
if (
isDefined(targettedWorkspaceMemberId) &&
workspaceMemberId === targettedWorkspaceMemberId
) {
return;
}
if (
await this.permissionsService.userHasWorkspaceSettingPermission({
userWorkspaceId,
workspaceId,
setting: PermissionFlagType.WORKSPACE_MEMBERS,
apiKeyId: apiKey ?? undefined,
})
) {
return;
}
throw new PermissionsException(
PermissionsExceptionMessage.PERMISSION_DENIED,
PermissionsExceptionCode.PERMISSION_DENIED,
);
}
async completeOnboardingProfileStepIfNameProvided({
userId,
workspaceId,
firstName,
lastName,
}: {
userId?: string;
workspaceId: string;
firstName?: string;
lastName?: string;
}) {
if (!userId) {
return;
}
if (firstName === '' && lastName === '') {
return;
}
if (!isDefined(firstName) && !isDefined(lastName)) {
return;
}
await this.onboardingService.setOnboardingCreateProfilePending({
userId,
workspaceId,
value: false,
});
}
}
@@ -3,10 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { WorkspaceMemberCreateManyPreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-create-many.pre-query.hook';
import { WorkspaceMemberCreateOnePreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-create-one.pre-query.hook';
import { WorkspaceMemberDeleteManyPreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-delete-many.pre-query.hook';
@@ -14,15 +12,12 @@ import { WorkspaceMemberDeleteOnePostQueryHook } from 'src/modules/workspace-mem
import { WorkspaceMemberDeleteOnePreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-delete-one.pre-query.hook';
import { WorkspaceMemberDestroyManyPreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-destroy-many.pre-query.hook';
import { WorkspaceMemberDestroyOnePreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-destroy-one.pre-query.hook';
import { WorkspaceMemberPreQueryHookService } from 'src/modules/workspace-member/query-hooks/workspace-member-pre-query-hook.service';
import { WorkspaceMemberRestoreManyPreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-restore-many.pre-query.hook';
import { WorkspaceMemberRestoreOnePreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-restore-one.pre-query.hook';
import { WorkspaceMemberUpdateManyPreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-update-many.pre-query.hook';
import { WorkspaceMemberUpdateOnePreQueryHook } from 'src/modules/workspace-member/query-hooks/workspace-member-update-one.pre-query.hook';
@Module({
providers: [
WorkspaceMemberPreQueryHookService,
WorkspaceMemberCreateOnePreQueryHook,
WorkspaceMemberCreateManyPreQueryHook,
WorkspaceMemberDeleteOnePreQueryHook,
@@ -32,14 +27,11 @@ import { WorkspaceMemberUpdateOnePreQueryHook } from 'src/modules/workspace-memb
WorkspaceMemberDestroyManyPreQueryHook,
WorkspaceMemberRestoreOnePreQueryHook,
WorkspaceMemberRestoreManyPreQueryHook,
WorkspaceMemberUpdateOnePreQueryHook,
WorkspaceMemberUpdateManyPreQueryHook,
],
imports: [
CoreEntityCacheModule,
FeatureFlagModule,
OnboardingModule,
PermissionsModule,
UserWorkspaceModule,
TypeOrmModule.forFeature([UserWorkspaceEntity]),
],
@@ -1,61 +0,0 @@
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import { type WorkspacePreQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
import { type UpdateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
import { isApiKeyAuthContext } from 'src/engine/core-modules/auth/guards/is-api-key-auth-context.guard';
import { isUserAuthContext } from 'src/engine/core-modules/auth/guards/is-user-auth-context.guard';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
import { WorkspaceMemberPreQueryHookService } from 'src/modules/workspace-member/query-hooks/workspace-member-pre-query-hook.service';
@WorkspaceQueryHook(`workspaceMember.updateOne`)
export class WorkspaceMemberUpdateOnePreQueryHook
implements WorkspacePreQueryHookInstance
{
constructor(
private readonly workspaceMemberPreQueryHookService: WorkspaceMemberPreQueryHookService,
private readonly userWorkspaceService: UserWorkspaceService,
) {}
async execute(
authContext: WorkspaceAuthContext,
_objectName: string,
payload: UpdateOneResolverArgs,
): Promise<UpdateOneResolverArgs> {
const workspace = authContext.workspace;
assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError);
await this.workspaceMemberPreQueryHookService.validateWorkspaceMemberUpdatePermissionOrThrow(
{
userWorkspaceId: isUserAuthContext(authContext)
? authContext.userWorkspaceId
: undefined,
workspaceMemberId: isUserAuthContext(authContext)
? authContext.workspaceMemberId
: undefined,
targettedWorkspaceMemberId: payload.id,
workspaceId: workspace.id,
apiKey: isApiKeyAuthContext(authContext)
? authContext.apiKey
: undefined,
},
);
await this.workspaceMemberPreQueryHookService.completeOnboardingProfileStepIfNameProvided(
{
userId: isUserAuthContext(authContext)
? authContext.user.id
: undefined,
workspaceId: workspace.id,
firstName: payload.data.name?.firstName,
lastName: payload.data.name?.lastName,
},
);
return payload;
}
}