diff --git a/packages/twenty-server/@types/express.d.ts b/packages/twenty-server/@types/express.d.ts index f30a99a19..6bf3987b0 100644 --- a/packages/twenty-server/@types/express.d.ts +++ b/packages/twenty-server/@types/express.d.ts @@ -10,5 +10,6 @@ declare module 'express-serve-static-core' { workspaceId?: string; workspaceMetadataVersion?: number; workspaceMemberId?: string; + userWorkspaceId?: string; } } diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.exception.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.exception.ts index e5d4e03a5..ef6d2f3e9 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.exception.ts @@ -8,6 +8,7 @@ export class AuthException extends CustomException { export enum AuthExceptionCode { USER_NOT_FOUND = 'USER_NOT_FOUND', + USER_WORKSPACE_NOT_FOUND = 'USER_WORKSPACE_NOT_FOUND', EMAIL_NOT_VERIFIED = 'EMAIL_NOT_VERIFIED', CLIENT_NOT_FOUND = 'CLIENT_NOT_FOUND', WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND', diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts index eb6fa57db..19516d046 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts @@ -29,11 +29,13 @@ import { EmailVerificationModule } from 'src/engine/core-modules/email-verificat import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module'; +import { GuardRedirectModule } from 'src/engine/core-modules/guard-redirect/guard-redirect.module'; import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity'; import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module'; import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module'; import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; +import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; import { User } from 'src/engine/core-modules/user/user.entity'; import { UserModule } from 'src/engine/core-modules/user/user.module'; @@ -45,7 +47,6 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module'; import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module'; -import { GuardRedirectModule } from 'src/engine/core-modules/guard-redirect/guard-redirect.module'; import { AuthResolver } from './auth.resolver'; @@ -70,6 +71,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy'; FeatureFlag, WorkspaceSSOIdentityProvider, KeyValuePair, + UserWorkspace, ], 'core', ), diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts index 168e1c574..192fbe176 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts @@ -3,6 +3,7 @@ import { AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { JwtPayload } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { JwtAuthStrategy } from './jwt.auth.strategy'; @@ -11,6 +12,7 @@ describe('JwtAuthStrategy', () => { let strategy: JwtAuthStrategy; let workspaceRepository: any; + let userWorkspaceRepository: any; let userRepository: any; let dataSourceService: any; let typeORMService: any; @@ -27,6 +29,10 @@ describe('JwtAuthStrategy', () => { findOne: jest.fn(async () => null), }; + userWorkspaceRepository = { + findOne: jest.fn(async () => new UserWorkspace()), + }; + // first we test the API_KEY case it('should throw AuthException if type is API_KEY and workspace is not found', async () => { const payload = { @@ -45,6 +51,7 @@ describe('JwtAuthStrategy', () => { dataSourceService, workspaceRepository, {} as any, + userWorkspaceRepository, ); await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( @@ -80,6 +87,7 @@ describe('JwtAuthStrategy', () => { dataSourceService, workspaceRepository, {} as any, + userWorkspaceRepository, ); await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( @@ -117,6 +125,7 @@ describe('JwtAuthStrategy', () => { dataSourceService, workspaceRepository, {} as any, + userWorkspaceRepository, ); const result = await strategy.validate(payload as JwtPayload); @@ -148,6 +157,7 @@ describe('JwtAuthStrategy', () => { dataSourceService, workspaceRepository, userRepository, + userWorkspaceRepository, ); await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( @@ -160,7 +170,7 @@ describe('JwtAuthStrategy', () => { } }); - it('should be truthy if type is ACCESS, no jti, and user exist', async () => { + it('should throw AuthExceptionCode if type is ACCESS, no jti, and userWorkspace not found', async () => { const payload = { sub: 'sub-default', type: 'ACCESS', @@ -174,6 +184,10 @@ describe('JwtAuthStrategy', () => { findOne: jest.fn(async () => ({ lastName: 'lastNameDefault' })), }; + userWorkspaceRepository = { + findOne: jest.fn(async () => null), + }; + strategy = new JwtAuthStrategy( {} as any, {} as any, @@ -181,10 +195,52 @@ describe('JwtAuthStrategy', () => { dataSourceService, workspaceRepository, userRepository, + userWorkspaceRepository, + ); + + await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( + new AuthException('UserWorkspace not found', expect.any(String)), + ); + try { + await strategy.validate(payload as JwtPayload); + } catch (e) { + expect(e.code).toBe(AuthExceptionCode.USER_WORKSPACE_NOT_FOUND); + } + }); + + it('should be truthy if type is ACCESS, no jti, and user and userWorkspace exist', async () => { + const payload = { + sub: 'sub-default', + type: 'ACCESS', + }; + + workspaceRepository = { + findOneBy: jest.fn(async () => new Workspace()), + }; + + userRepository = { + findOne: jest.fn(async () => ({ lastName: 'lastNameDefault' })), + }; + + userWorkspaceRepository = { + findOne: jest.fn(async () => ({ + id: 'userWorkspaceId', + })), + }; + + strategy = new JwtAuthStrategy( + {} as any, + {} as any, + typeORMService, + dataSourceService, + workspaceRepository, + userRepository, + userWorkspaceRepository, ); const user = await strategy.validate(payload as JwtPayload); expect(user.user?.lastName).toBe('lastNameDefault'); + expect(user.userWorkspaceId).toBe('userWorkspaceId'); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts index ae6c99061..d72cadb88 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts @@ -16,6 +16,7 @@ import { } from 'src/engine/core-modules/auth/types/auth-context.type'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; @@ -32,6 +33,8 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { private readonly workspaceRepository: Repository, @InjectRepository(User, 'core') private readonly userRepository: Repository, + @InjectRepository(UserWorkspace, 'core') + private readonly userWorkspaceRepository: Repository, ) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), @@ -117,7 +120,20 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { ); } - return { user, workspace }; + const userWorkspace = await this.userWorkspaceRepository.findOne({ + where: { + id: payload.userWorkspaceId, + }, + }); + + if (!userWorkspace) { + throw new AuthException( + 'UserWorkspace not found', + AuthExceptionCode.USER_WORKSPACE_NOT_FOUND, + ); + } + + return { user, workspace, userWorkspaceId: userWorkspace.id }; } async validate(payload: JwtPayload): Promise { diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts index 43438bb5d..29aa566c2 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.spec.ts @@ -12,6 +12,7 @@ import { EmailService } from 'src/engine/core-modules/email/email.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; +import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; @@ -25,6 +26,7 @@ describe('AccessTokenService', () => { let userRepository: Repository; let workspaceRepository: Repository; let twentyORMGlobalManager: TwentyORMGlobalManager; + let userWorkspaceRepository: Repository; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -63,6 +65,10 @@ describe('AccessTokenService', () => { provide: getRepositoryToken(Workspace, 'core'), useClass: Repository, }, + { + provide: getRepositoryToken(UserWorkspace, 'core'), + useClass: Repository, + }, { provide: EmailService, useValue: {}, @@ -92,6 +98,9 @@ describe('AccessTokenService', () => { twentyORMGlobalManager = module.get( TwentyORMGlobalManager, ); + userWorkspaceRepository = module.get>( + getRepositoryToken(UserWorkspace, 'core'), + ); }); it('should be defined', () => { @@ -109,6 +118,7 @@ describe('AccessTokenService', () => { activationStatus: WorkspaceActivationStatus.ACTIVE, id: workspaceId, }; + const mockUserWorkspace = { id: 'userWorkspaceId' }; const mockWorkspaceMember = { id: 'workspace-member-id' }; const mockToken = 'mock-token'; @@ -117,6 +127,9 @@ describe('AccessTokenService', () => { jest .spyOn(workspaceRepository, 'findOne') .mockResolvedValue(mockWorkspace as Workspace); + jest + .spyOn(userWorkspaceRepository, 'findOne') + .mockResolvedValue(mockUserWorkspace as UserWorkspace); jest .spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace') .mockResolvedValue({ diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts index 09dc96cd6..59f147d43 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts @@ -20,6 +20,7 @@ import { } from 'src/engine/core-modules/auth/types/auth-context.type'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { User } from 'src/engine/core-modules/user/user.entity'; import { userValidator } from 'src/engine/core-modules/user/user.validate'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -38,6 +39,8 @@ export class AccessTokenService { @InjectRepository(Workspace, 'core') private readonly workspaceRepository: Repository, private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + @InjectRepository(UserWorkspace, 'core') + private readonly userWorkspaceRepository: Repository, ) {} async generateAccessToken( @@ -87,11 +90,18 @@ export class AccessTokenService { tokenWorkspaceMemberId = workspaceMember.id; } + const userWorkspace = await this.userWorkspaceRepository.findOne({ + where: { + userId: user.id, + workspaceId, + }, + }); const jwtPayload: JwtPayload = { sub: user.id, workspaceId, workspaceMemberId: tokenWorkspaceMemberId, + userWorkspaceId: userWorkspace?.id, }; return { @@ -108,10 +118,10 @@ export class AccessTokenService { const decoded = await this.jwtWrapperService.decode(token); - const { user, apiKey, workspace, workspaceMemberId } = + const { user, apiKey, workspace, workspaceMemberId, userWorkspaceId } = await this.jwtStrategy.validate(decoded as JwtPayload); - return { user, apiKey, workspace, workspaceMemberId }; + return { user, apiKey, workspace, workspaceMemberId, userWorkspaceId }; } async validateTokenByRequest(request: Request): Promise { diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts b/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts index 2ad512b8b..79a9ccce5 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts @@ -12,15 +12,19 @@ import { RenewTokenService } from 'src/engine/core-modules/auth/token/services/r import { EmailModule } from 'src/engine/core-modules/email/email.module'; import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module'; +import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; -import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; @Module({ imports: [ JwtModule, - TypeOrmModule.forFeature([User, AppToken, Workspace], 'core'), + TypeOrmModule.forFeature( + [User, AppToken, Workspace, UserWorkspace], + 'core', + ), TypeORMModule, DataSourceModule, EmailModule, diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts index 5bbcfbca6..9174f7cf0 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts @@ -8,6 +8,7 @@ export type AuthContext = { apiKey?: ApiKeyWorkspaceEntity | null | undefined; workspaceMemberId?: string; workspace: Workspace; + userWorkspaceId?: string; }; export type JwtPayload = { @@ -16,4 +17,5 @@ export type JwtPayload = { workspaceMemberId?: string; jti?: string; type?: WorkspaceTokenType; + userWorkspaceId?: string; }; diff --git a/packages/twenty-server/src/engine/decorators/auth/auth-user-workspace-id.decorator.ts b/packages/twenty-server/src/engine/decorators/auth/auth-user-workspace-id.decorator.ts new file mode 100644 index 000000000..7e7b3347b --- /dev/null +++ b/packages/twenty-server/src/engine/decorators/auth/auth-user-workspace-id.decorator.ts @@ -0,0 +1,11 @@ +import { ExecutionContext, createParamDecorator } from '@nestjs/common'; + +import { getRequest } from 'src/utils/extract-request'; + +export const AuthUserWorkspaceId = createParamDecorator( + (data: unknown, ctx: ExecutionContext) => { + const request = getRequest(ctx); + + return request.userWorkspaceId; + }, +); diff --git a/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts b/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts index fb7edf644..fb451e3d5 100644 --- a/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts +++ b/packages/twenty-server/src/engine/guards/jwt-auth.guard.ts @@ -27,6 +27,7 @@ export class JwtAuthGuard implements CanActivate { request.workspaceId = data.workspace.id; request.workspaceMetadataVersion = metadataVersion; request.workspaceMemberId = data.workspaceMemberId; + request.userWorkspaceId = data.userWorkspaceId; return true; } catch (error) {