Skip to content

Commit 0312971

Browse files
committed
✨(backend,frontend) add per-room recording permissions with admin panel controls
Allow room admins to override global recording permissions (screen recording and transcription) on a per-room basis via the admin panel. Non-admin users see the effective permission through a read-only recording_permissions field. Includes input validation, fallback to global settings, and i18n support.
1 parent 1e52555 commit 0312971

17 files changed

Lines changed: 583 additions & 71 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ and this project adheres to
1010

1111
### Added
1212

13-
- ✨(backend,frontend) add configurable recording permissions
13+
- ✨(backend,frontend) add per-room configurable recording permissions with admin panel controls
1414

1515
## [1.6.0] - 2026-02-10
1616

src/backend/core/api/permissions.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -106,13 +106,20 @@ class HasRecordingPermission(IsAuthenticated):
106106

107107
message = "You do not have permission to perform this recording action."
108108

109-
def _get_permission_level(self, mode):
110-
"""Return the permission level for the given mode."""
109+
def _get_permission_level(self, mode, room=None):
110+
"""Return the permission level for the given mode, checking room config first."""
111111
if mode == "screen_recording":
112-
return getattr(settings, "RECORDING_SCREEN_PERMISSION", "admin_owner")
113-
if mode == "transcript":
114-
return getattr(settings, "RECORDING_TRANSCRIPT_PERMISSION", "admin_owner")
115-
return "admin_owner"
112+
key = "screen_recording_permission"
113+
default = getattr(settings, "RECORDING_SCREEN_PERMISSION", "admin_owner")
114+
elif mode == "transcript":
115+
key = "transcript_permission"
116+
default = getattr(settings, "RECORDING_TRANSCRIPT_PERMISSION", "admin_owner")
117+
else:
118+
return "admin_owner"
119+
120+
if room and room.configuration:
121+
return room.configuration.get(key, default)
122+
return default
116123

117124
def has_object_permission(self, request, view, obj):
118125
"""Check object-level permissions based on recording mode."""
@@ -129,7 +136,7 @@ def has_object_permission(self, request, view, obj):
129136
# No active recording, let the view handle the error
130137
return True
131138

132-
permission_level = self._get_permission_level(mode)
139+
permission_level = self._get_permission_level(mode, room=obj)
133140

134141
if permission_level == "authenticated":
135142
# Already authenticated via IsAuthenticated.has_permission

src/backend/core/api/serializers.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
# pylint: disable=abstract-method,no-name-in-module
44

5+
from django.conf import settings
56
from django.utils.translation import gettext_lazy as _
67

78
from livekit.api import ParticipantPermission
@@ -103,6 +104,9 @@ class Meta:
103104
read_only_fields = ["id", "slug"]
104105

105106

107+
VALID_RECORDING_PERMISSIONS = {"admin_owner", "authenticated"}
108+
109+
106110
class RoomSerializer(serializers.ModelSerializer):
107111
"""Serialize Room model for the API."""
108112

@@ -111,6 +115,19 @@ class Meta:
111115
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
112116
read_only_fields = ["id", "slug", "pin_code"]
113117

