diff --git a/api/src/chat/services/message.service.spec.ts b/api/src/chat/services/message.service.spec.ts index 7c4ee322b..555f370a7 100644 --- a/api/src/chat/services/message.service.spec.ts +++ b/api/src/chat/services/message.service.spec.ts @@ -21,6 +21,8 @@ import { import { buildTestingMocks } from '@/utils/test/utils'; import { IOOutgoingSubscribeMessage } from '@/websocket/pipes/io-message.pipe'; import { Room } from '@/websocket/types'; +import { SocketRequest } from '@/websocket/utils/socket-request'; +import { SocketResponse } from '@/websocket/utils/socket-response'; import { WebsocketGateway } from '@/websocket/websocket.gateway'; import { MessageRepository } from '../repositories/message.repository'; @@ -50,9 +52,14 @@ describe('MessageService', () => { success: true, subscribe: Room.MESSAGE, }; + let buildReqRes: ( + method: 'GET' | 'POST', + subscriberId: string, + ) => [SocketRequest, SocketResponse]; beforeAll(async () => { const { getMocks } = await buildTestingMocks({ + models: ['RoleModel', 'ModelModel'], autoInjectFrom: ['providers'], imports: [rootMongooseTestModule(installMessageFixtures)], providers: [MessageService, SubscriberRepository, UserRepository], @@ -81,24 +88,44 @@ describe('MessageService', () => { joinNotificationSockets: jest.fn(), }; mockMessageService = new MessageService({} as any, mockGateway as any); + buildReqRes = (method: 'GET' | 'POST', userId: string) => [ + { + sessionID: SESSION_ID, + method, + session: { passport: { user: { id: userId } } }, + } as SocketRequest, + { + json: jest.fn(), + status: jest.fn().mockReturnThis(), + } as any, + ]; }); afterEach(jest.clearAllMocks); afterAll(closeInMongodConnection); describe('subscribe', () => { - it('should join Notification sockets message room and return a success response', async () => { - const req = { sessionID: SESSION_ID }; - const res = { - json: jest.fn(), - status: jest.fn().mockReturnThis(), - }; + it('should join Notification sockets GET message room and return a success response', async () => { + const [req, res] = buildReqRes('GET', allUsers[0].id); + await mockMessageService.subscribe(req, res); + + expect(mockGateway.joinNotificationSockets).toHaveBeenCalledWith( + req, + Room.MESSAGE, + 'message', + ); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(SUCCESS_PAYLOAD); + }); - await mockMessageService.subscribe(req as any, res as any); + it('should join Notification sockets POST message room and return a success response', async () => { + const [req, res] = buildReqRes('POST', allUsers[0].id); + await mockMessageService.subscribe(req, res); expect(mockGateway.joinNotificationSockets).toHaveBeenCalledWith( - SESSION_ID, + req, Room.MESSAGE, + 'message', ); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith(SUCCESS_PAYLOAD); diff --git a/api/src/chat/services/message.service.ts b/api/src/chat/services/message.service.ts index 3e6be0428..4f59fc9e6 100644 --- a/api/src/chat/services/message.service.ts +++ b/api/src/chat/services/message.service.ts @@ -52,15 +52,15 @@ export class MessageService extends BaseService< @SocketRes() res: SocketResponse, ): Promise { try { - await this.gateway.joinNotificationSockets(req.sessionID, Room.MESSAGE); + await this.gateway.joinNotificationSockets(req, Room.MESSAGE, 'message'); return res.status(200).json({ success: true, subscribe: Room.MESSAGE, }); - } catch (e) { - this.logger.error('Websocket subscription', e); - throw new InternalServerErrorException(e); + } catch (err) { + this.logger.error('Websocket message room subscription error', err); + throw new InternalServerErrorException(err); } } diff --git a/api/src/chat/services/subscriber.service.spec.ts b/api/src/chat/services/subscriber.service.spec.ts index 4d995ad82..db8ec42f6 100644 --- a/api/src/chat/services/subscriber.service.spec.ts +++ b/api/src/chat/services/subscriber.service.spec.ts @@ -28,6 +28,8 @@ import { import { buildTestingMocks } from '@/utils/test/utils'; import { IOOutgoingSubscribeMessage } from '@/websocket/pipes/io-message.pipe'; import { Room } from '@/websocket/types'; +import { SocketRequest } from '@/websocket/utils/socket-request'; +import { SocketResponse } from '@/websocket/utils/socket-response'; import { WebsocketGateway } from '@/websocket/websocket.gateway'; import { LabelRepository } from '../repositories/label.repository'; @@ -55,9 +57,14 @@ describe('SubscriberService', () => { success: true, subscribe: Room.SUBSCRIBER, }; + let buildReqRes: ( + method: 'GET' | 'POST', + subscriberId: string, + ) => [SocketRequest, SocketResponse]; beforeAll(async () => { const { getMocks } = await buildTestingMocks({ + models: ['RoleModel', 'ModelModel'], autoInjectFrom: ['providers'], imports: [rootMongooseTestModule(installSubscriberFixtures)], providers: [SubscriberService, LabelRepository, UserRepository], @@ -82,28 +89,48 @@ describe('SubscriberService', () => { joinNotificationSockets: jest.fn(), }; mockSubscriberService = new SubscriberService( - {} as any, + subscriberRepository, {} as any, mockGateway as any, ); + buildReqRes = (method: 'GET' | 'POST', userId: string) => [ + { + sessionID: SESSION_ID, + method, + session: { passport: { user: { id: userId } } }, + } as SocketRequest, + { + json: jest.fn(), + status: jest.fn().mockReturnThis(), + } as any, + ]; }); afterEach(jest.clearAllMocks); afterAll(closeInMongodConnection); describe('subscribe', () => { - it('should join Notification sockets subscriber room and return a success response', async () => { - const req = { sessionID: SESSION_ID }; - const res = { - json: jest.fn(), - status: jest.fn().mockReturnThis(), - }; + it('should join Notification sockets GET subscriber room and return a success response', async () => { + const [req, res] = buildReqRes('GET', allUsers[0].id); + await mockSubscriberService.subscribe(req, res); + + expect(mockGateway.joinNotificationSockets).toHaveBeenCalledWith( + req, + Room.SUBSCRIBER, + 'subscriber', + ); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(SUCCESS_PAYLOAD); + }); - await mockSubscriberService.subscribe(req as any, res as any); + it('should join Notification sockets POST subscriber room and return a success response', async () => { + const [req, res] = buildReqRes('POST', allUsers[0].id); + await mockSubscriberService.subscribe(req, res); expect(mockGateway.joinNotificationSockets).toHaveBeenCalledWith( - SESSION_ID, + req, Room.SUBSCRIBER, + 'subscriber', ); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith(SUCCESS_PAYLOAD); diff --git a/api/src/chat/services/subscriber.service.ts b/api/src/chat/services/subscriber.service.ts index 75121c9d4..0f08d98cc 100644 --- a/api/src/chat/services/subscriber.service.ts +++ b/api/src/chat/services/subscriber.service.ts @@ -72,17 +72,18 @@ export class SubscriberService extends BaseService< ): Promise { try { await this.gateway.joinNotificationSockets( - req.sessionID, + req, Room.SUBSCRIBER, + 'subscriber', ); return res.status(200).json({ success: true, subscribe: Room.SUBSCRIBER, }); - } catch (e) { - this.logger.error('Websocket subscription'); - throw new InternalServerErrorException(e); + } catch (err) { + this.logger.error('Websocket subscriber room subscription error', err); + throw new InternalServerErrorException(err); } } diff --git a/api/src/user/services/permission.service.ts b/api/src/user/services/permission.service.ts index f8b68349b..d8dee010a 100644 --- a/api/src/user/services/permission.service.ts +++ b/api/src/user/services/permission.service.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Hexastack. All rights reserved. + * Copyright © 2025 Hexastack. All rights reserved. * * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. @@ -22,6 +22,8 @@ import { PermissionFull, PermissionPopulate, } from '../schemas/permission.schema'; +import { MethodToAction } from '../types/action.type'; +import { TModel } from '../types/model.type'; import { PermissionsTree } from '../types/permission.type'; @Injectable() @@ -80,4 +82,41 @@ export class PermissionService extends BaseService< return acc; }, {}); } + + /** + * Checks if a request (REST/WebSocket) is authorized to get access + * + * @param method - The Request Method + * @param userRoles - An array of ID's user Roles or empty + * @param targetModel - The target model that we want access + * @returns + */ + async canAccess( + method: string, + userRoles: string[], + targetModel: TModel, + ): Promise { + try { + const permissions = await this.getPermissions(); + + if (permissions && userRoles.length) { + const permissionsFromRoles = Object.entries(permissions) + .filter(([key, _]) => userRoles.includes(key)) + .map(([_, value]) => value); + + if ( + permissionsFromRoles.some((permission) => + permission[targetModel]?.includes(MethodToAction[method]), + ) + ) { + return true; + } + } + } catch (e) { + this.logger.error('Request has no ability to get access', e); + return false; + } + + return false; + } } diff --git a/api/src/utils/test/fixtures/model.ts b/api/src/utils/test/fixtures/model.ts index 97ebc1544..60ab38aa0 100644 --- a/api/src/utils/test/fixtures/model.ts +++ b/api/src/utils/test/fixtures/model.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Hexastack. All rights reserved. + * Copyright © 2025 Hexastack. All rights reserved. * * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. @@ -18,13 +18,24 @@ export const modelFixtures: ModelCreateDto[] = [ attributes: { att: 'att' }, relation: 'role', }, - { name: 'Content', identity: 'content', attributes: { att: 'att' }, relation: 'role', }, + { + name: 'Message', + identity: 'message', + attributes: { att: 'att' }, + relation: 'role', + }, + { + name: 'Subscriber', + identity: 'subscriber', + attributes: { att: 'att' }, + relation: 'role', + }, ]; export const installModelFixtures = async () => { diff --git a/api/src/utils/test/fixtures/permission.ts b/api/src/utils/test/fixtures/permission.ts index ecb2109e7..e345c70a2 100644 --- a/api/src/utils/test/fixtures/permission.ts +++ b/api/src/utils/test/fixtures/permission.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Hexastack. All rights reserved. + * Copyright © 2025 Hexastack. All rights reserved. * * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. @@ -40,6 +40,30 @@ export const permissionFixtures: PermissionCreateDto[] = [ role: '0', relation: 'role', }, + { + model: '2', + action: Action.CREATE, + role: '1', + relation: 'role', + }, + { + model: '2', + action: Action.READ, + role: '1', + relation: 'role', + }, + { + model: '3', + action: Action.CREATE, + role: '1', + relation: 'role', + }, + { + model: '3', + action: Action.READ, + role: '1', + relation: 'role', + }, ]; export const installPermissionFixtures = async () => { diff --git a/api/src/utils/test/fixtures/subscriber.ts b/api/src/utils/test/fixtures/subscriber.ts index 0e73ae737..eb7baa58f 100644 --- a/api/src/utils/test/fixtures/subscriber.ts +++ b/api/src/utils/test/fixtures/subscriber.ts @@ -15,7 +15,7 @@ import { getFixturesWithDefaultValues } from '../defaultValues'; import { FixturesTypeBuilder } from '../types'; import { installLabelFixtures } from './label'; -import { installUserFixtures } from './user'; +import { installPermissionFixtures } from './permission'; type TSubscriberFixtures = FixturesTypeBuilder; @@ -106,7 +106,7 @@ export const installSubscriberFixtures = async () => { SubscriberModel.name, SubscriberModel.schema, ); - const { users } = await installUserFixtures(); + const { users } = await installPermissionFixtures(); const labels = await installLabelFixtures(); const subscribers = await Subscriber.insertMany( subscriberFixtures.map((subscriberFixture) => ({ diff --git a/api/src/websocket/websocket.gateway.spec.ts b/api/src/websocket/websocket.gateway.spec.ts index 1ba68cdd0..6ea2d15be 100644 --- a/api/src/websocket/websocket.gateway.spec.ts +++ b/api/src/websocket/websocket.gateway.spec.ts @@ -10,6 +10,9 @@ import { INestApplication } from '@nestjs/common'; import { Socket, io } from 'socket.io-client'; import { v4 as uuidv4 } from 'uuid'; +import { UserRepository } from '@/user/repositories/user.repository'; +import { User } from '@/user/schemas/user.schema'; +import { installSubscriberFixtures } from '@/utils/test/fixtures/subscriber'; import { closeInMongodConnection, rootMongooseTestModule, @@ -18,6 +21,8 @@ import { buildTestingMocks } from '@/utils/test/utils'; import { SocketEventDispatcherService } from './services/socket-event-dispatcher.service'; import { Room } from './types'; +import { SocketRequest } from './utils/socket-request'; +import { SocketResponse } from './utils/socket-response'; import { WebsocketGateway } from './websocket.gateway'; describe('WebsocketGateway', () => { @@ -29,18 +34,27 @@ describe('WebsocketGateway', () => { let messageRoomSockets: Socket[]; let uuids: string[]; let validUuids: string[]; + let allUsers: User[]; + let userRepository: UserRepository; + const SESSION_ID = 'sessionId'; + let buildReqRes: ( + method: 'GET' | 'POST', + subscriberId: string, + ) => [SocketRequest, SocketResponse]; beforeAll(async () => { // Instantiate the app - const { module } = await buildTestingMocks({ + const { getMocks, module } = await buildTestingMocks({ providers: [WebsocketGateway, SocketEventDispatcherService], imports: [ - rootMongooseTestModule(({ uri, dbName }) => { + rootMongooseTestModule(async ({ uri, dbName }) => { + await installSubscriberFixtures(); process.env.MONGO_URI = uri; process.env.MONGO_DB = dbName; return Promise.resolve(); }), ], + autoInjectFrom: ['providers'], }); app = module.createNestApplication(); // Get the gateway instance from the app instance @@ -65,6 +79,21 @@ describe('WebsocketGateway', () => { ); await app.listen(3000); + + [userRepository] = await getMocks([UserRepository]); + allUsers = await userRepository.findAll(); + + buildReqRes = (method: 'GET' | 'POST', userId: string) => [ + { + sessionID: SESSION_ID, + method, + session: { passport: { user: { id: userId } } }, + } as SocketRequest, + { + json: jest.fn(), + status: jest.fn().mockReturnThis(), + } as any, + ]; }); afterAll(async () => { @@ -146,17 +175,20 @@ describe('WebsocketGateway', () => { it('should throw an error when socket array is empty', async () => { jest.spyOn(gateway, 'getNotificationSockets').mockResolvedValueOnce([]); + const [req] = buildReqRes('GET', allUsers[0].id); await expect( - gateway.joinNotificationSockets('sessionId', Room.MESSAGE), + gateway.joinNotificationSockets(req, Room.MESSAGE, 'message'), ).rejects.toThrow('No notification sockets found!'); expect(gateway.getNotificationSockets).toHaveBeenCalledWith('sessionId'); }); it('should throw an error with empty sessionId', async () => { + const [req] = buildReqRes('GET', allUsers[0].id); + req.sessionID = ''; await expect( - gateway.joinNotificationSockets('', Room.MESSAGE), + gateway.joinNotificationSockets(req, Room.MESSAGE, 'message'), ).rejects.toThrow('SessionId is required!'); }); }); diff --git a/api/src/websocket/websocket.gateway.ts b/api/src/websocket/websocket.gateway.ts index d1cf5af6b..cad689e4b 100644 --- a/api/src/websocket/websocket.gateway.ts +++ b/api/src/websocket/websocket.gateway.ts @@ -25,6 +25,7 @@ import { RemoteSocket, Server, Socket } from 'socket.io'; import { DefaultEventsMap } from 'socket.io/dist/typed-events'; import { sync as uid } from 'uid-safe'; +import { AuthorizationService } from '@/authorization/authorization.service'; import { MessageFull } from '@/chat/schemas/message.schema'; import { Subscriber, @@ -34,6 +35,7 @@ import { import { OutgoingMessage, StdEventType } from '@/chat/schemas/types/message'; import { config } from '@/config'; import { LoggerService } from '@/logger/logger.service'; +import { TModel } from '@/user/types/model.type'; import { getSessionStore } from '@/utils/constants/session-store'; import { IOIncomingMessage, IOMessagePipe } from './pipes/io-message.pipe'; @@ -51,6 +53,7 @@ export class WebsocketGateway private readonly logger: LoggerService, private readonly eventEmitter: EventEmitter2, private readonly socketEventDispatcherService: SocketEventDispatcherService, + private readonly authorizationService: AuthorizationService, ) {} @WebSocketServer() io: Server; @@ -449,7 +452,12 @@ export class WebsocketGateway * @param sessionId - The session id * @param room - the joined room name */ - async joinNotificationSockets(sessionId: string, room: Room): Promise { + async joinNotificationSockets( + req: SocketRequest, + room: Room, + targetModel: TModel, + ): Promise { + const sessionId = req.sessionID; if (!sessionId) { throw new Error('SessionId is required!'); } @@ -460,6 +468,28 @@ export class WebsocketGateway throw new Error('No notification sockets found!'); } + const method = req?.method.toUpperCase(); + + if (!method) { + throw new Error('Method is required!'); + } + + const userId = req?.session.passport?.user?.id; + + if (!userId) { + throw new Error('UserId is required!'); + } + + const hasAccess = await this.authorizationService.canAccess( + method, + userId, + targetModel, + ); + + if (!hasAccess) { + throw new Error('Not able to access!'); + } + notificationSockets.forEach((notificationSocket) => notificationSocket.join(room), );