backend: implement access recording secrets management in storage service

This commit is contained in:
Carlos Santos 2025-06-04 12:44:48 +02:00
parent cf27433e2d
commit 5089df16a7
10 changed files with 162 additions and 11 deletions

View File

@ -104,6 +104,10 @@ openvidu/
│ │ └── room-123/
│ │ └── {egressId}/
│ │ └── {uid}.json
│ ├── .secrets/
│ │ └── room-123/
│ │ └── {egressId}/
│ │ └── {uid}.json
| ├── .room_metadata/
│ │ └── room-123/
│ │ └── room_metadata.json

View File

@ -1,7 +1,7 @@
import { EgressStatus } from '@livekit/protocol';
import { MeetRecordingInfo, MeetRecordingStatus } from '@typings-ce';
import { EgressInfo } from 'livekit-server-sdk';
import INTERNAL_CONFIG from '../config/internal-config.js';
import { uid as secureUid } from 'uid/secure';
export class RecordingHelper {
private constructor() {
@ -186,6 +186,18 @@ export class RecordingHelper {
return size !== 0 ? size : undefined;
}
/**
* Builds the secrets for public and private access to recordings.
* @returns An object containing public and private access secrets.
*/
static buildAccessSecrets(): { publicAccessSecret: string; privateAccessSecret: string } {
return {
publicAccessSecret: secureUid(10),
privateAccessSecret: secureUid(10)
};
}
private static toSeconds(nanoseconds: number): number {
const nanosecondsToSeconds = 1 / 1_000_000_000;
return nanoseconds * nanosecondsToSeconds;

View File

@ -6,8 +6,9 @@ export const enum RedisKeyName {
GLOBAL_PREFERENCES = `${RedisKeyPrefix.BASE}global_preferences`,
ROOM = `${RedisKeyPrefix.BASE}room:`,
RECORDING = `${RedisKeyPrefix.BASE}recording:`,
RECORDING_SECRETS = `${RedisKeyPrefix.BASE}recording_secrets:`,
ARCHIVED_ROOM = `${RedisKeyPrefix.BASE}archived_room:`,
USER = `${RedisKeyPrefix.BASE}user:`,
USER = `${RedisKeyPrefix.BASE}user:`
}
export const enum RedisLockPrefix {

View File

@ -229,7 +229,10 @@ export class LivekitWebhookService {
// Send webhook notification
switch (webhookAction) {
case 'started':
specificTasks.push(this.storageService.archiveRoomMetadata(roomId));
specificTasks.push(
this.storageService.archiveRoomMetadata(roomId),
this.storageService.saveAccessRecordingSecrets(recordingId)
);
this.openViduWebhookService.sendRecordingStartedWebhook(recordingInfo);
break;
case 'updated':

View File

@ -38,4 +38,9 @@ export class S3KeyBuilder implements StorageKeyBuilder {
buildUserKey(userId: string): string {
return `${INTERNAL_CONFIG.S3_USERS_PREFIX}/${userId}.json`;
}
buildAccessRecordingSecretsKey(recordingId: string): string {
const { roomId, egressId, uid } = RecordingHelper.extractInfoFromRecordingId(recordingId);
return `${INTERNAL_CONFIG.S3_RECORDINGS_PREFIX}/.secrets/${roomId}/${egressId}/${uid}.json`;
}
}

View File

@ -150,4 +150,7 @@ export interface StorageKeyBuilder {
* @param userId - The unique identifier of the user
*/
buildUserKey(userId: string): string;
buildAccessRecordingSecretsKey(recordingId:string): string;
}

View File

@ -9,7 +9,7 @@ import {
MEET_WEBHOOK_ENABLED,
MEET_WEBHOOK_URL
} from '../../environment.js';
import { MeetLock, PasswordHelper } from '../../helpers/index.js';
import { MeetLock, PasswordHelper, RecordingHelper } from '../../helpers/index.js';
import {
errorRecordingNotFound,
errorRecordingRangeNotSatisfiable,
@ -393,15 +393,25 @@ export class MeetStorageService<
*/
async deleteRecording(recordingId: string): Promise<void> {
try {
// Keys for recording metadata
const redisMetadataKey = RedisKeyName.RECORDING + recordingId;
const storageMetadataKey = this.keyBuilder.buildMeetRecordingKey(recordingId);
// Key for access recording secrets
const storageSecretsKey = this.keyBuilder.buildAccessRecordingSecretsKey(recordingId);
const redisSecretsKey = RedisKeyName.RECORDING_SECRETS + recordingId;
// Binary recording key
const binaryRecordingKey = this.keyBuilder.buildBinaryRecordingKey(recordingId);
this.logger.info(`Deleting recording ${recordingId} with metadata key ${storageMetadataKey}`);
// Delete both metadata and binary files
// Delete secrets, metadata and binary recording files
await Promise.all([
this.deleteFromCacheAndStorage(redisMetadataKey, storageMetadataKey),
this.deleteFromCacheAndStorageBatch(
[redisMetadataKey, redisSecretsKey],
[storageMetadataKey, storageSecretsKey]
),
this.storageProvider.deleteObject(binaryRecordingKey)
]);
@ -426,13 +436,17 @@ export class MeetStorageService<
try {
// Build all paths from recordingIds
const metadataKeys: string[] = [];
const redisKeys: string[] = [];
const storageKeys: string[] = [];
const binaryKeys: string[] = [];
for (const recordingId of recordingIds) {
redisKeys.push(RedisKeyName.RECORDING + recordingId);
metadataKeys.push(this.keyBuilder.buildMeetRecordingKey(recordingId));
redisKeys.push(RedisKeyName.RECORDING_SECRETS + recordingId);
storageKeys.push(this.keyBuilder.buildMeetRecordingKey(recordingId));
storageKeys.push(this.keyBuilder.buildAccessRecordingSecretsKey(recordingId));
binaryKeys.push(this.keyBuilder.buildBinaryRecordingKey(recordingId));
}
@ -440,7 +454,7 @@ export class MeetStorageService<
// Delete all files in parallel using batch operations
await Promise.all([
this.deleteFromCacheAndStorageBatch(redisKeys, metadataKeys),
this.deleteFromCacheAndStorageBatch(redisKeys, storageKeys),
this.storageProvider.deleteObjects(binaryKeys)
]);
this.logger.verbose(`Successfully bulk deleted ${recordingIds.length} recordings`);
@ -478,6 +492,54 @@ export class MeetStorageService<
// USER DOMAIN LOGIC
// ==========================================
/**
* Saves access recording secrets (public and private) for a specific recording.
*
* @param recordingId - The unique identifier of the recording
* @param secrets - Object containing the public and private access secrets
* @param secrets.publicAccessSecret - The public access secret for the recording
* @param secrets.privateAccessSecret - The private access secret for the recording
* @returns A promise that resolves when the secrets are successfully saved
* @throws Will throw an error if the storage operation fails
*/
async saveAccessRecordingSecrets(recordingId: string): Promise<void> {
try {
const redisKey = RedisKeyName.RECORDING_SECRETS + recordingId;
const storageKey = this.keyBuilder.buildAccessRecordingSecretsKey(recordingId);
const secrets = RecordingHelper.buildAccessSecrets();
this.logger.debug(`Saving access secrets for recording ${recordingId} at ${storageKey}`);
await this.saveCacheAndStorage(redisKey, storageKey, secrets);
} catch (error) {
this.handleError(error, `Error saving access secrets for recording ${recordingId}`);
throw error;
}
}
async getAccessRecordingSecrets(
recordingId: string
): Promise<{ publicAccessSecret: string; privateAccessSecret: string } | null> {
try {
const redisKey = RedisKeyName.RECORDING_SECRETS + recordingId;
const secretsKey = this.keyBuilder.buildAccessRecordingSecretsKey(recordingId);
this.logger.debug(`Retrieving access secrets for recording ${recordingId} from ${secretsKey}`);
const secrets = await this.getFromCacheAndStorage<{
publicAccessSecret: string;
privateAccessSecret: string;
}>(redisKey, secretsKey);
if (!secrets) {
this.logger.warn(`No access secrets found for recording ${recordingId}`);
return null;
}
return secrets;
} catch (error) {
this.handleError(error, `Error fetching access secrets for recording ${recordingId}`);
throw error;
}
}
/**
* Retrieves user data for a specific username.
*
@ -733,6 +795,10 @@ export class MeetStorageService<
}
}
// ==========================================
// PRIVATE HELPER METHODS
// ==========================================
/**
* Returns the default global preferences.
* @returns {GPrefs}

View File

@ -23,7 +23,7 @@ describe('Recording API Tests', () => {
});
describe('Bulk Delete Recording Tests', () => {
it('"should return 200 when mixed valid and non-existent IDs are provided', async () => {
it('should return 200 when mixed valid and non-existent IDs are provided', async () => {
const testContext = await setupMultiRecordingsTestContext(3, 3, 3);
const recordingIds = testContext.rooms.map((room) => room.recordingId);
const nonExistentIds = ['nonExistent--EG_000--1234', 'nonExistent--EG_111--5678'];
@ -102,6 +102,21 @@ describe('Recording API Tests', () => {
expect(deleteResponse.status).toBe(204);
});
it('should delete all recordings and their secrets', async () => {
const response = await setupMultiRecordingsTestContext(3, 3, 3);
const recordingIds = response.rooms.map((room) => room.recordingId);
const deleteResponse = await bulkDeleteRecordings(recordingIds);
expect(deleteResponse.status).toBe(204);
const storageService = container.get(MeetStorageService);
for (const recordingId of recordingIds) {
const recSecrets = await storageService.getAccessRecordingSecrets(recordingId!);
expect(recSecrets).toBeNull();
}
});
it('should handle single recording deletion correctly', async () => {
const testContext = await setupMultiRecordingsTestContext(1, 1, 1);
const recordingId = testContext.rooms[0].recordingId;
@ -139,7 +154,6 @@ describe('Recording API Tests', () => {
expect(roomMetadata!.publisherRoomUrl).toContain(room.roomId);
const response = await startRecording(room.roomId, moderatorCookie);
console.log('Start recording response:', response.body);
expectValidStartRecordingResponse(response, room.roomId);
const secondRecordingId = response.body.recordingId;

View File

@ -49,6 +49,22 @@ describe('Recording API Tests', () => {
expect(getResponse.status).toBe(404);
});
it('should secrets be deleted when recording is deleted', async () => {
const storageService = container.get(MeetStorageService);
let recSecrets = await storageService.getAccessRecordingSecrets(recordingId);
expect(recSecrets).toBeDefined();
expect(recSecrets?.publicAccessSecret).toBeDefined();
expect(recSecrets?.privateAccessSecret).toBeDefined();
// Check that the room metadata still exists after deleteing the first recording
const deleteResponse = await deleteRecording(recordingId!);
expect(deleteResponse.status).toBe(204);
recSecrets = await storageService.getAccessRecordingSecrets(recordingId);
expect(recSecrets).toBe(null);
});
it('should delete room metadata when deleting the last recording', async () => {
const meetStorageService = container.get<MeetStorageService>(MeetStorageService);

View File

@ -19,6 +19,8 @@ import {
stopRecording
} from '../../../helpers/request-helpers.js';
import { setupMultiRoomTestContext, TestContext } from '../../../helpers/test-scenarios.js';
import { container } from '../../../../src/config/dependency-injector.config.js';
import { MeetStorageService } from '../../../../src/services/index.js';
describe('Recording API Tests', () => {
let context: TestContext | null = null;
@ -59,6 +61,31 @@ describe('Recording API Tests', () => {
expectValidStopRecordingResponse(stopResponse, recordingId, room.roomId);
});
it('should secrets and archived room files be created when recording starts', async () => {
const response = await startRecording(room.roomId, moderatorCookie);
const recordingId = response.body.recordingId;
expectValidStartRecordingResponse(response, room.roomId);
expectValidRecordingLocationHeader(response);
const storageService = container.get(MeetStorageService);
const recSecrets = await storageService.getAccessRecordingSecrets(recordingId);
expect(recSecrets).toBeDefined();
expect(recSecrets?.publicAccessSecret).toBeDefined();
expect(recSecrets?.privateAccessSecret).toBeDefined();
const archivedRoom = await storageService.getArchivedRoomMetadata(room.roomId);
expect(archivedRoom).toBeDefined();
expect(archivedRoom?.moderatorRoomUrl).toBeDefined();
expect(archivedRoom?.publisherRoomUrl).toBeDefined();
expect(archivedRoom?.preferences).toBeDefined();
// Check if secrets file is created
const secretsResponse = await stopRecording(recordingId, moderatorCookie);
expectValidStopRecordingResponse(secretsResponse, recordingId, room.roomId);
});
it('should successfully start recording, stop it, and start again (sequential operations)', async () => {
const firstStartResponse = await startRecording(room.roomId, moderatorCookie);
const firstRecordingId = firstStartResponse.body.recordingId;