Skip to content

Commit 0aa2b80

Browse files
committed
📝(docs) add recording delegate design spec
Design spec for a lightweight recording delegation system as an alternative approach to PR #794's global permission model. Instead of opening recording rights to all authenticated users, this proposes granular per-user delegation with request/approve flow and auto-approval when no admin is present.
1 parent 04be495 commit 0aa2b80

1 file changed

Lines changed: 318 additions & 0 deletions

File tree

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
# Recording Delegate — Design Spec
2+
3+
## Problem
4+
5+
When a meeting organizer is absent, no one can start recording or transcription because only room admins/owners have that permission. This is a common scenario (secretary creates meetings for executives, organizer can't attend, etc.).
6+
7+
The PR #794 proposed opening recording permissions to all authenticated users, but this doesn't fit multi-tenant deployments where authenticated users may belong to different organizations.
8+
9+
## Solution
10+
11+
A lightweight **Recording Delegate** system that lets admins/owners grant recording-specific rights to individual users, with a request/approve flow for live meetings and auto-approval when no admin is present.
12+
13+
## Current State
14+
15+
### Backend
16+
17+
- `HasPrivilegesOnRoom` permission class is used on `start-recording` and `stop-recording` actions in `RoomViewSet` (viewsets.py:299, 349). This checks `is_administrator_or_owner()`.
18+
- `ResourceAccess` model with roles: `owner`, `administrator`, `member`.
19+
- No backend-to-client DataChannel messaging exists. The backend uses the LiveKit Server SDK only for webhooks, mute/remove/update participant operations via `ParticipantsManagement` service. There is no `ListParticipants` or `SendData` call.
20+
21+
### Frontend
22+
23+
- `NoAccessView` component already includes a `RequestRecording` button and a `handleRequest` prop.
24+
- `ScreenRecordingSidePanel` already sends a `ScreenRecordingRequested` notification via DataChannel (client-to-client).
25+
- `useHasRecordingAccess` hook checks `useIsAdminOrOwner()` to determine recording access.
26+
- All DataChannel notifications are sent client-side via `useNotifyParticipants``room.localParticipant.publishData()`.
27+
28+
### What needs to change
29+
30+
- Replace `HasPrivilegesOnRoom` with `HasRecordingPermission` on recording endpoints (also check delegate status).
31+
- Update `useHasRecordingAccess` to also check delegate status.
32+
- Extend the existing `NoAccessView` request flow with backend-backed approval (currently it only sends a client-side notification with no persistence).
33+
- Add `ListParticipants` capability to `ParticipantsManagement` service.
34+
- Add backend-to-client notification capability via LiveKit Server SDK `RoomService.send_data()`.
35+
36+
## Design Decisions
37+
38+
- **Separate from the role system**: `RecordingDelegate` is a standalone model, not a new `RoleChoices` entry. This avoids polluting the existing owner/admin/member hierarchy and is easy to remove when a more advanced multi-admin system is built.
39+
- **Session or permanent**: the admin choosing to grant rights decides whether the delegation is for the current session only or permanent.
40+
- **Auto-approve for authenticated users**: when no admin/owner is present in the room, an authenticated user's request is auto-approved after 30s. This covers the "absent organizer" scenario without opening permissions globally.
41+
- **Notifications are hybrid**: client-to-client for immediate UX feedback (request/grant/revoke), backend-to-client for auto-approve (only the backend knows when the 30s timer fires).
42+
43+
## Data Model
44+
45+
### RecordingDelegate
46+
47+
| Field | Type | Description |
48+
|---|---|---|
49+
| `id` | UUID (PK) | Primary key |
50+
| `room` | FK → Room | The room this delegation applies to |
51+
| `user` | FK → User (CASCADE) | The delegated user |
52+
| `status` | CharField | `pending` or `approved` |
53+
| `is_permanent` | Boolean (default=False) | False = session-only, True = persists across meetings |
54+
| `granted_by` | FK → User (nullable, SET_NULL) | Who granted the rights. Null = auto-approved |
55+
| `created_at` | DateTime (auto) | Timestamp |
56+
57+
**Constraints:**
58+
- Unique together: `(room, user)`
59+
60+
The `status` field tracks pending requests in the database (not just Redis), so the `/approve/` endpoint can look up what it's approving, and the GET list can show pending requests to admins.
61+
62+
### Permission check
63+
64+
`HasRecordingPermission` replaces `HasPrivilegesOnRoom` on `start-recording` and `stop-recording` actions. It checks in order:
65+
1. User is admin/owner of the room → allowed
66+
2. User has a `RecordingDelegate` entry with `status=approved` for this room → allowed
67+
3. Otherwise → denied
68+
69+
Delegates can both start AND stop recordings (a delegate who starts a recording can stop it).
70+
71+
## API Endpoints
72+
73+
All endpoints nested under `/api/v1.0/rooms/{room_id}/recording-delegates/`.
74+
75+
| Method | Path | Permission | Description |
76+
|---|---|---|---|
77+
| `GET` | `/` | Admin/Owner | List delegates for this room (includes pending) |
78+
| `POST` | `/` | Admin/Owner | Grant recording rights (direct, status=approved) |
79+
| `DELETE` | `/{id}/` | Admin/Owner | Revoke a delegate |
80+
| `POST` | `/request/` | Authenticated | Request recording rights (creates status=pending) |
81+
| `POST` | `/{id}/approve/` | Admin/Owner | Approve a pending request |
82+
| `POST` | `/{id}/reject/` | Admin/Owner | Reject a pending request (deletes the entry) |
83+
84+
### Payloads
85+
86+
**POST (grant):**
87+
```json
88+
{
89+
"user": "uuid",
90+
"is_permanent": false
91+
}
92+
```
93+
94+
**POST approve:**
95+
```json
96+
{
97+
"is_permanent": false
98+
}
99+
```
100+
101+
**POST (request):**
102+
No body needed — user is derived from `request.user`.
103+
104+
**GET (list) response:**
105+
```json
106+
[
107+
{
108+
"id": "delegate-uuid",
109+
"user": { "id": "user-uuid", "name": "Jean Dupont" },
110+
"status": "approved",
111+
"is_permanent": true,
112+
"granted_by": { "id": "admin-uuid", "name": "Marie Martin" },
113+
"created_at": "2026-03-20T10:00:00Z"
114+
}
115+
]
116+
```
117+
118+
## New Backend Infrastructure
119+
120+
### LiveKit ListParticipants
121+
122+
Add a `list_participants(room_name)` method to `ParticipantsManagement` service using `livekit.api.RoomService.list_participants()`. This returns the list of currently connected participants with their identity (which maps to the user ID set when generating the LiveKit token).
123+
124+
### LiveKit SendData (backend → client)
125+
126+
Add a `send_data(room_name, data, participant_identities)` method to `ParticipantsManagement` service using `livekit.api.RoomService.send_data()`. This is needed for the auto-approve flow where the backend must notify the requester after the Celery timer fires.
127+
128+
### Participant identity mapping
129+
130+
LiveKit participant `identity` is set to the Django user's UUID string when the LiveKit token is generated. The `list_participants` response provides these identities, which can be directly matched against `ResourceAccess.user_id` to determine which participants are admins/owners.
131+
132+
## Delegation Flows
133+
134+
### Flow 1: Push by admin (direct grant)
135+
136+
1. Admin clicks "Grant recording rights" on a participant
137+
2. `POST /recording-delegates/` with `{ user, is_permanent }`
138+
3. `RecordingDelegate` created with `status=approved, granted_by=admin`
139+
4. Admin's frontend sends DataChannel notification to participant: `RecordingRightsGranted`
140+
5. Participant sees recording buttons appear
141+
142+
### Flow 2: Request by participant
143+
144+
1. Participant clicks "Request recording rights"
145+
2. `POST /recording-delegates/request/`
146+
3. Backend creates `RecordingDelegate` with `status=pending`
147+
4. Backend calls `list_participants` and cross-references with `ResourceAccess` to check admin presence
148+
149+
**Case A — Admin present:**
150+
5. Requester's frontend sends DataChannel notification to admins: `RecordingRightsRequested` (with delegate ID and user info)
151+
6. Admin sees popup: "X requests recording rights" [Session only] [Permanent] [Reject]
152+
7. Admin clicks → `POST /recording-delegates/{id}/approve/` or `/{id}/reject/`
153+
8. Backend updates `RecordingDelegate` status to `approved` (or deletes on reject)
154+
9. Admin's frontend sends DataChannel notification to requester: `RecordingRightsGranted` or `RecordingRightsRejected`
155+
156+
**Case B — No admin present:**
157+
5. Backend schedules a Celery task with `countdown=30` seconds, storing the task ID in cache as `auto_approve:{delegate_id}`
158+
6. API response includes `auto_approve_seconds: 30`
159+
7. Frontend shows countdown: "No admin present. Auto-approval in 30s..."
160+
8. After 30s, Celery task fires:
161+
- Re-checks the `RecordingDelegate` still exists and is still `pending` (requester may have left)
162+
- Re-checks no admin is present via `list_participants`
163+
- If both conditions met: updates to `status=approved, granted_by=null, is_permanent=False`
164+
- Sends notification via `RoomService.send_data()`: `RecordingRightsGranted`
165+
- If an admin is now present: does nothing (admin will handle via Case A)
166+
9. If an admin connects during the 30s:
167+
- Admin's frontend fetches pending requests via `GET /recording-delegates/?status=pending`
168+
- Admin sees and handles the request (Case A flow)
169+
- When admin approves/rejects, the `status` changes and the Celery task's re-check at step 8 will find it's no longer `pending` → no-op
170+
171+
### Flow 3: Pre-meeting
172+
173+
1. Owner/admin goes to room management page
174+
2. Searches for users and adds them as recording delegates
175+
3. `POST /recording-delegates/` with `{ user, is_permanent: true }`
176+
177+
### Revocation
178+
179+
1. Admin/owner clicks revoke on a delegate
180+
2. `DELETE /recording-delegates/{id}/`
181+
3. Admin's frontend sends DataChannel notification to participant: `RecordingRightsRevoked`
182+
4. Recording buttons disappear in real-time
183+
5. Any active recording started by this delegate continues to completion
184+
185+
## Real-time Communication
186+
187+
### Notification types
188+
189+
| Type | Mechanism | Sent by | Sent to | Trigger |
190+
|---|---|---|---|---|
191+
| `RecordingRightsRequested` | DataChannel (client) | Requester's browser | Admins in room | Participant requests rights |
192+
| `RecordingRightsGranted` | DataChannel (client) or SendData (backend for auto-approve) | Admin's browser / backend | Requester | Approved or auto-approved |
193+
| `RecordingRightsRejected` | DataChannel (client) | Admin's browser | Requester | Rejected |
194+
| `RecordingRightsRevoked` | DataChannel (client) | Admin's browser | The delegate | Admin revokes rights |
195+
196+
## Frontend Components
197+
198+
### Participant side (authenticated, non-admin)
199+
200+
Extend the existing `NoAccessView` component in `ScreenRecordingSidePanel`. The existing `RequestRecording` button and `handleRequest` prop are reused but connected to the new backend API instead of the current client-only notification.
201+
202+
States: `idle``pending` (with 30s countdown if auto-approve) → `granted` / `rejected`
203+
204+
Once `granted`: standard start/stop recording buttons appear.
205+
206+
### Admin side
207+
208+
- **Toast/popup** on incoming `RecordingRightsRequested` notification: "X requests recording rights" with actions [Session only] [Permanent] [Reject]
209+
- **Participant context menu**: "Grant recording rights" → sub-menu [Session] [Permanent]
210+
- On room join, fetch pending requests via `GET /recording-delegates/?status=pending` to catch requests made before the admin connected
211+
212+
### Room management page (pre-meeting)
213+
214+
New "Recording Delegation" section in the Admin panel:
215+
- User search field + list of current delegates with revoke button
216+
- Only visible to admin/owner
217+
218+
### Hooks
219+
220+
**`useRecordingDelegate(roomId)`**
221+
```
222+
→ { isDelegate, requestRights(), pendingRequest, countdown }
223+
```
224+
225+
**Update `useHasRecordingAccess`** to also return `true` when the user is a delegate (`status=approved`).
226+
227+
## Cleanup
228+
229+
### Definition of "session"
230+
231+
A session corresponds to a LiveKit room lifecycle (first participant joins → last participant leaves). Non-permanent delegates are cleaned up when the room ends, with a **5-minute grace period** to handle brief disconnections (all participants drop and reconnect quickly).
232+
233+
### Primary: LiveKit webhook `room_finished`
234+
235+
When `room_finished` fires, schedule a Celery task with `countdown=300` (5 minutes). When the task runs:
236+
- Check if the room is still empty via `list_participants`
237+
- If empty: delete all `RecordingDelegate` entries with `is_permanent=False` for that room
238+
- If participants are back: do nothing (new session started)
239+
240+
### Safety net: Celery periodic task
241+
242+
Every 6 hours, delete non-permanent delegates with `created_at` older than 24h. Covers missed webhooks. The 24h window is generous enough to cover multi-hour meetings.
243+
244+
## Edge Cases
245+
246+
| Case | Behavior |
247+
|---|---|
248+
| Delegate starts recording then is revoked | Active recording continues. Revocation prevents new recordings. |
249+
| Two participants request simultaneously (auto-approve) | Independent requests. Both get rights after 30s. |
250+
| Admin arrives during 30s countdown | Admin fetches pending requests on join. Celery task re-checks status before approving — if admin already handled it, task is a no-op. |
251+
| Requester leaves room during countdown | Frontend does not cancel the pending — Celery task re-checks the delegate still exists. If the requester deleted their request on leave, task is a no-op. |
252+
| Permanent delegate's user deleted | FK CASCADE removes the delegate entry. |
253+
| Duplicate request by same user | Unique constraint `(room, user)` prevents duplicates. Returns 200 with existing delegate if already exists. |
254+
| Brief room-empty gap (all disconnect/reconnect) | 5-minute grace period on `room_finished` prevents premature cleanup. |
255+
256+
## Security
257+
258+
- **Authentication required**: `IsAuthenticated` on `/request/` endpoint
259+
- **Rate limiting**: `SessionExchangeAnonRateThrottle`-style throttle on `/request/` — 5 requests/min per user per room. Returns HTTP 429 when exceeded.
260+
- **Audit trail**: `granted_by` field traces who granted (null = auto-approved), `created_at` for timing
261+
- **No anonymous auto-approve**: only authenticated users can trigger auto-approval
262+
- **Celery task safety**: auto-approve task re-checks both `status=pending` and admin absence before granting — no race condition
263+
264+
## Tests
265+
266+
### Backend (pytest)
267+
268+
**Model:**
269+
- CRUD operations on `RecordingDelegate`
270+
- Unique constraint `(room, user)` enforced
271+
- CASCADE delete on user/room deletion
272+
- Status transitions: pending → approved, pending → deleted (reject)
273+
274+
**Permissions:**
275+
- Admin/owner can start-recording (unchanged)
276+
- Delegate (status=approved) can start-recording → 201
277+
- Delegate (status=pending) cannot start-recording → 403
278+
- Authenticated non-delegate → 403
279+
- Anonymous → 401
280+
- Revoked delegate → 403
281+
- Delegate can stop-recording they started → 200
282+
283+
**API:**
284+
- POST delegate: admin → 201, non-admin → 403
285+
- DELETE delegate: admin → 204, non-admin → 403
286+
- POST request: authenticated → 201 (pending created), anonymous → 401, existing delegate → 200
287+
- POST approve: admin → 200 (status updated), non-admin → 403
288+
- POST reject: admin → 200 (delegate deleted), non-admin → 403
289+
- GET list: admin sees pending + approved, non-admin → 403
290+
291+
**Auto-approve:**
292+
- Request with no admin present → Celery task scheduled
293+
- After 30s, task fires → delegate approved with `granted_by=null`
294+
- Admin present when task fires → task is no-op
295+
- Delegate no longer pending when task fires → task is no-op
296+
- Requester deleted request → task is no-op
297+
298+
**Cleanup:**
299+
- Webhook `room_finished` + 5min grace → non-permanent delegates deleted
300+
- Room not empty after grace period → delegates preserved
301+
- Permanent delegates → preserved
302+
- Celery periodic task → delegates older than 24h deleted
303+
304+
**New infrastructure:**
305+
- `list_participants` returns correct participant identities
306+
- `send_data` delivers notification to specific participant
307+
- Participant identity maps to user UUID
308+
309+
### Frontend (vitest)
310+
311+
- `useRecordingDelegate`: states idle/pending/granted/rejected
312+
- `useHasRecordingAccess`: returns true for delegates
313+
- Request button visible for authenticated non-admin, hidden for anonymous
314+
- Recording buttons visible after granted
315+
- Admin popup: all 3 actions work (session/permanent/reject)
316+
- Admin fetches pending requests on room join
317+
- 30s countdown displayed correctly during auto-approve
318+
- Revocation removes recording buttons in real-time

0 commit comments

Comments
 (0)