Skip to content

Commit 557e683

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. Adds RecordingPermissionField component in admin panel, notification system for permission changes, input validation via serializer, and i18n support (en, fr, de, nl).
1 parent d308b58 commit 557e683

14 files changed

Lines changed: 547 additions & 83 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.10.0] - 2026-03-05
1616

src/backend/core/api/permissions.py

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

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

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

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

133-
permission_level = self._get_permission_level(mode)
140+
permission_level = self._get_permission_level(mode, room=obj)
134141

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

src/backend/core/api/serializers.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@
88

99
from django.conf import settings
1010
from django.core.exceptions import SuspiciousOperation
11-
12-
# pylint: disable=abstract-method,no-name-in-module
1311
from django.utils.translation import gettext_lazy as _
1412

1513
from django_pydantic_field.rest_framework import SchemaField
@@ -123,6 +121,9 @@ class Meta:
123121
read_only_fields = ["id", "slug"]
124122

125123

124+
VALID_RECORDING_PERMISSIONS = {"admin_owner", "authenticated"}
125+
126+
126127
class RoomSerializer(serializers.ModelSerializer):
127128
"""Serialize Room model for the API."""
128129

@@ -131,6 +132,19 @@ class Meta:
131132
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
132133
read_only_fields = ["id", "slug", "pin_code"]
133134

135+
def validate_configuration(self, value):
136+
"""Validate recording permission values in configuration."""
137+
if not isinstance(value, dict):
138+
return value
139+
140+
for key in ("screen_recording_permission", "transcript_permission"):
141+
if key in value and value[key] not in VALID_RECORDING_PERMISSIONS:
142+
raise serializers.ValidationError(
143+
{key: f"Must be one of: {', '.join(sorted(VALID_RECORDING_PERMISSIONS))}"}
144+
)
145+
146+
return value
147+
134148
def to_representation(self, instance):
135149
"""
136150
Add users only for administrator users.
@@ -157,6 +171,17 @@ def to_representation(self, instance):
157171

158172
configuration = output["configuration"]
159173

174+
output["recording_permissions"] = {
175+
"screen_recording_permission": configuration.get(
176+
"screen_recording_permission",
177+
settings.RECORDING_SCREEN_PERMISSION,
178+
),
179+
"transcript_permission": configuration.get(
180+
"transcript_permission",
181+
settings.RECORDING_TRANSCRIPT_PERMISSION,
182+
),
183+
}
184+
160185
if not is_admin_or_owner:
161186
del output["configuration"]
162187

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: 12 additions & 1 deletion
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
}
@@ -135,7 +146,7 @@ export const MainNotificationToast = () => {
135146
return () => {
136147
room.off(RoomEvent.DataReceived, handleDataReceived)
137148
}
138-
}, [room])
149+
}, [room, roomId])
139150

140151
useEffect(() => {
141152
const showJoinNotification = (participant: Participant) => {

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
}

src/frontend/src/features/rooms/api/ApiRoom.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { RecordingPermission } from '@/features/recording/types'
2+
13
export type ApiLiveKit = {
24
url: string
35
room: string
@@ -21,4 +23,8 @@ export type ApiRoom = {
2123
configuration?: {
2224
[key: string]: string | number | boolean | string[]
2325
}
26+
recording_permissions?: {
27+
screen_recording_permission?: RecordingPermission
28+
transcript_permission?: RecordingPermission
29+
}
2430
}

0 commit comments

Comments
 (0)