118+
def validate_configuration(self, value):
119+
"""Validate recording permission values in configuration."""
120+
if not isinstance(value, dict):
121+
return value
122+
123+
for key in ("screen_recording_permission", "transcript_permission"):
124+
if key in value and value[key] not in VALID_RECORDING_PERMISSIONS:
125+
raise serializers.ValidationError(
126+
{key: f"Must be one of: {', '.join(sorted(VALID_RECORDING_PERMISSIONS))}"}
127+
)
128+
129+
return value
130+
114131
def to_representation(self, instance):
115132
"""
116133
Add users only for administrator users.
@@ -137,6 +154,17 @@ def to_representation(self, instance):
137154

138155
configuration = output["configuration"]
139156

157+
output["recording_permissions"] = {
158+
"screen_recording_permission": configuration.get(
159+
"screen_recording_permission",
160+
settings.RECORDING_SCREEN_PERMISSION,
161+
),
162+
"transcript_permission": configuration.get(
163+
"transcript_permission",
164+
settings.RECORDING_TRANSCRIPT_PERMISSION,
165+
),
166+
}
167+
140168
if not is_admin_or_owner:
141169
del output["configuration"]
142170

src/backend/core/tests/rooms/test_api_rooms_recording_permissions.py

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,3 +418,231 @@ def test_stop_recording_follows_mode_permission_authenticated(
418418
# Any authenticated user should be able to stop
419419
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
420420
assert response.status_code == 200
421+
422+
423+
# =============================================================================
424+
# Tests for per-room recording permission overrides via room.configuration
425+
# =============================================================================
426+
427+
428+
def test_room_config_overrides_global_screen_recording_permission(
429+
mock_worker_service_factory, mock_worker_manager, settings
430+
):
431+
"""Room configuration should override global screen recording permission."""
432+
settings.RECORDING_ENABLE = True
433+
settings.RECORDING_SCREEN_PERMISSION = "admin_owner"
434+
435+
room = RoomFactory(configuration={"screen_recording_permission": "authenticated"})
436+
user = UserFactory()
437+
client = APIClient()
438+
client.force_login(user)
439+
440+
# Global is admin_owner, but room overrides to authenticated
441+
response = client.post(
442+
f"/api/v1.0/rooms/{room.id}/start-recording/",
443+
{"mode": "screen_recording"},
444+
)
445+
assert response.status_code == 201
446+
assert Recording.objects.count() == 1
447+
448+
449+
def test_room_config_overrides_global_transcript_permission(
450+
mock_worker_service_factory, mock_worker_manager, settings
451+
):
452+
"""Room configuration should override global transcript permission."""
453+
settings.RECORDING_ENABLE = True
454+
settings.RECORDING_TRANSCRIPT_PERMISSION = "admin_owner"
455+
456+
room = RoomFactory(configuration={"transcript_permission": "authenticated"})
457+
user = UserFactory()
458+
client = APIClient()
459+
client.force_login(user)
460+
461+
# Global is admin_owner, but room overrides to authenticated
462+
response = client.post(
463+
f"/api/v1.0/rooms/{room.id}/start-recording/",
464+
{"mode": "transcript"},
465+
)
466+
assert response.status_code == 201
467+
assert Recording.objects.count() == 1
468+
469+
470+
def test_room_config_restricts_when_global_is_permissive(settings):
471+
"""Room configuration can restrict permissions even when global is permissive."""
472+
settings.RECORDING_ENABLE = True
473+
settings.RECORDING_SCREEN_PERMISSION = "authenticated"
474+
475+
room = RoomFactory(configuration={"screen_recording_permission": "admin_owner"})
476+
user = UserFactory()
477+
client = APIClient()
478+
client.force_login(user)
479+
480+
# Global is authenticated, but room overrides to admin_owner
481+
response = client.post(
482+
f"/api/v1.0/rooms/{room.id}/start-recording/",
483+
{"mode": "screen_recording"},
484+
)
485+
assert response.status_code == 403
486+
assert Recording.objects.count() == 0
487+
488+
489+
def test_room_config_empty_falls_back_to_global(settings):
490+
"""Without room configuration override, global permission applies."""
491+
settings.RECORDING_ENABLE = True
492+
settings.RECORDING_SCREEN_PERMISSION = "admin_owner"
493+
494+
room = RoomFactory(configuration={})
495+
user = UserFactory()
496+
client = APIClient()
497+
client.force_login(user)
498+
499+
# No room override, global admin_owner applies
500+
response = client.post(
501+
f"/api/v1.0/rooms/{room.id}/start-recording/",
502+
{"mode": "screen_recording"},
503+
)
504+
assert response.status_code == 403
505+
assert Recording.objects.count() == 0
506+
507+
508+
def test_recording_permissions_in_room_response_for_admin(settings):
509+
"""recording_permissions should be present in room response for admin users."""
510+
settings.RECORDING_SCREEN_PERMISSION = "admin_owner"
511+
settings.RECORDING_TRANSCRIPT_PERMISSION = "authenticated"
512+
513+
room = RoomFactory()
514+
user = UserFactory()
515+
room.accesses.create(user=user, role="administrator")
516+
517+
client = APIClient()
518+
client.force_login(user)
519+
520+
response = client.get(f"/api/v1.0/rooms/{room.id}/")
521+
assert response.status_code == 200
522+
assert "recording_permissions" in response.json()
523+
assert (
524+
response.json()["recording_permissions"]["screen_recording_permission"]
525+
== "admin_owner"
526+
)
527+
assert (
528+
response.json()["recording_permissions"]["transcript_permission"]
529+
== "authenticated"
530+
)
531+
532+
533+
def test_recording_permissions_in_room_response_for_non_admin(settings):
534+
"""recording_permissions should be present in room response for non-admin users."""
535+
settings.RECORDING_SCREEN_PERMISSION = "authenticated"
536+
settings.RECORDING_TRANSCRIPT_PERMISSION = "admin_owner"
537+
538+
room = RoomFactory()
539+
user = UserFactory()
540+
room.accesses.create(user=user, role="member")
541+
542+
client = APIClient()
543+
client.force_login(user)
544+
545+
response = client.get(f"/api/v1.0/rooms/{room.id}/")
546+
assert response.status_code == 200
547+
assert "recording_permissions" in response.json()
548+
assert (
549+
response.json()["recording_permissions"]["screen_recording_permission"]
550+
== "authenticated"
551+
)
552+
assert (
553+
response.json()["recording_permissions"]["transcript_permission"]
554+
== "admin_owner"
555+
)
556+
# configuration should NOT be visible to non-admin
557+
assert "configuration" not in response.json()
558+
559+
560+
def test_recording_permissions_reflect_room_override(settings):
561+
"""recording_permissions should reflect room configuration override."""
562+
settings.RECORDING_SCREEN_PERMISSION = "admin_owner"
563+
settings.RECORDING_TRANSCRIPT_PERMISSION = "admin_owner"
564+
565+
room = RoomFactory(
566+
configuration={
567+
"screen_recording_permission": "authenticated",
568+
"transcript_permission": "authenticated",
569+
}
570+
)
571+
user = UserFactory()
572+
room.accesses.create(user=user, role="member")
573+
574+
client = APIClient()
575+
client.force_login(user)
576+
577+
response = client.get(f"/api/v1.0/rooms/{room.id}/")
578+
assert response.status_code == 200
579+
assert (
580+
response.json()["recording_permissions"]["screen_recording_permission"]
581+
== "authenticated"
582+
)
583+
assert (
584+
response.json()["recording_permissions"]["transcript_permission"]
585+
== "authenticated"
586+
)
587+
588+
589+
def test_admin_can_patch_room_recording_config(settings):
590+
"""Admin should be able to patch room configuration with recording permissions."""
591+
settings.RECORDING_ENABLE = True
592+
settings.RECORDING_SCREEN_PERMISSION = "admin_owner"
593+
594+
room = RoomFactory()
595+
user = UserFactory()
596+
room.accesses.create(user=user, role="administrator")
597+
598+
client = APIClient()
599+
client.force_login(user)
600+
601+
response = client.patch(
602+
f"/api/v1.0/rooms/{room.id}/",
603+
{"configuration": {"screen_recording_permission": "authenticated"}},
604+
format="json",
605+
)
606+
assert response.status_code == 200
607+
assert (
608+
response.json()["recording_permissions"]["screen_recording_permission"]
609+
== "authenticated"
610+
)
611+
612+
613+
def test_patch_room_rejects_invalid_recording_permission(settings):
614+
"""Invalid recording permission values should be rejected with 400."""
615+
settings.RECORDING_ENABLE = True
616+
617+
room = RoomFactory()
618+
user = UserFactory()
619+
room.accesses.create(user=user, role="administrator")
620+
621+
client = APIClient()
622+
client.force_login(user)
623+
624+
response = client.patch(
625+
f"/api/v1.0/rooms/{room.id}/",
626+
{"configuration": {"screen_recording_permission": "everyone"}},
627+
format="json",
628+
)
629+
assert response.status_code == 400
630+
631+
632+
def test_patch_room_rejects_invalid_transcript_permission(settings):
633+
"""Invalid transcript permission values should be rejected with 400."""
634+
settings.RECORDING_ENABLE = True
635+
636+
room = RoomFactory()
637+
user = UserFactory()
638+
room.accesses.create(user=user, role="administrator")
639+
640+
client = APIClient()
641+
client.force_login(user)
642+
643+
response = client.patch(
644+
f"/api/v1.0/rooms/{room.id}/",
645+
{"configuration": {"transcript_permission": "foobar"}},
646+
format="json",
647+
)
648+
assert response.status_code == 400

src/frontend/src/features/notifications/MainNotificationToast.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ import { decodeNotificationDataReceived } from './utils'
1010
import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification'
1111
import { ToastProvider, toastQueue } from './components/ToastProvider'
1212
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
13+
import { queryClient } from '@/api/queryClient'
14+
import { keys } from '@/api/queryKeys'
15+
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
16+
import { useParams } from 'wouter'
1317
import {
1418
Emoji,
1519
Reaction,
@@ -22,6 +26,7 @@ import {
2226
export const MainNotificationToast = () => {
2327
const room = useRoomContext()
2428
const { triggerNotificationSound } = useNotificationSound()
29+
const { roomId } = useParams()
2530

2631
const [reactions, setReactions] = useState<Reaction[]>([])
2732
const instanceIdRef = useRef(0)
@@ -127,6 +132,12 @@ export const MainNotificationToast = () => {
127132
)
128133
break
129134
}
135+
case NotificationType.RecordingPermissionsChanged:
136+
queryClient.fetchQuery({
137+
queryKey: [keys.room, roomId],
138+
queryFn: () => fetchRoom({ roomId: roomId! }),
139+
})
140+
break
130141
default:
131142
return
132143
}

src/frontend/src/features/notifications/NotificationType.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ export enum NotificationType {
1616
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
1717
RecordingSaving = 'recordingSaving',
1818
PermissionsRemoved = 'permissionsRemoved',
19+
RecordingPermissionsChanged = 'recordingPermissionsChanged',
1920
}

0 commit comments

Comments
 (0)