[fix] [permissions] Fix role assignment at sign-up (#11045)

In [a previous PR](https://github.com/twentyhq/twenty/pull/11023) I
fixed the issue where users where re-assigned the default role when
signin up using SSO.
The "fix" actually introduced another error, calling
`assignRoleToUserWorkspace` only when a user is signing up to twenty,
forgetting about the case where an existing twenty user signs up to a
different workspace. They should still be assigned the role in that
case.

To fix this and improve clarity, I moved assignRoleToUserWorkspace to
addUserToWorkspace and renamed addUserToWorkspace to
addUserToWorkspaceIfUserNotInWorkspace since this is at each sign in but
does nothing if user is already in the workspace.

I think ideally we should refactor this part to improve readability and
understandability, maybe with different flows for each case: signIn and
signUp, to twenty or to a workspace
This commit is contained in:
Marie
2025-03-20 11:37:26 +01:00
committed by GitHub
parent 4b34aa60b1
commit 8b7188e85b
8 changed files with 99 additions and 137 deletions

View File

@ -9,8 +9,8 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { ExistingUserOrNewUser } from 'src/engine/core-modules/auth/types/signInUp.type';
@ -101,7 +101,8 @@ describe('AuthService', () => {
useValue: {
checkUserWorkspaceExists:
userWorkspaceServiceCheckUserWorkspaceExistsMock,
addUserToWorkspace: userWorkspaceAddUserToWorkspaceMock,
addUserToWorkspaceIfUserNotInWorkspace:
userWorkspaceAddUserToWorkspaceMock,
},
},
{

View File

@ -37,8 +37,8 @@ import {
UserNotExists,
} from 'src/engine/core-modules/auth/dto/user-exists.entity';
import { WorkspaceInviteHashValid } from 'src/engine/core-modules/auth/dto/workspace-invite-hash-valid.entity';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import {
@ -106,7 +106,10 @@ export class AuthService {
workspacePersonalInviteToken: invitation.value,
email: user.email,
});
await this.userWorkspaceService.addUserToWorkspace(user, workspace);
await this.userWorkspaceService.addUserToWorkspaceIfUserNotInWorkspace(
user,
workspace,
);
return;
}

View File

@ -44,8 +44,6 @@ describe('SignInUpService', () => {
let userWorkspaceService: UserWorkspaceService;
let environmentService: EnvironmentService;
let domainManagerService: DomainManagerService;
let userRoleService: UserRoleService;
let featureFlagService: FeatureFlagService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@ -83,7 +81,7 @@ describe('SignInUpService', () => {
{
provide: UserWorkspaceService,
useValue: {
addUserToWorkspace: jest.fn(),
addUserToWorkspaceIfUserNotInWorkspace: jest.fn(),
checkUserWorkspaceExists: jest.fn(),
create: jest.fn(),
},
@ -148,8 +146,6 @@ describe('SignInUpService', () => {
environmentService = module.get<EnvironmentService>(EnvironmentService);
domainManagerService =
module.get<DomainManagerService>(DomainManagerService);
userRoleService = module.get<UserRoleService>(UserRoleService);
featureFlagService = module.get<FeatureFlagService>(FeatureFlagService);
});
it('should handle signInUp with valid personal invitation', async () => {
@ -179,10 +175,9 @@ describe('SignInUpService', () => {
.spyOn(workspaceInvitationService, 'invalidateWorkspaceInvitation')
.mockResolvedValue(undefined);
jest.spyOn(userWorkspaceService, 'addUserToWorkspace').mockResolvedValue({
user: {} as User,
userWorkspace: {} as UserWorkspace,
});
jest
.spyOn(userWorkspaceService, 'addUserToWorkspaceIfUserNotInWorkspace')
.mockResolvedValue(undefined);
const result = await service.signInUp(params);
@ -200,6 +195,9 @@ describe('SignInUpService', () => {
(params.workspace as Workspace).id,
'test@example.com',
);
expect(
userWorkspaceService.addUserToWorkspaceIfUserNotInWorkspace,
).toHaveBeenCalled();
});
it('should handle signInUp on existing workspace without invitation', async () => {
@ -217,16 +215,17 @@ describe('SignInUpService', () => {
},
};
jest.spyOn(userWorkspaceService, 'addUserToWorkspace').mockResolvedValue({
user: {} as User,
userWorkspace: {} as UserWorkspace,
});
jest
.spyOn(userWorkspaceService, 'addUserToWorkspaceIfUserNotInWorkspace')
.mockResolvedValue(undefined);
const result = await service.signInUp(params);
expect(result.workspace).toEqual(params.workspace);
expect(result.user).toBeDefined();
expect(userWorkspaceService.addUserToWorkspace).toHaveBeenCalled();
expect(
userWorkspaceService.addUserToWorkspaceIfUserNotInWorkspace,
).toHaveBeenCalled();
});
it('should handle signUp on new workspace for a new user', async () => {
@ -294,10 +293,9 @@ describe('SignInUpService', () => {
};
jest.spyOn(environmentService, 'get').mockReturnValue(false);
jest.spyOn(userWorkspaceService, 'addUserToWorkspace').mockResolvedValue({
user: {} as User,
userWorkspace: {} as UserWorkspace,
});
jest
.spyOn(userWorkspaceService, 'addUserToWorkspaceIfUserNotInWorkspace')
.mockResolvedValue(undefined);
jest
.spyOn(userWorkspaceService, 'checkUserWorkspaceExists')
.mockResolvedValue({} as UserWorkspace);
@ -306,7 +304,9 @@ describe('SignInUpService', () => {
expect(result.workspace).toEqual(params.workspace);
expect(result.user).toBeDefined();
expect(userWorkspaceService.addUserToWorkspace).toHaveBeenCalled();
expect(
userWorkspaceService.addUserToWorkspaceIfUserNotInWorkspace,
).toHaveBeenCalled();
});
it('should throw - handle signUp on workspace in pending state', async () => {
@ -365,81 +365,4 @@ describe('SignInUpService', () => {
expect(WorkspaceRepository.create).toHaveBeenCalled();
expect(WorkspaceRepository.save).toHaveBeenCalled();
});
it('should not assign default role when permissions are enabled and user exists', async () => {
const params: SignInUpBaseParams &
ExistingUserOrPartialUserWithPicture &
AuthProviderWithPasswordType = {
workspace: {
id: 'workspaceId',
defaultRoleId: 'defaultRoleId',
activationStatus: WorkspaceActivationStatus.ACTIVE,
} as Workspace,
authParams: { provider: 'password', password: 'validPassword' },
userData: {
type: 'existingUser',
existingUser: { email: 'test@example.com' } as User,
},
};
const mockUserWorkspace = { id: 'userWorkspaceId' };
jest.spyOn(featureFlagService, 'isFeatureEnabled').mockResolvedValue(true);
jest.spyOn(userWorkspaceService, 'addUserToWorkspace').mockResolvedValue({
user: {} as User,
userWorkspace: mockUserWorkspace as UserWorkspace,
});
await service.signInUp(params);
expect(params.workspace).toBeDefined();
expect(userRoleService.assignRoleToUserWorkspace).not.toHaveBeenCalled();
});
it('should assign default role when permissions are enabled and user does not exist', async () => {
const params: SignInUpBaseParams &
ExistingUserOrPartialUserWithPicture &
AuthProviderWithPasswordType = {
workspace: {
id: 'workspaceId',
defaultRoleId: 'defaultRoleId',
activationStatus: WorkspaceActivationStatus.ACTIVE,
} as Workspace,
authParams: { provider: 'password', password: 'validPassword' },
userData: {
type: 'newUserWithPicture',
newUserWithPicture: {
email: 'newuser@example.com',
picture: 'pictureUrl',
},
},
};
const mockUserWorkspace = { id: 'userWorkspaceId' };
jest.spyOn(featureFlagService, 'isFeatureEnabled').mockResolvedValue(true);
jest.spyOn(fileUploadService, 'uploadImage').mockResolvedValue({
id: '',
mimeType: '',
paths: ['path/to/image'],
});
jest.spyOn(UserRepository, 'create').mockReturnValue({} as User);
jest
.spyOn(UserRepository, 'save')
.mockResolvedValue({ id: 'newUserId' } as User);
jest.spyOn(userWorkspaceService, 'addUserToWorkspace').mockResolvedValue({
user: {} as User,
userWorkspace: mockUserWorkspace as UserWorkspace,
});
await service.signInUp(params);
expect(params.workspace).toBeDefined();
expect(userRoleService.assignRoleToUserWorkspace).toHaveBeenCalledWith({
workspaceId: params.workspace!.id,
userWorkspaceId: mockUserWorkspace.id,
roleId: params.workspace!.defaultRoleId,
});
});
});

View File

@ -226,37 +226,45 @@ export class SignInUpService {
) {
await this.throwIfWorkspaceIsNotReadyForSignInUp(params.workspace, params);
const currentUser =
params.userData.type === 'newUserWithPicture'
? await this.saveNewUser(
params.userData.newUserWithPicture,
params.workspace.id,
{ canAccessFullAdminPanel: false, canImpersonate: false },
)
: params.userData.existingUser;
const isNewUser = params.userData.type === 'newUserWithPicture';
const { user: updatedUser, userWorkspace } =
await this.userWorkspaceService.addUserToWorkspace(
currentUser,
if (isNewUser) {
const userData = params.userData as {
type: 'newUserWithPicture';
newUserWithPicture: PartialUserWithPicture;
};
const user = await this.saveNewUser(
userData.newUserWithPicture,
params.workspace.id,
{
canAccessFullAdminPanel: false,
canImpersonate: false,
},
);
await this.activateOnboardingForUser(user, params.workspace);
await this.userWorkspaceService.addUserToWorkspaceIfUserNotInWorkspace(
user,
params.workspace,
);
const user = Object.assign(currentUser, updatedUser);
const isSignUp = params.userData.type === 'newUserWithPicture';
if (isSignUp) {
await this.activateOnboardingForUser(user, params.workspace);
if (params.workspace.defaultRoleId) {
await this.userRoleService.assignRoleToUserWorkspace({
workspaceId: params.workspace.id,
userWorkspaceId: userWorkspace.id,
roleId: params.workspace.defaultRoleId,
});
}
return user;
}
const userData = params.userData as {
type: 'existingUser';
existingUser: User;
};
const user = userData.existingUser;
await this.userWorkspaceService.addUserToWorkspaceIfUserNotInWorkspace(
user,
params.workspace,
);
return user;
}

View File

@ -4,6 +4,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 { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
import { TwoFactorMethod } from 'src/engine/core-modules/two-factor-method/two-factor-method.entity';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceResolver } from 'src/engine/core-modules/user-workspace/user-workspace.resolver';
@ -13,9 +14,9 @@ import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-inv
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
@Module({
imports: [
@ -32,6 +33,7 @@ import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/doma
WorkspaceInvitationModule,
DomainManagerModule,
TwentyORMModule,
UserRoleModule,
],
services: [UserWorkspaceService],
}),

View File

@ -21,6 +21,12 @@ import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-in
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import {
PermissionsException,
PermissionsExceptionCode,
PermissionsExceptionMessage,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@ -30,6 +36,8 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
constructor(
@InjectRepository(UserWorkspace, 'core')
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
@InjectRepository(ObjectMetadataEntity, 'metadata')
@ -40,6 +48,7 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
private readonly domainManagerService: DomainManagerService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly userRoleService: UserRoleService,
) {
super(userWorkspaceRepository);
}
@ -112,7 +121,10 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
});
}
async addUserToWorkspace(user: User, workspace: Workspace) {
async addUserToWorkspaceIfUserNotInWorkspace(
user: User,
workspace: Workspace,
) {
let userWorkspace = await this.checkUserWorkspaceExists(
user.id,
workspace.id,
@ -122,17 +134,27 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
userWorkspace = await this.create(user.id, workspace.id);
await this.createWorkspaceMember(workspace.id, user);
const defaultRoleId = workspace.defaultRoleId;
if (!isDefined(defaultRoleId)) {
throw new PermissionsException(
PermissionsExceptionMessage.DEFAULT_ROLE_NOT_FOUND,
PermissionsExceptionCode.DEFAULT_ROLE_NOT_FOUND,
);
}
await this.userRoleService.assignRoleToUserWorkspace({
workspaceId: workspace.id,
userWorkspaceId: userWorkspace.id,
roleId: defaultRoleId,
});
await this.workspaceInvitationService.invalidateWorkspaceInvitation(
workspace.id,
user.email,
);
}
await this.workspaceInvitationService.invalidateWorkspaceInvitation(
workspace.id,
user.email,
);
return {
user,
userWorkspace,
};
}
public async getUserCount(workspaceId: string): Promise<number | undefined> {

View File

@ -24,6 +24,7 @@ export enum PermissionsExceptionCode {
INVALID_ARG = 'INVALID_ARG',
PERMISSIONS_V2_NOT_ENABLED = 'PERMISSIONS_V2_NOT_ENABLED',
ROLE_LABEL_ALREADY_EXISTS = 'ROLE_LABEL_ALREADY_EXISTS',
DEFAULT_ROLE_NOT_FOUND = 'DEFAULT_ROLE_NOT_FOUND',
}
export enum PermissionsExceptionMessage {
@ -43,4 +44,5 @@ export enum PermissionsExceptionMessage {
NO_ROLE_FOUND_FOR_USER_WORKSPACE = 'No role found for userWorkspace',
PERMISSIONS_V2_NOT_ENABLED = 'Permissions V2 is not enabled',
ROLE_LABEL_ALREADY_EXISTS = 'A role with this label already exists',
DEFAULT_ROLE_NOT_FOUND = 'Default role not found',
}

View File

@ -25,6 +25,7 @@ export const permissionGraphqlApiExceptionHandler = (
case PermissionsExceptionCode.ROLE_NOT_FOUND:
case PermissionsExceptionCode.USER_WORKSPACE_NOT_FOUND:
throw new NotFoundError(error.message);
case PermissionsExceptionCode.DEFAULT_ROLE_NOT_FOUND:
default:
throw new InternalServerError(error.message);
}