backend: enhace get all rooms and recordings methods to filter results based on user's role and permissions

This commit is contained in:
juancarmore 2026-01-09 11:26:14 +01:00
parent 0deae8ad29
commit b8507b824b
8 changed files with 200 additions and 27 deletions

View File

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

View File

@ -60,13 +60,24 @@ export abstract class BaseRepository<TDomain, TDocument extends Document> {
* 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<TDocument> = {}): Promise<TDomain[]> {
protected async findAll(filter: FilterQuery<TDocument> = {}, fields?: string): Promise<TDomain[]> {
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);

View File

@ -89,6 +89,7 @@ export class RecordingRepository<TRecording extends MeetRecordingInfo = MeetReco
* even when the sort field has duplicate values.
*
* @param options - Query options
* @param options.roomIds - Optional array of room IDs to filter by
* @param options.roomId - Optional room ID for exact match filtering
* @param options.roomName - Optional room name for regex match filtering (case-insensitive)
* @param options.status - Optional recording status to filter by
@ -99,12 +100,13 @@ export class RecordingRepository<TRecording extends MeetRecordingInfo = MeetReco
* @param options.sortOrder - Sort order: 'asc' or 'desc' (default: 'desc')
* @returns Object containing recordings array, pagination info, and optional next page token
*/
async find(options: MeetRecordingFilters = {}): Promise<{
async find(options: MeetRecordingFilters & { roomIds?: string[] } = {}): Promise<{
recordings: TRecording[];
isTruncated: boolean;
nextPageToken?: string;
}> {
const {
roomIds,
roomId,
roomName,
status,
@ -118,6 +120,11 @@ export class RecordingRepository<TRecording extends MeetRecordingInfo = MeetReco
// Build base filter
const filter: Record<string, unknown> = {};
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') }];

View File

@ -113,11 +113,67 @@ export class RoomMemberRepository extends BaseRepository<MeetRoomMember, MeetRoo
/**
* Finds room members by their memberIds.
*
* @param roomId - The ID of the room
* @param memberIds - Array of member identifiers
* @returns Array of found room members
*/
async findByRoomAndMemberIds(memberIds: string[]): Promise<MeetRoomMember[]> {
return await this.findAll({ memberId: { $in: memberIds } });
async findByRoomAndMemberIds(roomId: string, memberIds: string[], fields?: string): Promise<MeetRoomMember[]> {
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<string[]> {
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<string[]> {
// 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;
}
/**

View File

@ -68,6 +68,18 @@ export class RoomRepository<TRoom extends MeetRoom = MeetRoom> 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<TRoom[]> {
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<TRoom extends MeetRoom = MeetRoom> 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<TRoom extends MeetRoom = MeetRoom> 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<TRoom extends MeetRoom = MeetRoom> extends BaseRepos
const {
roomName,
status,
owner,
roomIds,
fields,
maxItems = 100,
nextPageToken,
@ -103,6 +119,15 @@ export class RoomRepository<TRoom extends MeetRoom = MeetRoom> extends BaseRepos
// Build base filter
const filter: Record<string, unknown> = {};
// 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');
}

View File

@ -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) {

View File

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

View File

@ -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<boolean> {
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.
*