backend: persist effective permissions in room member documents instead of compute them on retrieval. Update permissions of all room members when updating room roles permissions

This commit is contained in:
juancarmore 2026-01-28 16:03:31 +01:00
parent 89e7d5db88
commit 2e7cbeb96a
4 changed files with 148 additions and 139 deletions

View File

@ -7,7 +7,7 @@ import { INTERNAL_CONFIG } from '../../config/internal-config.js';
* Extends the MeetRoomMember interface with MongoDB Document functionality.
* Note: effectivePermissions is computed, not stored.
*/
export interface MeetRoomMemberDocument extends Omit<MeetRoomMember, 'effectivePermissions'>, Document {
export interface MeetRoomMemberDocument extends MeetRoomMember, Document {
/** Schema version for migration tracking (internal use only) */
schemaVersion?: number;
}
@ -88,6 +88,10 @@ const MeetRoomMemberSchema = new Schema<MeetRoomMemberDocument>(
type: MeetRoomMemberPartialPermissionsSchema,
required: false
},
effectivePermissions: {
type: MeetRoomMemberPermissionsSchema,
required: true
},
currentParticipantIdentity: {
type: String,
required: false

View File

@ -1,51 +1,30 @@
import {
MeetRoomMember,
MeetRoomMemberFilters,
MeetRoomMemberPermissions,
MeetRoomMemberRole,
MeetRoomRoles
} from '@openvidu-meet/typings';
import { MeetRoomMember, MeetRoomMemberFilters, MeetRoomMemberPermissions } from '@openvidu-meet/typings';
import { inject, injectable } from 'inversify';
import { errorRoomNotFound } from '../models/error.model.js';
import { MeetRoomMemberDocument, MeetRoomMemberModel } from '../models/mongoose-schemas/room-member.schema.js';
import { LoggerService } from '../services/logger.service.js';
import { BaseRepository } from './base.repository.js';
import { RoomRepository } from './room.repository.js';
/**
* Repository for managing MeetRoomMember entities in MongoDB.
* Handles the storage and retrieval of room members.
*/
@injectable()
export class RoomMemberRepository extends BaseRepository<MeetRoomMember, MeetRoomMemberDocument> {
private currentRoomRoles: MeetRoomRoles | undefined;
constructor(
@inject(LoggerService) logger: LoggerService,
@inject(RoomRepository) private roomRepository: RoomRepository
) {
export class RoomMemberRepository<TRoomMember extends MeetRoomMember = MeetRoomMember> extends BaseRepository<
TRoomMember,
MeetRoomMemberDocument
> {
constructor(@inject(LoggerService) logger: LoggerService) {
super(logger, MeetRoomMemberModel);
}
/**
* Transforms a MongoDB document into a domain room member object.
* Computes effective permissions based on base role and custom permissions.
*
* @param document - The MongoDB document
* @returns Room member with computed permissions
*/
protected toDomain(document: MeetRoomMemberDocument): MeetRoomMember {
const doc = document.toObject();
const effectivePermissions = this.computeEffectivePermissions(
this.currentRoomRoles!,
doc.baseRole,
doc.customPermissions
);
return {
...doc,
effectivePermissions
};
protected toDomain(document: MeetRoomMemberDocument): TRoomMember {
return document.toObject() as TRoomMember;
}
/**
@ -54,18 +33,9 @@ export class RoomMemberRepository extends BaseRepository<MeetRoomMember, MeetRoo
* @param member - The room member data to add
* @returns The created room member
*/
async create(member: Omit<MeetRoomMember, 'effectivePermissions'>): Promise<MeetRoomMember> {
const room = await this.roomRepository.findByRoomId(member.roomId);
if (!room) {
throw errorRoomNotFound(member.roomId);
}
this.currentRoomRoles = room.roles;
const document = await this.createDocument(member as MeetRoomMember);
const domain = this.toDomain(document);
this.currentRoomRoles = undefined;
return domain;
async create(member: TRoomMember): Promise<TRoomMember> {
const document = await this.createDocument(member as TRoomMember);
return this.toDomain(document);
}
/**
@ -75,18 +45,9 @@ export class RoomMemberRepository extends BaseRepository<MeetRoomMember, MeetRoo
* @returns The updated room member
* @throws Error if room member not found
*/
async update(member: MeetRoomMember): Promise<MeetRoomMember> {
const room = await this.roomRepository.findByRoomId(member.roomId);
if (!room) {
throw errorRoomNotFound(member.roomId);
}
this.currentRoomRoles = room.roles;
async update(member: TRoomMember): Promise<TRoomMember> {
const document = await this.updateOne({ roomId: member.roomId, memberId: member.memberId }, member);
const domain = this.toDomain(document);
this.currentRoomRoles = undefined;
return domain;
return this.toDomain(document);
}
/**
@ -96,18 +57,9 @@ export class RoomMemberRepository extends BaseRepository<MeetRoomMember, MeetRoo
* @param memberId - The ID of the member
* @returns The room member or null if not found
*/
async findByRoomAndMemberId(roomId: string, memberId: string): Promise<MeetRoomMember | null> {
const room = await this.roomRepository.findByRoomId(roomId);
if (!room) {
return null;
}
this.currentRoomRoles = room.roles;
async findByRoomAndMemberId(roomId: string, memberId: string): Promise<TRoomMember | null> {
const document = await this.findOne({ roomId, memberId });
const domain = document ? this.toDomain(document) : null;
this.currentRoomRoles = undefined;
return domain;
return document ? this.toDomain(document) : null;
}
/**
@ -118,7 +70,7 @@ export class RoomMemberRepository extends BaseRepository<MeetRoomMember, MeetRoo
* @param fields - Comma-separated list of fields to include in the result
* @returns Array of found room members
*/
async findByRoomAndMemberIds(roomId: string, memberIds: string[], fields?: string): Promise<MeetRoomMember[]> {
async findByRoomAndMemberIds(roomId: string, memberIds: string[], fields?: string): Promise<TRoomMember[]> {
return await this.findAll({ roomId, memberId: { $in: memberIds } }, fields);
}
@ -135,46 +87,23 @@ export class RoomMemberRepository extends BaseRepository<MeetRoomMember, MeetRoo
/**
* 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')
* @param permission - The permission key to check
* @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;
const members = await this.findAll(
{
memberId,
[`effectivePermissions.${permission}`]: true
},
'roomId'
);
return members.map((member) => member.roomId);
}
/**
@ -194,18 +123,10 @@ export class RoomMemberRepository extends BaseRepository<MeetRoomMember, MeetRoo
roomId: string,
options: MeetRoomMemberFilters = {}
): Promise<{
members: MeetRoomMember[];
members: TRoomMember[];
isTruncated: boolean;
nextPageToken?: string;
}> {
const room = await this.roomRepository.findByRoomId(roomId);
if (!room) {
throw errorRoomNotFound(roomId);
}
this.currentRoomRoles = room.roles;
const {
name,
fields,
@ -234,8 +155,6 @@ export class RoomMemberRepository extends BaseRepository<MeetRoomMember, MeetRoo
fields
);
this.currentRoomRoles = undefined;
return {
members: result.items,
isTruncated: result.isTruncated,
@ -285,33 +204,4 @@ export class RoomMemberRepository extends BaseRepository<MeetRoomMember, MeetRoo
async deleteAllByMemberId(memberId: string): Promise<void> {
await this.deleteMany({ memberId }, false);
}
// ==========================================
// PRIVATE HELPER METHODS
// ==========================================
/**
* Computes effective permissions by merging base role permissions with custom permissions.
*
* @param roomRoles - The room roles configuration
* @param baseRole - The base role of the member
* @param customPermissions - Optional custom permissions that override the base role
* @returns The effective permissions object
*/
private computeEffectivePermissions(
roomRoles: MeetRoomRoles,
baseRole: MeetRoomMemberRole,
customPermissions?: Partial<MeetRoomMemberPermissions>
): MeetRoomMemberPermissions {
const basePermissions = roomRoles[baseRole].permissions;
if (!customPermissions) {
return basePermissions;
}
return {
...basePermissions,
...customPermissions
};
}
}

View File

@ -7,12 +7,14 @@ import {
MeetRoomMemberRole,
MeetRoomMemberTokenMetadata,
MeetRoomMemberTokenOptions,
MeetRoomRoles,
MeetRoomStatus,
MeetUserRole,
TrackSource
} from '@openvidu-meet/typings';
import { inject, injectable } from 'inversify';
import { ParticipantInfo } from 'livekit-server-sdk';
import merge from 'lodash.merge';
import { uid as secureUid } from 'uid/secure';
import { uid } from 'uid/single';
import { MEET_ENV } from '../environment.js';
@ -105,6 +107,10 @@ export class RoomMemberService {
throw new Error('Either userId or name must be provided');
}
// Compute effective permissions
const room = await this.roomService.getMeetRoom(roomId);
const effectivePermissions = this.computeEffectivePermissions(room.roles, baseRole, customPermissions);
const roomMember = {
memberId,
roomId,
@ -112,11 +118,29 @@ export class RoomMemberService {
membershipDate: Date.now(),
accessUrl,
baseRole,
customPermissions
customPermissions,
effectivePermissions
};
return this.roomMemberRepository.create(roomMember);
}
/**
* Computes effective permissions by merging base role permissions with custom permissions.
*
* @param roomRoles - The room roles configuration
* @param baseRole - The base role of the member
* @param customPermissions - Optional custom permissions that override the base role
* @returns The effective permissions object
*/
protected computeEffectivePermissions(
roomRoles: MeetRoomRoles,
baseRole: MeetRoomMemberRole,
customPermissions?: Partial<MeetRoomMemberPermissions>
): MeetRoomMemberPermissions {
const basePermissions = roomRoles[baseRole].permissions;
return merge({}, basePermissions, customPermissions);
}
/**
* Checks if a user (registered or external) is a member of a room.
*
@ -191,6 +215,14 @@ export class RoomMemberService {
member.customPermissions = updates.customPermissions;
}
// Recompute effective permissions
const room = await this.roomService.getMeetRoom(roomId);
member.effectivePermissions = this.computeEffectivePermissions(
room.roles,
member.baseRole,
member.customPermissions
);
const updatedMember = await this.roomMemberRepository.update(member);
// If member is currently in a meeting, check if they still have permission to join
@ -219,6 +251,85 @@ export class RoomMemberService {
return updatedMember;
}
/**
* Updates effective permissions for all members of a room based on the new room roles permissions.
* This method should be called when room roles are updated to ensure all members
* have their effective permissions recalculated.
*
* @param roomId - The ID of the room
* @param roomRoles - The updated room roles configuration
* @returns A promise that resolves when all members have been updated
*/
async updateAllRoomMemberPermissions(roomId: string, roomRoles: MeetRoomRoles): Promise<void> {
this.logger.verbose(`Updating effective permissions for all members in room '${roomId}'`);
const BATCH_SIZE = 20; // Process members in smaller batches
let nextPageToken: string | undefined;
let totalUpdated = 0;
let batchNumber = 0;
do {
batchNumber++;
// Get a batch of members
const {
members,
isTruncated,
nextPageToken: token
} = await this.getAllRoomMembers(roomId, {
maxItems: BATCH_SIZE,
nextPageToken
});
if (members.length === 0) {
break;
}
this.logger.verbose(`Processing batch ${batchNumber} with ${members.length} members in room '${roomId}'`);
// Update each member's effective permissions in this batch
const updatePromises = members.map(async (member) => {
try {
// Recalculate effective permissions based on new room roles
const effectivePermissions = this.computeEffectivePermissions(
roomRoles,
member.baseRole,
member.customPermissions
);
// Update the member with new effective permissions
member.effectivePermissions = effectivePermissions;
await this.roomMemberRepository.update(member);
this.logger.verbose(
`Updated effective permissions for member '${member.memberId}' in room '${roomId}'`
);
} catch (error) {
this.logger.error(
`Failed to update effective permissions for member '${member.memberId}' in room '${roomId}':`,
error
);
// Continue with other members even if one fails
}
});
// Wait for all updates in this batch to complete before moving to the next batch
await Promise.all(updatePromises);
totalUpdated += members.length;
nextPageToken = isTruncated ? token : undefined;
this.logger.verbose(`Completed batch ${batchNumber}, total updated: ${totalUpdated} members`);
} while (nextPageToken);
if (totalUpdated === 0) {
this.logger.verbose(`No members found in room '${roomId}' to update`);
return;
}
this.logger.info(`Successfully updated effective permissions for ${totalUpdated} members in room '${roomId}'`);
}
/**
* Updates the currentParticipantIdentity for a room member.
*

View File

@ -263,8 +263,12 @@ export class RoomService {
// Merge existing roles with new roles (partial update)
room.roles = merge({}, room.roles, roles);
await this.roomRepository.update(room);
// Update existing room members with new effective permissions
const roomMemberService = await this.getRoomMemberService();
await roomMemberService.updateAllRoomMemberPermissions(roomId, room.roles);
return room;
}