Files
meet/docs/superpowers/specs/2026-03-20-recording-delegate-design.md
T
Michel-Marie MAUDET 0aa2b80be9 📝(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.
2026-03-20 06:19:36 +01:00

15 KiB

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 useNotifyParticipantsroom.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):

{
  "user": "uuid",
  "is_permanent": false
}

POST approve:

{
  "is_permanent": false
}

POST (request): No body needed — user is derived from request.user.

GET (list) response:

[
  {
    "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)
  1. 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: idlepending (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