|
| 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