mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-10 07:14:30 +00:00
✨(backend) audit recordings, transcripts and summaries
Emit audit events across the recording lifecycle: recording.start, recording.stop and recording.delete from the API, recording.end when LiveKit reports the egress ended, recording.transcript.request when the backend sends a recording to the summary service, and recording.transcript.report and recording.summary.report when that service calls back. Small bugfix: AuditViewMixin keeps a copy of a deleted target, since deleting an instance clears its primary key.
This commit is contained in:
@@ -21,6 +21,7 @@ and this project adheres to
|
||||
- ✨(backend) add structured audit logging facility
|
||||
- ✨(backend) audit external API token and room operations
|
||||
- 🔒️(backend) audit writes and bulk actions made in the Django admin
|
||||
- ✨(backend) audit recordings, transcripts and summaries
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ Standard fields follow the [Elastic Common Schema](https://www.elastic.co/guide/
|
||||
| `event.reason` | Why it did not succeed: `authentication_failed`, `permission_denied`, `rate_limited`, `validation_error`, `not_found`, `conflict`, `internal_error` |
|
||||
| `lasuite.outcome` | `success`, `failure` or `denied` |
|
||||
| `lasuite.actor.type` | `user`, `application`, `service`, `system` or `anonymous`, see [Actors](#actors) |
|
||||
| `lasuite.actor.name` | Name of a `service` actor: `roomkit`, `summary` |
|
||||
| `lasuite.actor.name` | Name of a `service` actor: `roomkit`, `summary`, `livekit` |
|
||||
| `lasuite.auth.method` | `session`, `application_jwt`, `addons_jwt`, `resource_server`, `livekit_token`, `shared_secret`, `client_credentials`, `oidc`, `password`, `none`, or `unknown` for a class that is not registered. Requests served outside DRF, as the admin and logout are, report `session` when signed in |
|
||||
| `lasuite.application.client_id` | The external application acting, when there is one. Only set once its credentials are verified |
|
||||
| `user.id`, `user.sub`, `user.domain` | The account whose authority the action used, see [Actors](#actors): primary key, OIDC sub when the account has one, and email domain. The email address is never recorded |
|
||||
@@ -84,7 +84,7 @@ An audited API action that raises an exception DRF does not handle is still reco
|
||||
|---|---|---|
|
||||
| `user` | A person's account acting for itself: session, OIDC or password login, add-on token, LiveKit token of a known account | That account |
|
||||
| `application` | A client application acting on behalf of a user: a Meet application through its client credentials or its delegated token, or another La Suite application through the resource server. `lasuite.application.client_id` names it | The delegating user |
|
||||
| `service` | An internal peer of the deployment acting on its own behalf: the LiveKit SIP bridge (`roomkit`), the summary service (`summary`). | Absent |
|
||||
| `service` | An internal peer of the deployment : the LiveKit SIP bridge (`roomkit`), the summary service (`summary`), the LiveKit server reporting on a recording (`livekit`) | Absent |
|
||||
| `system` | The backend itself, with no inbound request | Absent |
|
||||
| `anonymous` | A caller that did not authenticate, or failed to | Absent |
|
||||
|
||||
@@ -101,11 +101,26 @@ an account a user. An event emitted with neither a request nor an actor is the s
|
||||
| `room.update` | A room is updated through the external API, or the attempt fails | `lasuite.target` = room, refusals included, `lasuite.details.updated_fields`, `previous_access_level` |
|
||||
| `room.retrieve` | A room is read through the external API, or the attempt fails | `lasuite.target` = room |
|
||||
| `room.list` | Rooms are listed through the external API, or the attempt fails | `lasuite.details.total` |
|
||||
| `recording.start` | A room owner or administrator starts a recording, or the attempt fails: conflict with a recording in progress, worker error | `lasuite.target` = recording, or the room when none was created, `lasuite.details.collect_metadata` |
|
||||
| `recording.stop` | A room owner or administrator stops the recording in progress, or the attempt fails | `lasuite.target` = recording, or the room when none is active |
|
||||
| `recording.end` | LiveKit reports a recording ended (`egress_ended` webhook): `success` when its media file is available, `failure` when it was aborted or failed | `lasuite.actor.name` = `livekit`, `lasuite.target` = recording, as it was before the report is processed, `lasuite.details.worker_event` (`completed`, `limit reached`, `aborted`, `failed`), `error_code` |
|
||||
| `recording.delete` | A recording is deleted, or the attempt fails | `lasuite.target` = recording |
|
||||
| `recording.transcript.request` | The backend sends a recording to the summary service to be transcribed, or fails to | `lasuite.actor.type` = `system`, `lasuite.target` = recording, `lasuite.details.summary_requested`: whether a summary is to be made of the transcript, `job_id` |
|
||||
| `recording.transcript.report` | The summary service reports on a transcript (`external-process-hook`), or a call to the hook is refused | `lasuite.actor.name` = `summary`, `lasuite.target` = recording, absent for an unknown job, `lasuite.details.job_id`, `status`. A reported `failure` is a `failure` |
|
||||
| `recording.summary.report` | The summary service reports on a summary | As `recording.transcript.report` |
|
||||
| `user.login` | A user logs in or a login attempt fails, `denied` with reason `authentication_failed` | `lasuite.auth.method` = `oidc` or `password`, or `unknown`: named after the backend on success, `lasuite.details.auth_backend`, and after the credentials submitted on failure (a password, or the nonce of the OIDC callback) |
|
||||
| `user.logout` | A user logs out | |
|
||||
| `admin.access` | A signed-in account without staff access reaches an admin page (always denied), once per refused page | `event.reason`, `http.response.status_code`: the redirect to the login page |
|
||||
| `admin.<target>.<verb>` | A write is made through the Django admin, see below | |
|
||||
|
||||
### Recordings, transcripts and summaries
|
||||
|
||||
A recording's target carries what was recorded: `mode` is how the media was captured (`screen_recording`, a video;
|
||||
`transcript`, an audio track), `requested_mode` what the user asked for, and `is_transcribed` whether it is sent to
|
||||
the summary service. They differ for a transcript started with a screen capture: `mode` is `screen_recording`,
|
||||
`requested_mode` `transcript`. A transcript and a summary are not recordings but what the summary service derives
|
||||
from one, audited under `recording.transcript.*` and `recording.summary.*` with the recording as their target.
|
||||
|
||||
Actions are always dotted, lower-case, with the format `<target>.<verb>`, and name what was attempted: whether it
|
||||
succeeded is told by `event.outcome`, `lasuite.outcome` and `event.reason`, never by the action.
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ from rest_framework import (
|
||||
)
|
||||
from rest_framework.settings import api_settings
|
||||
|
||||
from core import analytics, enums, models, utils
|
||||
from core import analytics, audit, auditing, enums, models, utils
|
||||
from core.api import throttling
|
||||
from core.api.filters import ListFileFilter
|
||||
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
||||
@@ -168,6 +168,7 @@ class UserViewSet(
|
||||
|
||||
|
||||
class RoomViewSet(
|
||||
audit.AuditViewMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
@@ -312,6 +313,7 @@ class RoomViewSet(
|
||||
permission_classes=[
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
],
|
||||
audit_action=auditing.RECORDING_START,
|
||||
)
|
||||
@FeatureFlag.require("recording")
|
||||
def start_room_recording(self, request, pk=None): # pylint: disable=unused-argument
|
||||
@@ -349,6 +351,11 @@ class RoomViewSet(
|
||||
recording=recording,
|
||||
)
|
||||
|
||||
self.audit_target = recording
|
||||
self.audit_details = {
|
||||
"collect_metadata": bool(recording.options.get("collect_metadata")),
|
||||
}
|
||||
|
||||
except (DjangoValidationError, IntegrityError):
|
||||
# DjangoValidationError covers the Python-level check (full_clean);
|
||||
# IntegrityError covers the race where two concurrent requests both
|
||||
@@ -393,6 +400,7 @@ class RoomViewSet(
|
||||
permission_classes=[
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
],
|
||||
audit_action=auditing.RECORDING_STOP,
|
||||
)
|
||||
@FeatureFlag.require("recording")
|
||||
def stop_room_recording(self, request, pk=None): # pylint: disable=unused-argument
|
||||
@@ -409,6 +417,8 @@ class RoomViewSet(
|
||||
"No active recording found for this room."
|
||||
) from e
|
||||
|
||||
self.audit_target = recording
|
||||
|
||||
worker_service = get_worker_service(mode=recording.mode)
|
||||
worker_manager = WorkerServiceMediator(worker_service=worker_service)
|
||||
|
||||
@@ -942,6 +952,7 @@ class ResourceAccessViewSet(
|
||||
|
||||
|
||||
class RecordingViewSet(
|
||||
audit.AuditViewMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
@@ -956,6 +967,23 @@ class RecordingViewSet(
|
||||
queryset = models.Recording.objects.all()
|
||||
serializer_class = serializers.RecordingSerializer
|
||||
|
||||
audit_actions = {"destroy": auditing.RECORDING_DELETE}
|
||||
# What the summary service reports on, once its event is validated
|
||||
audit_process_type = None
|
||||
|
||||
def get_audit_action(self):
|
||||
"""Audit a summary apart from the transcript it is made from."""
|
||||
if self.audit_process_type == "summary":
|
||||
return auditing.RECORDING_SUMMARY_REPORT
|
||||
return super().get_audit_action()
|
||||
|
||||
def get_audit_fields(self, status_code, error=None):
|
||||
"""Report a failure of the summary service as one, though it is acknowledged."""
|
||||
fields = super().get_audit_fields(status_code, error)
|
||||
if status_code < 400 and (self.audit_details or {}).get("status") == "failure":
|
||||
fields["outcome"] = audit.Outcome.FAILURE
|
||||
return fields
|
||||
|
||||
def get_queryset(self):
|
||||
"""Restrict recordings to the user's ones."""
|
||||
user = self.request.user
|
||||
@@ -971,6 +999,7 @@ class RecordingViewSet(
|
||||
url_path="external-process-hook",
|
||||
authentication_classes=[RecordingProcessWebhookAuthentication],
|
||||
serializer_class=serializers.ExternalProcessEventSerializer,
|
||||
audit_action=auditing.RECORDING_TRANSCRIPT_REPORT,
|
||||
)
|
||||
def on_external_process_event_received(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Handle incoming external process events for recordings."""
|
||||
@@ -984,12 +1013,16 @@ class RecordingViewSet(
|
||||
|
||||
validated_data = serializer.validated_data
|
||||
job_id = validated_data["job_id"]
|
||||
self.audit_process_type = validated_data.get("type")
|
||||
self.audit_details = {"job_id": job_id, "status": validated_data.get("status")}
|
||||
try:
|
||||
recording = models.Recording.objects.get(external_process_id=job_id)
|
||||
except models.Recording.DoesNotExist as e:
|
||||
logger.warning("No recording found for job_id %s: %s", job_id, e)
|
||||
return ok_response
|
||||
|
||||
self.audit_target = recording
|
||||
|
||||
if validated_data.get("type") == "transcript":
|
||||
if validated_data.get("status") == "success":
|
||||
logger.info(
|
||||
|
||||
@@ -18,6 +18,7 @@ A refusal is recorded under the action that was attempted, with its outcome
|
||||
and reason derived from the response status.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
@@ -91,6 +92,11 @@ class AuditViewMixin:
|
||||
self.audit_target = obj
|
||||
super().check_object_permissions(request, obj)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Keep a copy of the target, since deleting an instance clears its pk."""
|
||||
self.audit_target = copy.copy(instance)
|
||||
super().perform_destroy(instance)
|
||||
|
||||
def finalize_response(self, request, response, *args, **kwargs):
|
||||
"""Audit the response once DRF has built it."""
|
||||
response = super().finalize_response(request, response, *args, **kwargs)
|
||||
|
||||
@@ -34,6 +34,19 @@ ROOM_CREATE = audit.Action("room.create")
|
||||
ROOM_LIST = audit.Action("room.list")
|
||||
ROOM_RETRIEVE = audit.Action("room.retrieve")
|
||||
ROOM_UPDATE = audit.Action("room.update")
|
||||
RECORDING_START = audit.Action("recording.start", types=(EventType.START,))
|
||||
RECORDING_STOP = audit.Action("recording.stop", types=(EventType.END,))
|
||||
RECORDING_END = audit.Action("recording.end", types=(EventType.END,))
|
||||
RECORDING_DELETE = audit.Action("recording.delete", types=(EventType.DELETION,))
|
||||
RECORDING_TRANSCRIPT_REQUEST = audit.Action(
|
||||
"recording.transcript.request", types=(EventType.START,)
|
||||
)
|
||||
RECORDING_TRANSCRIPT_REPORT = audit.Action(
|
||||
"recording.transcript.report", types=(EventType.END,)
|
||||
)
|
||||
RECORDING_SUMMARY_REPORT = audit.Action(
|
||||
"recording.summary.report", types=(EventType.END,)
|
||||
)
|
||||
|
||||
# Models
|
||||
|
||||
@@ -75,7 +88,7 @@ audit.register(
|
||||
)
|
||||
audit.register(
|
||||
models.Recording,
|
||||
fields=("room_id", "status", "mode"),
|
||||
fields=("room_id", "status", "mode", "requested_mode", "is_transcribed"),
|
||||
admin_values=("status", "mode"),
|
||||
)
|
||||
audit.register(models.File, admin_values=("title", "upload_state"))
|
||||
|
||||
@@ -702,6 +702,22 @@ class Recording(BaseModel):
|
||||
"""Check if the recording is in a saved state."""
|
||||
return self.status in RecordingStatusChoices.saved_statuses()
|
||||
|
||||
@property
|
||||
def requested_mode(self) -> str:
|
||||
"""Return the mode the user asked for.
|
||||
|
||||
A transcript started with a screen capture is stored as a screen
|
||||
recording, which keeps the transcript as its original mode.
|
||||
"""
|
||||
return (self.options or {}).get("original_mode") or self.mode
|
||||
|
||||
@property
|
||||
def is_transcribed(self) -> bool:
|
||||
"""Tell whether the recording is sent to the summary service."""
|
||||
return self.mode == RecordingModeChoices.TRANSCRIPT or bool(
|
||||
(self.options or {}).get("transcribe", False)
|
||||
)
|
||||
|
||||
@property
|
||||
def extension(self):
|
||||
"""Get recording extension based on its mode."""
|
||||
|
||||
@@ -17,7 +17,7 @@ import requests
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit import api as livekit_api
|
||||
|
||||
from core import models, utils
|
||||
from core import audit, auditing, models, utils
|
||||
from core.analytics import UserFeatureFlag, is_user_feature_flag_enabled
|
||||
from core.utils import generate_download_s3_url
|
||||
|
||||
@@ -52,7 +52,7 @@ class NotificationService:
|
||||
|
||||
if recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
|
||||
summary_success = True
|
||||
if recording.options.get("transcribe", False):
|
||||
if recording.is_transcribed:
|
||||
summary_success = self._notify_summary_service(recording)
|
||||
|
||||
email_success = self._notify_user_by_email(recording)
|
||||
@@ -213,14 +213,30 @@ class NotificationService:
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service(recording: models.Recording):
|
||||
if settings.SUMMARY_SERVICE_VERSION == 1:
|
||||
return NotificationService._notify_summary_service_v1(recording)
|
||||
if settings.SUMMARY_SERVICE_VERSION == 2:
|
||||
return NotificationService._notify_summary_service_v2(recording)
|
||||
|
||||
raise NotImplementedError(
|
||||
f"Unknown summary service version: {settings.SUMMARY_SERVICE_VERSION}"
|
||||
)
|
||||
succeeded = False
|
||||
audit_details = {}
|
||||
try:
|
||||
if settings.SUMMARY_SERVICE_VERSION == 1:
|
||||
succeeded = NotificationService._notify_summary_service_v1(recording)
|
||||
elif settings.SUMMARY_SERVICE_VERSION == 2:
|
||||
succeeded = NotificationService._notify_summary_service_v2(
|
||||
recording, audit_details
|
||||
)
|
||||
else:
|
||||
raise NotImplementedError(
|
||||
"Unknown summary service version: "
|
||||
f"{settings.SUMMARY_SERVICE_VERSION}"
|
||||
)
|
||||
return succeeded
|
||||
finally:
|
||||
audit.log(
|
||||
auditing.RECORDING_TRANSCRIPT_REQUEST,
|
||||
actor_type=audit.ActorType.SYSTEM,
|
||||
target=recording,
|
||||
outcome=audit.Outcome.SUCCESS if succeeded else audit.Outcome.FAILURE,
|
||||
job_id=recording.external_process_id,
|
||||
**audit_details,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service_v1(recording: models.Recording):
|
||||
@@ -298,8 +314,11 @@ class NotificationService:
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service_v2(recording: models.Recording):
|
||||
"""Notify summary service about a new recording."""
|
||||
def _notify_summary_service_v2(recording: models.Recording, audit_details=None):
|
||||
"""Notify summary service about a new recording.
|
||||
|
||||
Whether a summary is asked for is added to ``audit_details``.
|
||||
"""
|
||||
|
||||
if (
|
||||
not settings.SUMMARY_SERVICE_ENDPOINT
|
||||
@@ -349,6 +368,12 @@ class NotificationService:
|
||||
"ended_at": ended_at.isoformat(),
|
||||
}
|
||||
|
||||
summary_requested = is_user_feature_flag_enabled(
|
||||
owner_access.user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
)
|
||||
if audit_details is not None:
|
||||
audit_details["summary_requested"] = summary_requested
|
||||
|
||||
payload = {
|
||||
"user_sub": owner_access.user.sub,
|
||||
"user_email": owner_access.user.email,
|
||||
@@ -372,9 +397,7 @@ class NotificationService:
|
||||
),
|
||||
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||
"form_link": form_link,
|
||||
"auto_create_summary": is_user_feature_flag_enabled(
|
||||
owner_access.user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
),
|
||||
"auto_create_summary": summary_requested,
|
||||
},
|
||||
"metadata": metadata_payload,
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ class RecordingEventsService:
|
||||
@staticmethod
|
||||
def _notify_participants(recording: Recording, event: RecordingWorkerEvent):
|
||||
"""Notify the room's participants that a recording ended on the given event."""
|
||||
recording_mode = recording.options.get("original_mode", None) or recording.mode
|
||||
recording_mode = recording.requested_mode
|
||||
|
||||
notification_type = get_notification_type(recording_mode, event)
|
||||
if not notification_type:
|
||||
|
||||
@@ -68,7 +68,7 @@ class WorkerServiceMediator:
|
||||
finally:
|
||||
recording.save()
|
||||
|
||||
mode = recording.options.get("original_mode", None) or recording.mode
|
||||
mode = recording.requested_mode
|
||||
|
||||
try:
|
||||
RoomManagement.update_metadata(
|
||||
|
||||
@@ -12,8 +12,9 @@ from django.utils import timezone
|
||||
|
||||
from livekit import api
|
||||
|
||||
from core import models
|
||||
from core.recording.enums import RecordingWorkerEvent
|
||||
from core import audit, auditing, models
|
||||
from core.recording.enums import UNSUCCESSFUL_EVENTS, RecordingWorkerEvent
|
||||
from core.recording.event.authentication import MachineUser
|
||||
from core.recording.services.metadata_collector import (
|
||||
MetadataCollectorException,
|
||||
MetadataCollectorService,
|
||||
@@ -219,6 +220,7 @@ class LiveKitEventsService:
|
||||
) from err
|
||||
|
||||
event = to_recording_event(data.egress_info.status)
|
||||
self._audit_recording_end(recording, event, data.egress_info.error_code)
|
||||
|
||||
# Log if/why the recording failed
|
||||
self.recording_events.log_worker_error(
|
||||
@@ -254,6 +256,23 @@ class LiveKitEventsService:
|
||||
|
||||
self.recording_events.handle_terminal_event(recording, event)
|
||||
|
||||
@staticmethod
|
||||
def _audit_recording_end(recording, event, error_code=None):
|
||||
"""Audit the end of a recording, as LiveKit reports it."""
|
||||
audit.log(
|
||||
auditing.RECORDING_END,
|
||||
actor=MachineUser("livekit"),
|
||||
auth_method="shared_secret",
|
||||
target=recording,
|
||||
outcome=(
|
||||
audit.Outcome.FAILURE
|
||||
if event in UNSUCCESSFUL_EVENTS
|
||||
else audit.Outcome.SUCCESS
|
||||
),
|
||||
worker_event=event.value if event is not None else None,
|
||||
error_code=error_code or None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_connection_test_room(room_name: str) -> bool:
|
||||
"""Return True for ephemeral rooms created by the connection test endpoint."""
|
||||
|
||||
@@ -15,6 +15,7 @@ import pytest
|
||||
|
||||
from core import factories, models
|
||||
from core.analytics import UserFeatureFlag
|
||||
from core.audit.testing import find_events
|
||||
from core.recording.event.notification import NotificationService, notification_service
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -479,3 +480,63 @@ def test_notify_summary_service_v2_payload_json_serializable_without_timestamps(
|
||||
assert isinstance(title, str)
|
||||
# ...so the payload serializes exactly the way ``requests`` serializes it.
|
||||
json.dumps(payload)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("summary_requested", [True, False])
|
||||
@mock.patch("core.recording.event.notification.requests.post")
|
||||
@mock.patch("core.recording.event.notification.generate_download_s3_url")
|
||||
@mock.patch.object(
|
||||
NotificationService, "_get_recording_timestamps", new_callable=mock.AsyncMock
|
||||
)
|
||||
def test_notify_summary_service_is_audited( # noqa: PLR0913, PLR0917
|
||||
mock_get_recording_timestamps,
|
||||
mock_generate_download_s3_url,
|
||||
mock_post,
|
||||
summary_requested,
|
||||
settings,
|
||||
audit_events,
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""Sending a recording to be transcribed says whether a summary is asked for."""
|
||||
settings.SUMMARY_SERVICE_VERSION = 2
|
||||
settings.SUMMARY_SERVICE_ENDPOINT = "https://summary.test/api/v2/tasks"
|
||||
settings.SUMMARY_SERVICE_API_TOKEN = "summary-token"
|
||||
settings.METADATA_COLLECTOR_ENABLED = False
|
||||
|
||||
recording = factories.RecordingFactory(mode="transcript")
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER
|
||||
)
|
||||
mock_get_recording_timestamps.return_value = (None, None)
|
||||
mock_generate_download_s3_url.return_value = "https://storage.test/recording.ogg"
|
||||
mock_post.return_value.json.return_value = {"job_id": "job-7"}
|
||||
|
||||
with mock.patch(
|
||||
"core.recording.event.notification.is_user_feature_flag_enabled",
|
||||
return_value=summary_requested,
|
||||
):
|
||||
assert NotificationService._notify_summary_service(recording) is True
|
||||
|
||||
[event] = find_events(audit_events, "recording.transcript.request")
|
||||
|
||||
assert event["lasuite"]["outcome"] == "success"
|
||||
assert event["lasuite"]["actor"] == {"type": "system"}
|
||||
assert event["lasuite"]["target"]["id"] == str(recording.id)
|
||||
assert event["lasuite"]["details"] == {
|
||||
"job_id": "job-7",
|
||||
"summary_requested": summary_requested,
|
||||
}
|
||||
|
||||
|
||||
def test_notify_summary_service_failure_is_audited(settings, audit_events):
|
||||
"""A recording the summary service never received is a failure."""
|
||||
settings.SUMMARY_SERVICE_VERSION = 2
|
||||
settings.SUMMARY_SERVICE_ENDPOINT = None
|
||||
|
||||
recording = factories.RecordingFactory(mode="transcript")
|
||||
|
||||
assert NotificationService._notify_summary_service(recording) is False
|
||||
|
||||
[event] = find_events(audit_events, "recording.transcript.request")
|
||||
|
||||
assert event["lasuite"]["outcome"] == "failure"
|
||||
assert event["lasuite"]["target"]["id"] == str(recording.id)
|
||||
|
||||
@@ -5,6 +5,7 @@ Test recordings API endpoints in the Meet core app: delete.
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...audit.testing import capture_audit, find_events
|
||||
from ...factories import RecordingFactory, UserFactory, UserRecordingAccessFactory
|
||||
from ...models import Recording
|
||||
|
||||
@@ -112,3 +113,24 @@ def test_api_recordings_delete_final(role):
|
||||
|
||||
assert response.status_code == 204
|
||||
assert Recording.objects.count() == 0
|
||||
|
||||
|
||||
def test_api_recordings_delete_is_audited():
|
||||
"""A deleted recording is still identified by the event."""
|
||||
user = UserFactory()
|
||||
recording = RecordingFactory(status="saved", mode="transcript")
|
||||
UserRecordingAccessFactory(role="owner", user=user, recording=recording)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with capture_audit() as events:
|
||||
response = client.delete(f"/api/v1.0/recordings/{recording.id}/")
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
[event] = find_events(events, "recording.delete")
|
||||
|
||||
assert event["event"]["type"] == ["deletion"]
|
||||
assert event["lasuite"]["outcome"] == "success"
|
||||
assert event["lasuite"]["target"]["id"] == str(recording.id)
|
||||
assert event["lasuite"]["target"]["mode"] == "transcript"
|
||||
|
||||
@@ -6,6 +6,7 @@ Test recordings API endpoints: external process hook.
|
||||
|
||||
import pytest
|
||||
|
||||
from ...audit.testing import find_events
|
||||
from ...factories import RecordingFactory
|
||||
from ...models import RecordingStatusChoices
|
||||
|
||||
@@ -132,3 +133,82 @@ def test_external_process_event_non_transcript_event_does_not_change_status(
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == RecordingStatusChoices.SAVED
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"process_type,action",
|
||||
[
|
||||
("transcript", "recording.transcript.report"),
|
||||
("summary", "recording.summary.report"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"process_status,outcome", [("success", "success"), ("failure", "failure")]
|
||||
)
|
||||
def test_external_process_event_is_audited( # noqa: PLR0913, PLR0917
|
||||
external_process_settings,
|
||||
client,
|
||||
audit_events,
|
||||
process_type,
|
||||
action,
|
||||
process_status,
|
||||
outcome,
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""A transcript and a summary are audited apart, as reported by the service."""
|
||||
recording = RecordingFactory(
|
||||
status=RecordingStatusChoices.SAVED, external_process_id="job-123"
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/external-process-hook/",
|
||||
{"job_id": "job-123", "type": process_type, "status": process_status},
|
||||
HTTP_AUTHORIZATION="Bearer testWebhookToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
[event] = find_events(audit_events, action)
|
||||
|
||||
assert event["lasuite"]["outcome"] == outcome
|
||||
assert event["lasuite"]["actor"] == {"type": "service", "name": "summary"}
|
||||
assert event["lasuite"]["target"]["id"] == str(recording.id)
|
||||
assert event["lasuite"]["details"] == {
|
||||
"job_id": "job-123",
|
||||
"status": process_status,
|
||||
}
|
||||
|
||||
|
||||
def test_external_process_event_unknown_recording_is_audited(
|
||||
external_process_settings, client, audit_events
|
||||
):
|
||||
"""An event about an unknown job keeps its job id, with no target."""
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/external-process-hook/",
|
||||
{"job_id": "job-unknown", "type": "summary", "status": "success"},
|
||||
HTTP_AUTHORIZATION="Bearer testWebhookToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
[event] = find_events(audit_events, "recording.summary.report")
|
||||
|
||||
assert "target" not in event["lasuite"]
|
||||
assert event["lasuite"]["details"]["job_id"] == "job-unknown"
|
||||
|
||||
|
||||
def test_external_process_event_refused_is_audited(
|
||||
external_process_settings, client, audit_events
|
||||
):
|
||||
"""A caller without the token is denied under the transcript action."""
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/external-process-hook/",
|
||||
{"job_id": "job-1", "type": "summary", "status": "success"},
|
||||
HTTP_AUTHORIZATION="Bearer wrongToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
[event] = find_events(audit_events, "recording.transcript.report")
|
||||
|
||||
assert event["lasuite"]["outcome"] == "denied"
|
||||
assert not find_events(audit_events, "recording.summary.report")
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Test rooms API endpoints in the Meet core app: start recording.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument,no-member
|
||||
# pylint: disable=redefined-outer-name,unused-argument,no-member,too-many-lines
|
||||
|
||||
from unittest import mock
|
||||
|
||||
@@ -10,6 +10,7 @@ import pytest
|
||||
from livekit import api as livekit_api
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...audit.testing import capture_audit, find_events
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import Recording
|
||||
from ...recording.worker.exceptions import RecordingStartError
|
||||
@@ -879,3 +880,142 @@ def test_start_recording_options_original_mode_invalid(settings, value):
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_start_recording_is_audited(
|
||||
settings, mock_worker_service_factory, mock_worker_manager, audit_events
|
||||
):
|
||||
"""A started screen recording names the recording and its mode."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
recording = Recording.objects.get(room=room)
|
||||
[event] = find_events(audit_events, "recording.start")
|
||||
|
||||
assert event["event"]["type"] == ["start"]
|
||||
assert event["lasuite"]["outcome"] == "success"
|
||||
assert event["user"]["id"] == str(user.pk)
|
||||
assert event["lasuite"]["target"] == {
|
||||
"type": "recording",
|
||||
"id": str(recording.pk),
|
||||
"room_id": str(room.pk),
|
||||
"status": "initiated",
|
||||
"mode": "screen_recording",
|
||||
"requested_mode": "screen_recording",
|
||||
"is_transcribed": False,
|
||||
}
|
||||
assert event["lasuite"]["details"] == {"collect_metadata": False}
|
||||
|
||||
|
||||
def test_start_recording_transcript_with_screen_capture_is_audited(
|
||||
settings, mock_worker_service_factory, mock_worker_manager, audit_events
|
||||
):
|
||||
"""A transcript recorded with the screen is told apart from a screen recording."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{
|
||||
"mode": "screen_recording",
|
||||
"options": {"transcribe": True, "original_mode": "transcript"},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
[event] = find_events(audit_events, "recording.start")
|
||||
target = event["lasuite"]["target"]
|
||||
|
||||
assert target["mode"] == "screen_recording"
|
||||
assert target["requested_mode"] == "transcript"
|
||||
assert target["is_transcribed"] is True
|
||||
|
||||
|
||||
def test_start_recording_conflict_is_audited_on_the_room(
|
||||
settings, mock_worker_service_factory, mock_worker_manager, audit_events
|
||||
):
|
||||
"""A conflicting start is a failure aimed at the room, no recording existing."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
Recording.objects.create(room=room, mode="screen_recording", status="active")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "transcript"},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
|
||||
[event] = find_events(audit_events, "recording.start")
|
||||
|
||||
assert event["lasuite"]["outcome"] == "failure"
|
||||
assert event["event"]["reason"] == "conflict"
|
||||
assert event["lasuite"]["target"]["type"] == "room"
|
||||
assert event["lasuite"]["target"]["id"] == str(room.pk)
|
||||
|
||||
|
||||
def test_start_recording_worker_error_is_audited(
|
||||
settings, mock_worker_service_factory, mock_worker_manager, audit_events
|
||||
):
|
||||
"""A recording the worker could not start is a failure on that recording."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
mock_worker_manager.start = mock.Mock(side_effect=RecordingStartError("boom"))
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "transcript"},
|
||||
)
|
||||
|
||||
assert response.status_code == 502
|
||||
|
||||
[event] = find_events(audit_events, "recording.start")
|
||||
|
||||
assert event["lasuite"]["outcome"] == "failure"
|
||||
assert event["http"]["response"]["status_code"] == 502
|
||||
assert event["lasuite"]["target"]["id"] == str(Recording.objects.get().pk)
|
||||
assert event["lasuite"]["target"]["mode"] == "transcript"
|
||||
assert event["lasuite"]["target"]["is_transcribed"] is True
|
||||
|
||||
|
||||
def test_start_recording_anonymous_is_audited():
|
||||
"""An anonymous attempt is denied."""
|
||||
room = RoomFactory()
|
||||
|
||||
with capture_audit() as events:
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
[event] = find_events(events, "recording.start")
|
||||
|
||||
assert event["lasuite"]["outcome"] == "denied"
|
||||
assert event["lasuite"]["actor"]["type"] == "anonymous"
|
||||
|
||||
@@ -9,6 +9,7 @@ from unittest import mock
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...audit.testing import find_events
|
||||
from ...factories import RecordingFactory, RoomFactory, UserFactory
|
||||
from ...models import Recording, RecordingStatusChoices
|
||||
from ...recording.worker.exceptions import RecordingStopError
|
||||
@@ -181,3 +182,48 @@ def test_stop_recording_success(
|
||||
|
||||
# Verify the recording still exists
|
||||
assert Recording.objects.count() == 1
|
||||
|
||||
|
||||
def test_stop_recording_is_audited(
|
||||
settings, mock_worker_service_factory, mock_worker_manager, audit_events
|
||||
):
|
||||
"""A stopped recording is the target of the event."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
recording = RecordingFactory(
|
||||
room=room, mode="transcript", status=RecordingStatusChoices.ACTIVE
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
[event] = find_events(audit_events, "recording.stop")
|
||||
|
||||
assert event["event"]["type"] == ["end"]
|
||||
assert event["lasuite"]["outcome"] == "success"
|
||||
assert event["lasuite"]["target"]["id"] == str(recording.pk)
|
||||
assert event["lasuite"]["target"]["mode"] == "transcript"
|
||||
|
||||
|
||||
def test_stop_recording_without_active_recording_is_audited(settings, audit_events):
|
||||
"""Stopping a room that records nothing fails on the room."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
[event] = find_events(audit_events, "recording.stop")
|
||||
|
||||
assert event["event"]["reason"] == "not_found"
|
||||
assert event["lasuite"]["target"]["type"] == "room"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Test LiveKitEvents service.
|
||||
"""
|
||||
# pylint: disable=W0621,W0613, W0212, E0611
|
||||
# pylint: disable=W0621,W0613, W0212, E0611, too-many-lines
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
@@ -13,6 +13,7 @@ from django.utils import timezone
|
||||
import pytest
|
||||
from livekit.api import EgressStatus
|
||||
|
||||
from core.audit.testing import find_events
|
||||
from core.factories import RecordingFactory, RoomFactory
|
||||
from core.models import Room
|
||||
from core.recording.enums import RecordingWorkerEvent
|
||||
@@ -994,3 +995,54 @@ def test_participant_left_without_identity_is_ignored(mock_delete, service, sett
|
||||
|
||||
service._handle_participant_left(data) # pylint: disable=protected-access
|
||||
mock_delete.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("egress_status", "worker_event", "outcome"),
|
||||
(
|
||||
(EgressStatus.EGRESS_COMPLETE, "completed", "success"),
|
||||
(EgressStatus.EGRESS_LIMIT_REACHED, "limit reached", "success"),
|
||||
(EgressStatus.EGRESS_ABORTED, "aborted", "failure"),
|
||||
(EgressStatus.EGRESS_FAILED, "failed", "failure"),
|
||||
),
|
||||
)
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_is_audited( # noqa: PLR0913, PLR0917
|
||||
mock_update_metadata,
|
||||
mock_notify,
|
||||
mock_notify_external_services,
|
||||
egress_status,
|
||||
worker_event,
|
||||
outcome,
|
||||
service,
|
||||
audit_events,
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""The end of a recording is audited as LiveKit reports it."""
|
||||
|
||||
recording = RecordingFactory(
|
||||
worker_id="worker-1",
|
||||
status="active",
|
||||
mode="screen_recording",
|
||||
options={"transcribe": True},
|
||||
)
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = egress_status
|
||||
mock_data.egress_info.error_code = 0
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
[event] = find_events(audit_events, "recording.end")
|
||||
|
||||
assert event["event"]["type"] == ["end"]
|
||||
assert event["lasuite"]["outcome"] == outcome
|
||||
assert event["lasuite"]["actor"] == {"type": "service", "name": "livekit"}
|
||||
assert event["lasuite"]["auth"] == {"method": "shared_secret"}
|
||||
assert event["lasuite"]["target"]["id"] == str(recording.id)
|
||||
assert event["lasuite"]["target"]["is_transcribed"] is True
|
||||
assert event["lasuite"]["details"] == {"worker_event": worker_event}
|
||||
|
||||
Reference in New Issue
Block a user