diff --git a/meet-ce/backend/openapi/components/schemas/internal/room-member-token-options.yaml b/meet-ce/backend/openapi/components/schemas/internal/room-member-token-options.yaml index cbc8431b..6125b068 100644 --- a/meet-ce/backend/openapi/components/schemas/internal/room-member-token-options.yaml +++ b/meet-ce/backend/openapi/components/schemas/internal/room-member-token-options.yaml @@ -4,7 +4,13 @@ required: properties: secret: type: string - description: A secret key for room access. Determines the member's role. + description: | + A secret key for room access. + + Supported secret types: + - Moderator anonymous access secret + - Speaker anonymous access secret + - Recording anonymous access secret (generates a token with read-only recording permissions) example: 'abc123456' joinMeeting: type: boolean diff --git a/meet-ce/backend/openapi/paths/internal/room-members.yaml b/meet-ce/backend/openapi/paths/internal/room-members.yaml index 6569ee5b..b98c8c62 100644 --- a/meet-ce/backend/openapi/paths/internal/room-members.yaml +++ b/meet-ce/backend/openapi/paths/internal/room-members.yaml @@ -4,6 +4,7 @@ summary: Generate room member token description: > Generates a token for a user to access an OpenVidu Meet room and its resources. + When using the anonymous recording secret, generated tokens only allow retrieval of room recordings. tags: - Internal API - Room Members security: diff --git a/meet-ce/backend/src/models/error.model.ts b/meet-ce/backend/src/models/error.model.ts index 4348efdd..5b5838ad 100644 --- a/meet-ce/backend/src/models/error.model.ts +++ b/meet-ce/backend/src/models/error.model.ts @@ -283,10 +283,13 @@ export const errorInvalidRoomSecret = (roomId: string, secret: string): OpenVidu return new OpenViduMeetError('Room Error', `Secret '${secret}' is not recognized for room '${roomId}'`, 400); }; -export const errorAnonymousAccessDisabled = (roomId: string, role: MeetRoomMemberRole): OpenViduMeetError => { +export const errorAnonymousAccessDisabled = ( + roomId: string, + role: MeetRoomMemberRole | 'recording' +): OpenViduMeetError => { return new OpenViduMeetError( 'Room Error', - `Anonymous access in room '${roomId}' is disabled for role '${role}'`, + `Anonymous access in room '${roomId}' is disabled for ${role === 'recording' ? 'recordings' : `role '${role}'`}`, 403 ); }; diff --git a/meet-ce/backend/src/services/room-member.service.ts b/meet-ce/backend/src/services/room-member.service.ts index 70885037..855deede 100644 --- a/meet-ce/backend/src/services/room-member.service.ts +++ b/meet-ce/backend/src/services/room-member.service.ts @@ -417,15 +417,9 @@ export class RoomMemberService { effectivePermissions = member.effectivePermissions; } else { // If secret matches anonymous access URL secret, assign role and permissions based on it - baseRole = await this.getRoomMemberRoleBySecret(roomId, secret); - const { roles, access } = await this.roomService.getMeetRoom(roomId, ['roles', 'access']); - - // Check that anonymous access is enabled for the role - if (!access.anonymous[baseRole].enabled) { - throw errorAnonymousAccessDisabled(roomId, baseRole); - } - - effectivePermissions = roles[baseRole].permissions; + const anonymousAccess = await this.resolveAnonymousAccessBySecret(roomId, secret); + baseRole = anonymousAccess.baseRole; + effectivePermissions = anonymousAccess.effectivePermissions; } } else { // Case 2: Authenticated user @@ -475,6 +469,49 @@ export class RoomMemberService { return this.generateToken(roomId, baseRole, effectivePermissions, customPermissions, memberId); } + /** + * Resolves anonymous access and effective permissions from a room secret. + * + * - Moderator and speaker secrets map to their room role permissions. + * - Recording secret maps to read-only recording permissions. + */ + protected async resolveAnonymousAccessBySecret( + roomId: string, + secret: string + ): Promise<{ baseRole: MeetRoomMemberRole; effectivePermissions: MeetRoomMemberPermissions }> { + const { roles, access } = await this.roomService.getMeetRoom(roomId, ['roles', 'access']); + const { moderatorSecret, speakerSecret, recordingSecret } = MeetRoomHelper.extractSecretsFromRoom(access); + + const anonymousRole: MeetRoomMemberRole | 'recording' | undefined = + secret === moderatorSecret + ? MeetRoomMemberRole.MODERATOR + : secret === speakerSecret + ? MeetRoomMemberRole.SPEAKER + : secret === recordingSecret + ? 'recording' + : undefined; + + if (!anonymousRole) { + throw errorInvalidRoomSecret(roomId, secret); + } + + if (!access.anonymous[anonymousRole].enabled) { + throw errorAnonymousAccessDisabled(roomId, anonymousRole); + } + + if (anonymousRole === 'recording') { + return { + baseRole: MeetRoomMemberRole.SPEAKER, + effectivePermissions: this.getRecordingReadOnlyPermissions() + }; + } + + return { + baseRole: anonymousRole, + effectivePermissions: roles[anonymousRole].permissions + }; + } + /** * Generates a token for joining a meeting. * Handles both new token generation and token refresh. @@ -598,30 +635,6 @@ export class RoomMemberService { }); } - /** - * Validates a secret against a room's moderator and speaker secrets and returns the corresponding role. - * - * @param roomId - The unique identifier of the room to check - * @param secret - The secret to validate against the room's moderator and speaker secrets - * @returns A promise that resolves to the room member role (MODERATOR or SPEAKER) if the secret is valid - * @throws Error if room not found - * @throws Error if the moderator or speaker secrets cannot be extracted from their URLs - * @throws Error if the provided secret doesn't match any of the room's secrets (unauthorized) - */ - protected async getRoomMemberRoleBySecret(roomId: string, secret: string): Promise { - const { access } = await this.roomService.getMeetRoom(roomId, ['access']); - const { moderatorSecret, speakerSecret } = MeetRoomHelper.extractSecretsFromRoom(access); - - switch (secret) { - case moderatorSecret: - return MeetRoomMemberRole.MODERATOR; - case speakerSecret: - return MeetRoomMemberRole.SPEAKER; - default: - throw errorInvalidRoomSecret(roomId, secret); - } - } - /** * Gets all permissions set to true. */ @@ -666,6 +679,16 @@ export class RoomMemberService { }; } + /** + * Gets a permission set that only allows retrieving recordings. + */ + getRecordingReadOnlyPermissions(): MeetRoomMemberPermissions { + return { + ...this.getNoPermissions(), + canRetrieveRecordings: true + }; + } + /** * Gets the LiveKit permissions for a room member based on their Meet permissions. * diff --git a/meet-ce/backend/tests/helpers/test-scenarios.ts b/meet-ce/backend/tests/helpers/test-scenarios.ts index a0f14e4d..33f6ce8d 100644 --- a/meet-ce/backend/tests/helpers/test-scenarios.ts +++ b/meet-ce/backend/tests/helpers/test-scenarios.ts @@ -51,7 +51,7 @@ export const setupSingleRoom = async ( }); // Extract the room secrets and generate room member tokens - const { moderatorSecret, speakerSecret } = MeetRoomHelper.extractSecretsFromRoom(room.access); + const { moderatorSecret, speakerSecret, recordingSecret } = MeetRoomHelper.extractSecretsFromRoom(room.access); const [moderatorToken, speakerToken] = await Promise.all([ generateRoomMemberToken(room.roomId, { secret: moderatorSecret, joinMeeting: false }), generateRoomMemberToken(room.roomId, { secret: speakerSecret, joinMeeting: false }) @@ -67,7 +67,8 @@ export const setupSingleRoom = async ( moderatorSecret, moderatorToken, speakerSecret, - speakerToken + speakerToken, + recordingSecret }; }; diff --git a/meet-ce/backend/tests/integration/api/room-members/generate-room-member-token.test.ts b/meet-ce/backend/tests/integration/api/room-members/generate-room-member-token.test.ts index af7dc333..e3116b1c 100644 --- a/meet-ce/backend/tests/integration/api/room-members/generate-room-member-token.test.ts +++ b/meet-ce/backend/tests/integration/api/room-members/generate-room-member-token.test.ts @@ -42,6 +42,23 @@ const allPermissions: MeetRoomMemberPermissions = { canChangeVirtualBackground: true }; +const recordingReadOnlyPermissions: MeetRoomMemberPermissions = { + canRecord: false, + canRetrieveRecordings: true, + canDeleteRecordings: false, + canJoinMeeting: false, + canShareAccessLinks: false, + canMakeModerator: false, + canKickParticipants: false, + canEndMeeting: false, + canPublishVideo: false, + canPublishAudio: false, + canShareScreen: false, + canReadChat: false, + canWriteChat: false, + canChangeVirtualBackground: false +}; + describe('Room Members API Tests', () => { let roomData: RoomData; let roomId: string; @@ -131,6 +148,57 @@ describe('Room Members API Tests', () => { }); }); + it('should generate read-only recording token when anonymous.recording.enabled is true', async () => { + expect(roomData.recordingSecret).toBeDefined(); + const recordingSecret = roomData.recordingSecret!; + + await updateRoomAccessConfig(roomId, { + anonymous: { + recording: { enabled: true } + } + }); + + const response = await generateRoomMemberTokenRequest(roomId, { secret: recordingSecret }); + expectValidRoomMemberTokenResponse(response, { + roomId, + baseRole: MeetRoomMemberRole.SPEAKER, + effectivePermissions: recordingReadOnlyPermissions + }); + }); + + it('should fail to generate recording token when anonymous.recording.enabled is false', async () => { + expect(roomData.recordingSecret).toBeDefined(); + const recordingSecret = roomData.recordingSecret!; + + await updateRoomAccessConfig(roomId, { + anonymous: { + recording: { enabled: false } + } + }); + + const response = await generateRoomMemberTokenRequest(roomId, { secret: recordingSecret }); + expect(response.status).toBe(403); + + await updateRoomAccessConfig(roomId, { + anonymous: { + recording: { enabled: true } + } + }); + }); + + it('should fail to generate recording token for joining meeting', async () => { + expect(roomData.recordingSecret).toBeDefined(); + const recordingSecret = roomData.recordingSecret!; + + const response = await generateRoomMemberTokenRequest(roomId, { + secret: recordingSecret, + joinMeeting: true, + participantName: 'Recording Viewer' + }); + + expect(response.status).toBe(403); + }); + it('should fail to generate token when secret is invalid', async () => { const response = await generateRoomMemberTokenRequest(roomId, { secret: 'invalid-secret' diff --git a/meet-ce/backend/tests/integration/api/security/room-members-security.test.ts b/meet-ce/backend/tests/integration/api/security/room-members-security.test.ts index c3d6dd28..ceec0f02 100644 --- a/meet-ce/backend/tests/integration/api/security/room-members-security.test.ts +++ b/meet-ce/backend/tests/integration/api/security/room-members-security.test.ts @@ -538,6 +538,16 @@ describe('Room Members API Security Tests', () => { expect(response.status).toBe(200); }); + it('should succeed when using room recording secret', async () => { + expect(roomData.recordingSecret).toBeDefined(); + + const response = await request(app).post(`${INTERNAL_ROOMS_PATH}/${roomId}/members/token`).send({ + secret: roomData.recordingSecret, + joinMeeting: false + }); + expect(response.status).toBe(200); + }); + it('should fail when using invalid room secret', async () => { const response = await request(app).post(`${INTERNAL_ROOMS_PATH}/${roomId}/members/token`).send({ secret: 'invalid_secret', diff --git a/meet-ce/backend/tests/interfaces/scenarios.ts b/meet-ce/backend/tests/interfaces/scenarios.ts index b61e8488..c344cfe0 100644 --- a/meet-ce/backend/tests/interfaces/scenarios.ts +++ b/meet-ce/backend/tests/interfaces/scenarios.ts @@ -6,6 +6,7 @@ export interface RoomData { moderatorToken: string; speakerSecret: string; speakerToken: string; + recordingSecret?: string; recordingId?: string; users?: RoomTestUsers; }