[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;
workspaceMetadataVersion?: number;
workspaceMemberId?: string;
userWorkspaceId?: string;
}
}

View File

@ -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',

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 { 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',
),

View File

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

View File

@ -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<Workspace>,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
@InjectRepository(UserWorkspace, 'core')
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
) {
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<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 { 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<User>;
let workspaceRepository: Repository<Workspace>;
let twentyORMGlobalManager: TwentyORMGlobalManager;
let userWorkspaceRepository: Repository<UserWorkspace>;
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>(
TwentyORMGlobalManager,
);
userWorkspaceRepository = module.get<Repository<UserWorkspace>>(
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({

View File

@ -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<Workspace>,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
@InjectRepository(UserWorkspace, 'core')
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
) {}
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<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 { 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,

View File

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

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.workspaceMetadataVersion = metadataVersion;
request.workspaceMemberId = data.workspaceMemberId;
request.userWorkspaceId = data.userWorkspaceId;
return true;
} catch (error) {