From 0aa2b80be9aa68de6b38304d24c8d67919cf8479 Mon Sep 17 00:00:00 2001 From: Michel-Marie MAUDET Date: Fri, 20 Mar 2026 06:19:36 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=9D(docs)=20add=20recording=20delegate?= =?UTF-8?q?=20design=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../2026-03-20-recording-delegate-design.md | 318 ++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-20-recording-delegate-design.md diff --git a/docs/superpowers/specs/2026-03-20-recording-delegate-design.md b/docs/superpowers/specs/2026-03-20-recording-delegate-design.md new file mode 100644 index 00000000..de41998a --- /dev/null +++ b/docs/superpowers/specs/2026-03-20-recording-delegate-design.md @@ -0,0 +1,318 @@ +# Recording Delegate — Design Spec + +## Problem + +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.). + +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. + +## Solution + +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. + +## Current State + +### Backend + +- `HasPrivilegesOnRoom` permission class is used on `start-recording` and `stop-recording` actions in `RoomViewSet` (viewsets.py:299, 349). This checks `is_administrator_or_owner()`. +- `ResourceAccess` model with roles: `owner`, `administrator`, `member`. +- 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. + +### Frontend + +- `NoAccessView` component already includes a `RequestRecording` button and a `handleRequest` prop. +- `ScreenRecordingSidePanel` already sends a `ScreenRecordingRequested` notification via DataChannel (client-to-client). +- `useHasRecordingAccess` hook checks `useIsAdminOrOwner()` to determine recording access. +- All DataChannel notifications are sent client-side via `useNotifyParticipants` → `room.localParticipant.publishData()`. + +### What needs to change + +- Replace `HasPrivilegesOnRoom` with `HasRecordingPermission` on recording endpoints (also check delegate status). +- Update `useHasRecordingAccess` to also check delegate status. +- Extend the existing `NoAccessView` request flow with backend-backed approval (currently it only sends a client-side notification with no persistence). +- Add `ListParticipants` capability to `ParticipantsManagement` service. +- Add backend-to-client notification capability via LiveKit Server SDK `RoomService.send_data()`. + +## Design Decisions + +- **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. +- **Session or permanent**: the admin choosing to grant rights decides whether the delegation is for the current session only or permanent. +- **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. +- **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). + +## Data Model + +### RecordingDelegate + +| Field | Type | Description | +|---|---|---| +| `id` | UUID (PK) | Primary key | +| `room` | FK → Room | The room this delegation applies to | +| `user` | FK → User (CASCADE) | The delegated user | +| `status` | CharField | `pending` or `approved` | +| `is_permanent` | Boolean (default=False) | False = session-only, True = persists across meetings | +| `granted_by` | FK → User (nullable, SET_NULL) | Who granted the rights. Null = auto-approved | +| `created_at` | DateTime (auto) | Timestamp | + +**Constraints:** +- Unique together: `(room, user)` + +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. + +### Permission check + +`HasRecordingPermission` replaces `HasPrivilegesOnRoom` on `start-recording` and `stop-recording` actions. It checks in order: +1. User is admin/owner of the room → allowed +2. User has a `RecordingDelegate` entry with `status=approved` for this room → allowed +3. Otherwise → denied + +Delegates can both start AND stop recordings (a delegate who starts a recording can stop it). + +## API Endpoints + +All endpoints nested under `/api/v1.0/rooms/{room_id}/recording-delegates/`. + +| Method | Path | Permission | Description | +|---|---|---|---| +| `GET` | `/` | Admin/Owner | List delegates for this room (includes pending) | +| `POST` | `/` | Admin/Owner | Grant recording rights (direct, status=approved) | +| `DELETE` | `/{id}/` | Admin/Owner | Revoke a delegate | +| `POST` | `/request/` | Authenticated | Request recording rights (creates status=pending) | +| `POST` | `/{id}/approve/` | Admin/Owner | Approve a pending request | +| `POST` | `/{id}/reject/` | Admin/Owner | Reject a pending request (deletes the entry) | + +### Payloads + +**POST (grant):** +```json +{ + "user": "uuid", + "is_permanent": false +} +``` + +**POST approve:** +```json +{ + "is_permanent": false +} +``` + +**POST (request):** +No body needed — user is derived from `request.user`. + +**GET (list) response:** +```json +[ + { + "id": "delegate-uuid", + "user": { "id": "user-uuid", "name": "Jean Dupont" }, + "status": "approved", + "is_permanent": true, + "granted_by": { "id": "admin-uuid", "name": "Marie Martin" }, + "created_at": "2026-03-20T10:00:00Z" + } +] +``` + +## New Backend Infrastructure + +### LiveKit ListParticipants + +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). + +### LiveKit SendData (backend → client) + +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. + +### Participant identity mapping + +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. + +## Delegation Flows + +### Flow 1: Push by admin (direct grant) + +1. Admin clicks "Grant recording rights" on a participant +2. `POST /recording-delegates/` with `{ user, is_permanent }` +3. `RecordingDelegate` created with `status=approved, granted_by=admin` +4. Admin's frontend sends DataChannel notification to participant: `RecordingRightsGranted` +5. Participant sees recording buttons appear + +### Flow 2: Request by participant + +1. Participant clicks "Request recording rights" +2. `POST /recording-delegates/request/` +3. Backend creates `RecordingDelegate` with `status=pending` +4. Backend calls `list_participants` and cross-references with `ResourceAccess` to check admin presence + +**Case A — Admin present:** +5. Requester's frontend sends DataChannel notification to admins: `RecordingRightsRequested` (with delegate ID and user info) +6. Admin sees popup: "X requests recording rights" [Session only] [Permanent] [Reject] +7. Admin clicks → `POST /recording-delegates/{id}/approve/` or `/{id}/reject/` +8. Backend updates `RecordingDelegate` status to `approved` (or deletes on reject) +9. Admin's frontend sends DataChannel notification to requester: `RecordingRightsGranted` or `RecordingRightsRejected` + +**Case B — No admin present:** +5. Backend schedules a Celery task with `countdown=30` seconds, storing the task ID in cache as `auto_approve:{delegate_id}` +6. API response includes `auto_approve_seconds: 30` +7. Frontend shows countdown: "No admin present. Auto-approval in 30s..." +8. After 30s, Celery task fires: + - Re-checks the `RecordingDelegate` still exists and is still `pending` (requester may have left) + - Re-checks no admin is present via `list_participants` + - If both conditions met: updates to `status=approved, granted_by=null, is_permanent=False` + - Sends notification via `RoomService.send_data()`: `RecordingRightsGranted` + - If an admin is now present: does nothing (admin will handle via Case A) +9. If an admin connects during the 30s: + - Admin's frontend fetches pending requests via `GET /recording-delegates/?status=pending` + - Admin sees and handles the request (Case A flow) + - 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 + +### Flow 3: Pre-meeting + +1. Owner/admin goes to room management page +2. Searches for users and adds them as recording delegates +3. `POST /recording-delegates/` with `{ user, is_permanent: true }` + +### Revocation + +1. Admin/owner clicks revoke on a delegate +2. `DELETE /recording-delegates/{id}/` +3. Admin's frontend sends DataChannel notification to participant: `RecordingRightsRevoked` +4. Recording buttons disappear in real-time +5. Any active recording started by this delegate continues to completion + +## Real-time Communication + +### Notification types + +| Type | Mechanism | Sent by | Sent to | Trigger | +|---|---|---|---|---| +| `RecordingRightsRequested` | DataChannel (client) | Requester's browser | Admins in room | Participant requests rights | +| `RecordingRightsGranted` | DataChannel (client) or SendData (backend for auto-approve) | Admin's browser / backend | Requester | Approved or auto-approved | +| `RecordingRightsRejected` | DataChannel (client) | Admin's browser | Requester | Rejected | +| `RecordingRightsRevoked` | DataChannel (client) | Admin's browser | The delegate | Admin revokes rights | + +## Frontend Components + +### Participant side (authenticated, non-admin) + +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. + +States: `idle` → `pending` (with 30s countdown if auto-approve) → `granted` / `rejected` + +Once `granted`: standard start/stop recording buttons appear. + +### Admin side + +- **Toast/popup** on incoming `RecordingRightsRequested` notification: "X requests recording rights" with actions [Session only] [Permanent] [Reject] +- **Participant context menu**: "Grant recording rights" → sub-menu [Session] [Permanent] +- On room join, fetch pending requests via `GET /recording-delegates/?status=pending` to catch requests made before the admin connected + +### Room management page (pre-meeting) + +New "Recording Delegation" section in the Admin panel: +- User search field + list of current delegates with revoke button +- Only visible to admin/owner + +### Hooks + +**`useRecordingDelegate(roomId)`** +``` +→ { isDelegate, requestRights(), pendingRequest, countdown } +``` + +**Update `useHasRecordingAccess`** to also return `true` when the user is a delegate (`status=approved`). + +## Cleanup + +### Definition of "session" + +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). + +### Primary: LiveKit webhook `room_finished` + +When `room_finished` fires, schedule a Celery task with `countdown=300` (5 minutes). When the task runs: +- Check if the room is still empty via `list_participants` +- If empty: delete all `RecordingDelegate` entries with `is_permanent=False` for that room +- If participants are back: do nothing (new session started) + +### Safety net: Celery periodic task + +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. + +## Edge Cases + +| Case | Behavior | +|---|---| +| Delegate starts recording then is revoked | Active recording continues. Revocation prevents new recordings. | +| Two participants request simultaneously (auto-approve) | Independent requests. Both get rights after 30s. | +| 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. | +| 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. | +| Permanent delegate's user deleted | FK CASCADE removes the delegate entry. | +| Duplicate request by same user | Unique constraint `(room, user)` prevents duplicates. Returns 200 with existing delegate if already exists. | +| Brief room-empty gap (all disconnect/reconnect) | 5-minute grace period on `room_finished` prevents premature cleanup. | + +## Security + +- **Authentication required**: `IsAuthenticated` on `/request/` endpoint +- **Rate limiting**: `SessionExchangeAnonRateThrottle`-style throttle on `/request/` — 5 requests/min per user per room. Returns HTTP 429 when exceeded. +- **Audit trail**: `granted_by` field traces who granted (null = auto-approved), `created_at` for timing +- **No anonymous auto-approve**: only authenticated users can trigger auto-approval +- **Celery task safety**: auto-approve task re-checks both `status=pending` and admin absence before granting — no race condition + +## Tests + +### Backend (pytest) + +**Model:** +- CRUD operations on `RecordingDelegate` +- Unique constraint `(room, user)` enforced +- CASCADE delete on user/room deletion +- Status transitions: pending → approved, pending → deleted (reject) + +**Permissions:** +- Admin/owner can start-recording (unchanged) +- Delegate (status=approved) can start-recording → 201 +- Delegate (status=pending) cannot start-recording → 403 +- Authenticated non-delegate → 403 +- Anonymous → 401 +- Revoked delegate → 403 +- Delegate can stop-recording they started → 200 + +**API:** +- POST delegate: admin → 201, non-admin → 403 +- DELETE delegate: admin → 204, non-admin → 403 +- POST request: authenticated → 201 (pending created), anonymous → 401, existing delegate → 200 +- POST approve: admin → 200 (status updated), non-admin → 403 +- POST reject: admin → 200 (delegate deleted), non-admin → 403 +- GET list: admin sees pending + approved, non-admin → 403 + +**Auto-approve:** +- Request with no admin present → Celery task scheduled +- After 30s, task fires → delegate approved with `granted_by=null` +- Admin present when task fires → task is no-op +- Delegate no longer pending when task fires → task is no-op +- Requester deleted request → task is no-op + +**Cleanup:** +- Webhook `room_finished` + 5min grace → non-permanent delegates deleted +- Room not empty after grace period → delegates preserved +- Permanent delegates → preserved +- Celery periodic task → delegates older than 24h deleted + +**New infrastructure:** +- `list_participants` returns correct participant identities +- `send_data` delivers notification to specific participant +- Participant identity maps to user UUID + +### Frontend (vitest) + +- `useRecordingDelegate`: states idle/pending/granted/rejected +- `useHasRecordingAccess`: returns true for delegates +- Request button visible for authenticated non-admin, hidden for anonymous +- Recording buttons visible after granted +- Admin popup: all 3 actions work (session/permanent/reject) +- Admin fetches pending requests on room join +- 30s countdown displayed correctly during auto-approve +- Revocation removes recording buttons in real-time