From b8507b824bb75aaa12e3ee796378c02a5e582d17 Mon Sep 17 00:00:00 2001 From: juancarmore Date: Fri, 9 Jan 2026 11:26:14 +0100 Subject: [PATCH] backend: enhace get all rooms and recordings methods to filter results based on user's role and permissions --- .../src/controllers/recording.controller.ts | 11 +--- .../src/repositories/base.repository.ts | 15 ++++- .../src/repositories/recording.repository.ts | 9 ++- .../repositories/room-member.repository.ts | 60 ++++++++++++++++++- .../src/repositories/room.repository.ts | 27 ++++++++- .../backend/src/services/recording.service.ts | 60 +++++++++++++++++-- .../src/services/room-member.service.ts | 2 +- meet-ce/backend/src/services/room.service.ts | 43 +++++++++++-- 8 files changed, 200 insertions(+), 27 deletions(-) diff --git a/meet-ce/backend/src/controllers/recording.controller.ts b/meet-ce/backend/src/controllers/recording.controller.ts index 4f51e4d7..ccec2ffd 100644 --- a/meet-ce/backend/src/controllers/recording.controller.ts +++ b/meet-ce/backend/src/controllers/recording.controller.ts @@ -53,18 +53,9 @@ export const stopRecording = async (req: Request, res: Response) => { export const getRecordings = async (req: Request, res: Response) => { const logger = container.get(LoggerService); const recordingService = container.get(RecordingService); - const requestSessionService = container.get(RequestSessionService); const queryParams = req.query; - // If room member token is present, retrieve only recordings for the room associated with the token - const roomId = requestSessionService.getRoomIdFromMember(); - - if (roomId) { - queryParams.roomId = roomId; - logger.info(`Getting recordings for room '${roomId}'`); - } else { - logger.info('Getting all recordings'); - } + logger.info('Getting all recordings'); try { const { recordings, isTruncated, nextPageToken } = await recordingService.getAllRecordings(queryParams); diff --git a/meet-ce/backend/src/repositories/base.repository.ts b/meet-ce/backend/src/repositories/base.repository.ts index 1f0ef9c0..0ecf1cb6 100644 --- a/meet-ce/backend/src/repositories/base.repository.ts +++ b/meet-ce/backend/src/repositories/base.repository.ts @@ -60,13 +60,24 @@ export abstract class BaseRepository { * WARNING: Use with caution on large collections. Consider using findMany() with pagination instead. * * @param filter - Base MongoDB query filter + * @param fields - Optional comma-separated list of fields to select from database * @returns Array of domain objects matching the filter */ - protected async findAll(filter: FilterQuery = {}): Promise { + protected async findAll(filter: FilterQuery = {}, fields?: string): Promise { try { - const documents = await this.model.find(filter).exec(); + let query = this.model.find(filter); + + if (fields) { + const fieldSelection = fields + .split(',') + .map((field) => field.trim()) + .filter((field) => field !== '') + .join(' '); + query = query.select(fieldSelection); + } // Transform documents to domain objects + const documents = await query.exec(); return documents.map((doc) => this.toDomain(doc)); } catch (error) { this.logger.error('Error finding all documents with filter:', filter, error); diff --git a/meet-ce/backend/src/repositories/recording.repository.ts b/meet-ce/backend/src/repositories/recording.repository.ts index 7d5a4cea..afdef194 100644 --- a/meet-ce/backend/src/repositories/recording.repository.ts +++ b/meet-ce/backend/src/repositories/recording.repository.ts @@ -89,6 +89,7 @@ export class RecordingRepository { const { + roomIds, roomId, roomName, status, @@ -118,6 +120,11 @@ export class RecordingRepository = {}; + if (roomIds && roomIds.length > 0) { + // Filter by multiple room IDs + filter.roomId = { $in: roomIds }; + } + if (roomId && roomName) { // Both defined: OR filter with exact roomId match and regex roomName match filter.$or = [{ roomId }, { roomName: new RegExp(roomName, 'i') }]; diff --git a/meet-ce/backend/src/repositories/room-member.repository.ts b/meet-ce/backend/src/repositories/room-member.repository.ts index 1a9703cd..889340bc 100644 --- a/meet-ce/backend/src/repositories/room-member.repository.ts +++ b/meet-ce/backend/src/repositories/room-member.repository.ts @@ -113,11 +113,67 @@ export class RoomMemberRepository extends BaseRepository { - return await this.findAll({ memberId: { $in: memberIds } }); + async findByRoomAndMemberIds(roomId: string, memberIds: string[], fields?: string): Promise { + return await this.findAll({ roomId, memberId: { $in: memberIds } }, fields); + } + + /** + * Gets all room IDs where a user is a member. + * + * @param memberId - The ID of the member (userId) + * @returns Array of room IDs where the user is a member + */ + async getRoomIdsByMemberId(memberId: string): Promise { + const members = await this.findAll({ memberId }, 'roomId'); + return members.map((m) => m.roomId); + } + + /** + * Gets all room IDs where a member has a specific permission enabled. + * Takes into account both base role permissions and custom permissions. + * + * @param memberId - The ID of the member (userId) + * @param permission - The permission key to check (e.g., 'canRetrieveRecordings') + * @returns Array of room IDs where the member has the specified permission + */ + async getRoomIdsByMemberIdWithPermission( + memberId: string, + permission: keyof MeetRoomMemberPermissions + ): Promise { + // Get all memberships for this user + const members = await this.findAll({ memberId }, 'roomId,baseRole,customPermissions'); + + if (members.length === 0) { + return []; + } + + // Fetch all rooms + const roomIds = members.map((m) => m.roomId); + const rooms = await this.roomRepository.findByRoomIds(roomIds, 'roomId,roles'); + const roomsMap = new Map(rooms.map((room) => [room.roomId, room])); + + // Filter members where the permission is enabled + const roomIdsWithPermission: string[] = []; + + for (const member of members) { + const room = roomsMap.get(member.roomId); + + if (!room) continue; + + // Compute effective permissions + const basePermissions = room.roles[member.baseRole].permissions; + const effectivePermission = member.customPermissions?.[permission] ?? basePermissions[permission]; + + if (effectivePermission) { + roomIdsWithPermission.push(member.roomId); + } + } + + return roomIdsWithPermission; } /** diff --git a/meet-ce/backend/src/repositories/room.repository.ts b/meet-ce/backend/src/repositories/room.repository.ts index 2b4c203b..305d7c58 100644 --- a/meet-ce/backend/src/repositories/room.repository.ts +++ b/meet-ce/backend/src/repositories/room.repository.ts @@ -68,6 +68,18 @@ export class RoomRepository extends BaseRepos return document ? this.enrichRoomWithBaseUrls(document) : null; } + /** + * Finds rooms by their roomIds. + * Returns rooms with enriched URLs (including base URL). + * + * @param roomIds - Array of room identifiers + * @param fields - Comma-separated list of fields to include in the result + * @returns Array of found rooms + */ + async findByRoomIds(roomIds: string[], fields?: string): Promise { + return await this.findAll({ roomId: { $in: roomIds } }, fields); + } + /** * Finds rooms with optional filtering, pagination, and sorting. * Returns rooms with enriched URLs (including base URL). @@ -78,6 +90,8 @@ export class RoomRepository extends BaseRepos * @param options - Query options * @param options.roomName - Optional room name to filter by (case-insensitive partial match) * @param options.status - Optional room status to filter by + * @param options.owner - Optional owner userId to filter by + * @param options.roomIds - Optional array of room IDs to filter by, representing rooms the user is a member of * @param options.fields - Comma-separated list of fields to include in the result * @param options.maxItems - Maximum number of results to return (default: 100) * @param options.nextPageToken - Token for pagination (encoded cursor with last sortField value and _id) @@ -85,7 +99,7 @@ export class RoomRepository extends BaseRepos * @param options.sortOrder - Sort order: 'asc' or 'desc' (default: 'desc') * @returns Object containing rooms array, pagination info, and optional next page token */ - async find(options: MeetRoomFilters = {}): Promise<{ + async find(options: MeetRoomFilters & { owner?: string; roomIds?: string[] } = {}): Promise<{ rooms: TRoom[]; isTruncated: boolean; nextPageToken?: string; @@ -93,6 +107,8 @@ export class RoomRepository extends BaseRepos const { roomName, status, + owner, + roomIds, fields, maxItems = 100, nextPageToken, @@ -103,6 +119,15 @@ export class RoomRepository extends BaseRepos // Build base filter const filter: Record = {}; + // Handle owner and roomIds with $or when both are present + if (owner && roomIds && roomIds.length > 0) { + filter.$or = [{ owner }, { roomId: { $in: roomIds } }]; + } else if (owner) { + filter.owner = owner; + } else if (roomIds && roomIds.length > 0) { + filter.roomId = { $in: roomIds }; + } + if (roomName) { filter.roomName = new RegExp(roomName, 'i'); } diff --git a/meet-ce/backend/src/services/recording.service.ts b/meet-ce/backend/src/services/recording.service.ts index 235b1459..39c00b27 100644 --- a/meet-ce/backend/src/services/recording.service.ts +++ b/meet-ce/backend/src/services/recording.service.ts @@ -1,4 +1,4 @@ -import { MeetRecordingFilters, MeetRecordingInfo, MeetRecordingStatus } from '@openvidu-meet/typings'; +import { MeetRecordingFilters, MeetRecordingInfo, MeetRecordingStatus, MeetUserRole } from '@openvidu-meet/typings'; import { inject, injectable } from 'inversify'; import { EgressStatus, EncodedFileOutput, EncodedFileType, RoomCompositeOptions } from 'livekit-server-sdk'; import ms from 'ms'; @@ -24,12 +24,14 @@ import { OpenViduMeetError } from '../models/error.model.js'; import { RecordingRepository } from '../repositories/recording.repository.js'; +import { RoomMemberRepository } from '../repositories/room-member.repository.js'; import { RoomRepository } from '../repositories/room.repository.js'; import { DistributedEventService } from './distributed-event.service.js'; import { FrontendEventService } from './frontend-event.service.js'; import { LiveKitService } from './livekit.service.js'; import { LoggerService } from './logger.service.js'; import { MutexService, RedisLock } from './mutex.service.js'; +import { RequestSessionService } from './request-session.service.js'; import { BlobStorageService } from './storage/blob-storage.service.js'; @injectable() @@ -39,7 +41,9 @@ export class RecordingService { @inject(MutexService) protected mutexService: MutexService, @inject(DistributedEventService) protected systemEventService: DistributedEventService, @inject(RoomRepository) protected roomRepository: RoomRepository, + @inject(RoomMemberRepository) protected roomMemberRepository: RoomMemberRepository, @inject(RecordingRepository) protected recordingRepository: RecordingRepository, + @inject(RequestSessionService) protected requestSessionService: RequestSessionService, @inject(BlobStorageService) protected blobStorageService: BlobStorageService, @inject(FrontendEventService) protected frontendEventService: FrontendEventService, @inject(LoggerService) protected logger: LoggerService @@ -196,10 +200,15 @@ export class RecordingService { } /** - * Retrieves a paginated list of all recordings stored in MongoDB. + * Retrieves a list of recordings based on the provided filtering, pagination, and sorting options. + * + * If the request is made with a room member token, only recordings for the associated room are returned. + * If the request is made by an authenticated user, access is determined by the user's role and permissions: + * - ADMIN: Can see all recordings + * - USER: Can see recordings from rooms they own OR where they are members with canRetrieveRecordings permission + * - ROOM_MEMBER: Can see recordings from rooms where they are members with canRetrieveRecordings permission * - * @param maxItems - The maximum number of items to retrieve in a single request. - * @param nextPageToken - (Optional) A token to retrieve the next page of results. + * @param filters - Filtering, pagination and sorting options * @returns A promise that resolves to an object containing: * - `recordings`: An array of `MeetRecordingInfo` objects representing the recordings. * - `isTruncated`: A boolean indicating whether there are more items to retrieve. @@ -212,7 +221,48 @@ export class RecordingService { nextPageToken?: string; }> { try { - const response = await this.recordingRepository.find(filters); + const user = this.requestSessionService.getAuthenticatedUser(); + const memberRoomId = this.requestSessionService.getRoomIdFromMember(); + const queryOptions: MeetRecordingFilters & { roomIds?: string[]; owner?: string } = { ...filters }; + + // If room member token is present, retrieve only recordings for the room associated with the token + if (memberRoomId) { + queryOptions.roomId = memberRoomId; + } else if (user && user.role !== MeetUserRole.ADMIN) { + // For USER and ROOM_MEMBER roles, + // get room IDs where user is member WITH canRetrieveRecordings permission + const memberRoomIds = await this.roomMemberRepository.getRoomIdsByMemberIdWithPermission( + user.userId, + 'canRetrieveRecordings' + ); + + let ownedRoomIds: string[] = []; + + // If USER role, also get owned room IDs + if (user.role === MeetUserRole.USER) { + const ownedRooms = await this.roomRepository.find({ + owner: user.userId, + fields: 'roomId' + }); + ownedRoomIds = ownedRooms.rooms.map((r) => r.roomId); + } + + // Combine owned rooms and member rooms with permission + const accessibleRoomIds = [...new Set([...ownedRoomIds, ...memberRoomIds])]; + + if (accessibleRoomIds.length === 0) { + // User has no access to any rooms, return empty result + return { + recordings: [], + isTruncated: false + }; + } + + // Apply roomIds filter + queryOptions.roomIds = accessibleRoomIds; + } + + const response = await this.recordingRepository.find(queryOptions); this.logger.info(`Retrieved ${response.recordings.length} recordings.`); return response; } catch (error) { diff --git a/meet-ce/backend/src/services/room-member.service.ts b/meet-ce/backend/src/services/room-member.service.ts index d9c936e7..d4f628f5 100644 --- a/meet-ce/backend/src/services/room-member.service.ts +++ b/meet-ce/backend/src/services/room-member.service.ts @@ -204,7 +204,7 @@ export class RoomMemberService { deleted: string[]; failed: { memberId: string; error: string }[]; }> { - const membersToDelete = await this.roomMemberRepository.findByRoomAndMemberIds(memberIds); + const membersToDelete = await this.roomMemberRepository.findByRoomAndMemberIds(roomId, memberIds, 'memberId'); const foundMemberIds = membersToDelete.map((m) => m.memberId); const failed = memberIds diff --git a/meet-ce/backend/src/services/room.service.ts b/meet-ce/backend/src/services/room.service.ts index 828045a8..93d66d24 100644 --- a/meet-ce/backend/src/services/room.service.ts +++ b/meet-ce/backend/src/services/room.service.ts @@ -327,17 +327,38 @@ export class RoomService { } /** - * Retrieves a list of rooms. - * @returns A Promise that resolves to an array of {@link MeetRoom} objects. - * @throws If there was an error retrieving the rooms. + * Retrieves a list of rooms based on the provided filtering, pagination, and sorting options. + * + * If the request is made by an authenticated user, access is determined by the user's role: + * - ADMIN: Can see all rooms + * - USER: Can see rooms they own or are members of + * - ROOM_MEMBER: Can see rooms they are members of + * + * @param filters - Filtering, pagination and sorting options + * @returns A Promise that resolves to paginated room list + * @throws If there was an error retrieving the rooms */ async getAllMeetRooms(filters: MeetRoomFilters): Promise<{ rooms: MeetRoom[]; isTruncated: boolean; nextPageToken?: string; }> { - const response = await this.roomRepository.find(filters); - return response; + const user = this.requestSessionService.getAuthenticatedUser(); + const queryOptions: MeetRoomFilters & { roomIds?: string[]; owner?: string } = { ...filters }; + + // Admin can see all rooms - no additional filters needed + if (user && user.role !== MeetUserRole.ADMIN) { + // For USER and ROOM_MEMBER roles, get the list of room IDs they are members of + const memberRoomIds = await this.roomMemberRepository.getRoomIdsByMemberId(user.userId); + queryOptions.roomIds = memberRoomIds; + + // If USER role, also filter by rooms they own + if (user.role === MeetUserRole.USER) { + queryOptions.owner = user.userId; + } + } + + return await this.roomRepository.find(queryOptions); } /** @@ -773,6 +794,18 @@ export class RoomService { return room?.owner === userId; } + /** + * Checks if a user is a member of a room. + * + * @param roomId - The ID of the room + * @param memberId - The ID of the member (userId) + * @returns A promise that resolves to true if the user is a member, false otherwise + */ + async isRoomMember(roomId: string, memberId: string): Promise { + const member = await this.roomMemberRepository.findByRoomAndMemberId(roomId, memberId); + return !!member; + } + /** * Validates if the provided secret matches one of the room's secrets for anonymous access. *