backend: enhance room member token generation with support for recording access and update related tests
This commit is contained in:
parent
f49fd863b7
commit
e05dfdf001
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
);
|
||||
};
|
||||
|
||||
@ -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<MeetRoomMemberRole> {
|
||||
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.
|
||||
*
|
||||
|
||||
@ -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
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -6,6 +6,7 @@ export interface RoomData {
|
||||
moderatorToken: string;
|
||||
speakerSecret: string;
|
||||
speakerToken: string;
|
||||
recordingSecret?: string;
|
||||
recordingId?: string;
|
||||
users?: RoomTestUsers;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user