[Permissions] Add userWorkspaceId to JWT token (#9954)

This information will be used to fetch a user's role and check their
permissions
This commit is contained in:
Marie
2025-01-31 18:15:29 +01:00
committed by GitHub
parent f00e7cc670
commit 58aa86cc0c
11 changed files with 124 additions and 7 deletions

View File

@ -10,5 +10,6 @@ declare module 'express-serve-static-core' {
workspaceId?: string; workspaceId?: string;
workspaceMetadataVersion?: number; workspaceMetadataVersion?: number;
workspaceMemberId?: string; workspaceMemberId?: string;
userWorkspaceId?: string;
} }
} }

View File

@ -8,6 +8,7 @@ export class AuthException extends CustomException {
export enum AuthExceptionCode { export enum AuthExceptionCode {
USER_NOT_FOUND = 'USER_NOT_FOUND', USER_NOT_FOUND = 'USER_NOT_FOUND',
USER_WORKSPACE_NOT_FOUND = 'USER_WORKSPACE_NOT_FOUND',
EMAIL_NOT_VERIFIED = 'EMAIL_NOT_VERIFIED', EMAIL_NOT_VERIFIED = 'EMAIL_NOT_VERIFIED',
CLIENT_NOT_FOUND = 'CLIENT_NOT_FOUND', CLIENT_NOT_FOUND = 'CLIENT_NOT_FOUND',
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND', WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',

View File

@ -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 { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; 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 { 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 { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity'; 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 { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.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 { 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 { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { UserModule } from 'src/engine/core-modules/user/user.module'; 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 { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module'; import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.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'; import { AuthResolver } from './auth.resolver';
@ -70,6 +71,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
FeatureFlag, FeatureFlag,
WorkspaceSSOIdentityProvider, WorkspaceSSOIdentityProvider,
KeyValuePair, KeyValuePair,
UserWorkspace,
], ],
'core', 'core',
), ),

View File

@ -3,6 +3,7 @@ import {
AuthExceptionCode, AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception'; } from 'src/engine/core-modules/auth/auth.exception';
import { JwtPayload } from 'src/engine/core-modules/auth/types/auth-context.type'; 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 { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { JwtAuthStrategy } from './jwt.auth.strategy'; import { JwtAuthStrategy } from './jwt.auth.strategy';
@ -11,6 +12,7 @@ describe('JwtAuthStrategy', () => {
let strategy: JwtAuthStrategy; let strategy: JwtAuthStrategy;
let workspaceRepository: any; let workspaceRepository: any;
let userWorkspaceRepository: any;
let userRepository: any; let userRepository: any;
let dataSourceService: any; let dataSourceService: any;
let typeORMService: any; let typeORMService: any;
@ -27,6 +29,10 @@ describe('JwtAuthStrategy', () => {
findOne: jest.fn(async () => null), findOne: jest.fn(async () => null),
}; };
userWorkspaceRepository = {
findOne: jest.fn(async () => new UserWorkspace()),
};
// first we test the API_KEY case // first we test the API_KEY case
it('should throw AuthException if type is API_KEY and workspace is not found', async () => { it('should throw AuthException if type is API_KEY and workspace is not found', async () => {
const payload = { const payload = {
@ -45,6 +51,7 @@ describe('JwtAuthStrategy', () => {
dataSourceService, dataSourceService,
workspaceRepository, workspaceRepository,
{} as any, {} as any,
userWorkspaceRepository,
); );
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
@ -80,6 +87,7 @@ describe('JwtAuthStrategy', () => {
dataSourceService, dataSourceService,
workspaceRepository, workspaceRepository,
{} as any, {} as any,
userWorkspaceRepository,
); );
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
@ -117,6 +125,7 @@ describe('JwtAuthStrategy', () => {
dataSourceService, dataSourceService,
workspaceRepository, workspaceRepository,
{} as any, {} as any,
userWorkspaceRepository,
); );
const result = await strategy.validate(payload as JwtPayload); const result = await strategy.validate(payload as JwtPayload);
@ -148,6 +157,7 @@ describe('JwtAuthStrategy', () => {
dataSourceService, dataSourceService,
workspaceRepository, workspaceRepository,
userRepository, userRepository,
userWorkspaceRepository,
); );
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( 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 = { const payload = {
sub: 'sub-default', sub: 'sub-default',
type: 'ACCESS', type: 'ACCESS',
@ -174,6 +184,10 @@ describe('JwtAuthStrategy', () => {
findOne: jest.fn(async () => ({ lastName: 'lastNameDefault' })), findOne: jest.fn(async () => ({ lastName: 'lastNameDefault' })),
}; };
userWorkspaceRepository = {
findOne: jest.fn(async () => null),
};
strategy = new JwtAuthStrategy( strategy = new JwtAuthStrategy(
{} as any, {} as any,
{} as any, {} as any,
@ -181,10 +195,52 @@ describe('JwtAuthStrategy', () => {
dataSourceService, dataSourceService,
workspaceRepository, workspaceRepository,
userRepository, 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); const user = await strategy.validate(payload as JwtPayload);
expect(user.user?.lastName).toBe('lastNameDefault'); expect(user.user?.lastName).toBe('lastNameDefault');
expect(user.userWorkspaceId).toBe('userWorkspaceId');
}); });
}); });

View File

@ -16,6 +16,7 @@ import {
} from 'src/engine/core-modules/auth/types/auth-context.type'; } from 'src/engine/core-modules/auth/types/auth-context.type';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.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 { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; 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<Workspace>, private readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(User, 'core') @InjectRepository(User, 'core')
private readonly userRepository: Repository<User>, private readonly userRepository: Repository<User>,
@InjectRepository(UserWorkspace, 'core')
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
) { ) {
super({ super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 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<AuthContext> { async validate(payload: JwtPayload): Promise<AuthContext> {

View File

@ -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 { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.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 { 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 { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@ -25,6 +26,7 @@ describe('AccessTokenService', () => {
let userRepository: Repository<User>; let userRepository: Repository<User>;
let workspaceRepository: Repository<Workspace>; let workspaceRepository: Repository<Workspace>;
let twentyORMGlobalManager: TwentyORMGlobalManager; let twentyORMGlobalManager: TwentyORMGlobalManager;
let userWorkspaceRepository: Repository<UserWorkspace>;
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
@ -63,6 +65,10 @@ describe('AccessTokenService', () => {
provide: getRepositoryToken(Workspace, 'core'), provide: getRepositoryToken(Workspace, 'core'),
useClass: Repository, useClass: Repository,
}, },
{
provide: getRepositoryToken(UserWorkspace, 'core'),
useClass: Repository,
},
{ {
provide: EmailService, provide: EmailService,
useValue: {}, useValue: {},
@ -92,6 +98,9 @@ describe('AccessTokenService', () => {
twentyORMGlobalManager = module.get<TwentyORMGlobalManager>( twentyORMGlobalManager = module.get<TwentyORMGlobalManager>(
TwentyORMGlobalManager, TwentyORMGlobalManager,
); );
userWorkspaceRepository = module.get<Repository<UserWorkspace>>(
getRepositoryToken(UserWorkspace, 'core'),
);
}); });
it('should be defined', () => { it('should be defined', () => {
@ -109,6 +118,7 @@ describe('AccessTokenService', () => {
activationStatus: WorkspaceActivationStatus.ACTIVE, activationStatus: WorkspaceActivationStatus.ACTIVE,
id: workspaceId, id: workspaceId,
}; };
const mockUserWorkspace = { id: 'userWorkspaceId' };
const mockWorkspaceMember = { id: 'workspace-member-id' }; const mockWorkspaceMember = { id: 'workspace-member-id' };
const mockToken = 'mock-token'; const mockToken = 'mock-token';
@ -117,6 +127,9 @@ describe('AccessTokenService', () => {
jest jest
.spyOn(workspaceRepository, 'findOne') .spyOn(workspaceRepository, 'findOne')
.mockResolvedValue(mockWorkspace as Workspace); .mockResolvedValue(mockWorkspace as Workspace);
jest
.spyOn(userWorkspaceRepository, 'findOne')
.mockResolvedValue(mockUserWorkspace as UserWorkspace);
jest jest
.spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace') .spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace')
.mockResolvedValue({ .mockResolvedValue({

View File

@ -20,6 +20,7 @@ import {
} from 'src/engine/core-modules/auth/types/auth-context.type'; } from 'src/engine/core-modules/auth/types/auth-context.type';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.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 { User } from 'src/engine/core-modules/user/user.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate'; import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@ -38,6 +39,8 @@ export class AccessTokenService {
@InjectRepository(Workspace, 'core') @InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>, private readonly workspaceRepository: Repository<Workspace>,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager, private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
@InjectRepository(UserWorkspace, 'core')
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
) {} ) {}
async generateAccessToken( async generateAccessToken(
@ -87,11 +90,18 @@ export class AccessTokenService {
tokenWorkspaceMemberId = workspaceMember.id; tokenWorkspaceMemberId = workspaceMember.id;
} }
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: {
userId: user.id,
workspaceId,
},
});
const jwtPayload: JwtPayload = { const jwtPayload: JwtPayload = {
sub: user.id, sub: user.id,
workspaceId, workspaceId,
workspaceMemberId: tokenWorkspaceMemberId, workspaceMemberId: tokenWorkspaceMemberId,
userWorkspaceId: userWorkspace?.id,
}; };
return { return {
@ -108,10 +118,10 @@ export class AccessTokenService {
const decoded = await this.jwtWrapperService.decode(token); 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); await this.jwtStrategy.validate(decoded as JwtPayload);
return { user, apiKey, workspace, workspaceMemberId }; return { user, apiKey, workspace, workspaceMemberId, userWorkspaceId };
} }
async validateTokenByRequest(request: Request): Promise<AuthContext> { async validateTokenByRequest(request: Request): Promise<AuthContext> {

View File

@ -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 { EmailModule } from 'src/engine/core-modules/email/email.module';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.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 { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; 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({ @Module({
imports: [ imports: [
JwtModule, JwtModule,
TypeOrmModule.forFeature([User, AppToken, Workspace], 'core'), TypeOrmModule.forFeature(
[User, AppToken, Workspace, UserWorkspace],
'core',
),
TypeORMModule, TypeORMModule,
DataSourceModule, DataSourceModule,
EmailModule, EmailModule,

View File

@ -8,6 +8,7 @@ export type AuthContext = {
apiKey?: ApiKeyWorkspaceEntity | null | undefined; apiKey?: ApiKeyWorkspaceEntity | null | undefined;
workspaceMemberId?: string; workspaceMemberId?: string;
workspace: Workspace; workspace: Workspace;
userWorkspaceId?: string;
}; };
export type JwtPayload = { export type JwtPayload = {
@ -16,4 +17,5 @@ export type JwtPayload = {
workspaceMemberId?: string; workspaceMemberId?: string;
jti?: string; jti?: string;
type?: WorkspaceTokenType; type?: WorkspaceTokenType;
userWorkspaceId?: string;
}; };

View File

@ -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;
},
);

View File

@ -27,6 +27,7 @@ export class JwtAuthGuard implements CanActivate {
request.workspaceId = data.workspace.id; request.workspaceId = data.workspace.id;
request.workspaceMetadataVersion = metadataVersion; request.workspaceMetadataVersion = metadataVersion;
request.workspaceMemberId = data.workspaceMemberId; request.workspaceMemberId = data.workspaceMemberId;
request.userWorkspaceId = data.userWorkspaceId;
return true; return true;
} catch (error) { } catch (error) {