Skip to content

Commit 4ae56b7

Browse files
authored
refactor(auth): consolidate PUBLIC_MODE and mutation guards into reusable helpers (#909)
* refactor(auth): consolidate PUBLIC_MODE and mutation guards into reusable helpers * fix: fix websocket test override
1 parent cf6e867 commit 4ae56b7

15 files changed

+96
-86
lines changed

server/reflector/auth/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
authenticated = auth_module.authenticated
1313
current_user = auth_module.current_user
1414
current_user_optional = auth_module.current_user_optional
15+
current_user_optional_if_public_mode = auth_module.current_user_optional_if_public_mode
1516
parse_ws_bearer_token = auth_module.parse_ws_bearer_token
1617
current_user_ws_optional = auth_module.current_user_ws_optional
1718
verify_raw_token = auth_module.verify_raw_token

server/reflector/auth/auth_jwt.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,17 @@ async def current_user_optional(
129129
return await _authenticate_user(jwt_token, api_key, jwtauth)
130130

131131

132+
async def current_user_optional_if_public_mode(
133+
jwt_token: Annotated[Optional[str], Depends(oauth2_scheme)],
134+
api_key: Annotated[Optional[str], Depends(api_key_header)],
135+
jwtauth: JWTAuth = Depends(),
136+
) -> Optional[UserInfo]:
137+
user = await _authenticate_user(jwt_token, api_key, jwtauth)
138+
if user is None and not settings.PUBLIC_MODE:
139+
raise HTTPException(status_code=401, detail="Not authenticated")
140+
return user
141+
142+
132143
def parse_ws_bearer_token(
133144
websocket: "WebSocket",
134145
) -> tuple[Optional[str], Optional[str]]:

server/reflector/auth/auth_none.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ def current_user_optional():
2121
return None
2222

2323

24+
def current_user_optional_if_public_mode():
25+
# auth_none means no authentication at all — always public
26+
return None
27+
28+
2429
def parse_ws_bearer_token(websocket):
2530
return None, None
2631

server/reflector/auth/auth_password.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,16 @@ async def current_user_optional(
150150
return await _authenticate_user(jwt_token, api_key)
151151

152152

153+
async def current_user_optional_if_public_mode(
154+
jwt_token: Annotated[Optional[str], Depends(oauth2_scheme)],
155+
api_key: Annotated[Optional[str], Depends(api_key_header)],
156+
) -> Optional[UserInfo]:
157+
user = await _authenticate_user(jwt_token, api_key)
158+
if user is None and not settings.PUBLIC_MODE:
159+
raise HTTPException(status_code=401, detail="Not authenticated")
160+
return user
161+
162+
153163
# --- WebSocket auth (same pattern as auth_jwt.py) ---
154164
def parse_ws_bearer_token(
155165
websocket: "WebSocket",

server/reflector/db/transcripts.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,18 @@ def user_can_mutate(transcript: Transcript, user_id: str | None) -> bool:
697697
return False
698698
return user_id and transcript.user_id == user_id
699699

700+
@staticmethod
701+
def check_can_mutate(transcript: Transcript, user_id: str | None) -> None:
702+
"""
703+
Raises HTTP 403 if the user cannot mutate the transcript.
704+
705+
Policy:
706+
- Anonymous transcripts (user_id is None) are editable by anyone
707+
- Owned transcripts can only be mutated by their owner
708+
"""
709+
if transcript.user_id is not None and transcript.user_id != user_id:
710+
raise HTTPException(status_code=403, detail="Not authorized")
711+
700712
@asynccontextmanager
701713
async def transaction(self):
702714
"""

server/reflector/views/meetings.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
)
1717
from reflector.db.rooms import rooms_controller
1818
from reflector.logger import logger
19-
from reflector.settings import settings
2019
from reflector.utils.string import NonEmptyString
2120
from reflector.video_platforms.factory import create_platform_client
2221

@@ -92,15 +91,15 @@ class StartRecordingRequest(BaseModel):
9291
async def start_recording(
9392
meeting_id: NonEmptyString,
9493
body: StartRecordingRequest,
95-
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
94+
user: Annotated[
95+
Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode)
96+
],
9697
) -> dict[str, Any]:
9798
"""Start cloud or raw-tracks recording via Daily.co REST API.
9899
99100
Both cloud and raw-tracks are started via REST API to bypass enable_recording limitation of allowing only 1 recording at a time.
100101
Uses different instanceIds for cloud vs raw-tracks (same won't work)
101102
"""
102-
if not user and not settings.PUBLIC_MODE:
103-
raise HTTPException(status_code=401, detail="Not authenticated")
104103
meeting = await meetings_controller.get_by_id(meeting_id)
105104
if not meeting:
106105
raise HTTPException(status_code=404, detail="Meeting not found")

server/reflector/views/rooms.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
from reflector.redis_cache import RedisAsyncLock
1818
from reflector.schemas.platform import Platform
1919
from reflector.services.ics_sync import ics_sync_service
20-
from reflector.settings import settings
2120
from reflector.utils.url import add_query_param
2221
from reflector.video_platforms.factory import create_platform_client
2322
from reflector.worker.webhook import test_webhook
@@ -178,11 +177,10 @@ class CalendarEventResponse(BaseModel):
178177

179178
@router.get("/rooms", response_model=Page[RoomDetails])
180179
async def rooms_list(
181-
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
180+
user: Annotated[
181+
Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode)
182+
],
182183
) -> list[RoomDetails]:
183-
if not user and not settings.PUBLIC_MODE:
184-
raise HTTPException(status_code=401, detail="Not authenticated")
185-
186184
user_id = user["sub"] if user else None
187185

188186
paginated = await apaginate(

server/reflector/views/transcripts.py

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -263,16 +263,15 @@ class SearchResponse(BaseModel):
263263

264264
@router.get("/transcripts", response_model=Page[GetTranscriptMinimal])
265265
async def transcripts_list(
266-
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
266+
user: Annotated[
267+
Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode)
268+
],
267269
source_kind: SourceKind | None = None,
268270
room_id: str | None = None,
269271
search_term: str | None = None,
270272
change_seq_from: int | None = None,
271273
sort_by: Literal["created_at", "change_seq"] | None = None,
272274
):
273-
if not user and not settings.PUBLIC_MODE:
274-
raise HTTPException(status_code=401, detail="Not authenticated")
275-
276275
user_id = user["sub"] if user else None
277276

278277
# Default behavior preserved: sort_by=None → "-created_at"
@@ -307,13 +306,10 @@ async def transcripts_search(
307306
from_datetime: SearchFromDatetimeParam = None,
308307
to_datetime: SearchToDatetimeParam = None,
309308
user: Annotated[
310-
Optional[auth.UserInfo], Depends(auth.current_user_optional)
309+
Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode)
311310
] = None,
312311
):
313312
"""Full-text search across transcript titles and content."""
314-
if not user and not settings.PUBLIC_MODE:
315-
raise HTTPException(status_code=401, detail="Not authenticated")
316-
317313
user_id = user["sub"] if user else None
318314

319315
if from_datetime and to_datetime and from_datetime > to_datetime:
@@ -346,11 +342,10 @@ async def transcripts_search(
346342
@router.post("/transcripts", response_model=GetTranscriptWithParticipants)
347343
async def transcripts_create(
348344
info: CreateTranscript,
349-
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
345+
user: Annotated[
346+
Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode)
347+
],
350348
):
351-
if not user and not settings.PUBLIC_MODE:
352-
raise HTTPException(status_code=401, detail="Not authenticated")
353-
354349
user_id = user["sub"] if user else None
355350
transcript = await transcripts_controller.add(
356351
info.name,

server/reflector/views/transcripts_participants.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,7 @@ async def transcript_add_participant(
6262
transcript = await transcripts_controller.get_by_id_for_http(
6363
transcript_id, user_id=user_id
6464
)
65-
if transcript.user_id is not None and transcript.user_id != user_id:
66-
raise HTTPException(status_code=403, detail="Not authorized")
65+
transcripts_controller.check_can_mutate(transcript, user_id)
6766

6867
# ensure the speaker is unique
6968
if participant.speaker is not None and transcript.participants is not None:
@@ -109,8 +108,7 @@ async def transcript_update_participant(
109108
transcript = await transcripts_controller.get_by_id_for_http(
110109
transcript_id, user_id=user_id
111110
)
112-
if transcript.user_id is not None and transcript.user_id != user_id:
113-
raise HTTPException(status_code=403, detail="Not authorized")
111+
transcripts_controller.check_can_mutate(transcript, user_id)
114112

115113
# ensure the speaker is unique
116114
for p in transcript.participants:
@@ -148,7 +146,6 @@ async def transcript_delete_participant(
148146
transcript = await transcripts_controller.get_by_id_for_http(
149147
transcript_id, user_id=user_id
150148
)
151-
if transcript.user_id is not None and transcript.user_id != user_id:
152-
raise HTTPException(status_code=403, detail="Not authorized")
149+
transcripts_controller.check_can_mutate(transcript, user_id)
153150
await transcripts_controller.delete_participant(transcript, participant_id)
154151
return DeletionStatus(status="ok")

server/reflector/views/transcripts_process.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
prepare_transcript_processing,
1616
validate_transcript_for_processing,
1717
)
18-
from reflector.settings import settings
1918

2019
router = APIRouter()
2120

@@ -27,11 +26,10 @@ class ProcessStatus(BaseModel):
2726
@router.post("/transcripts/{transcript_id}/process")
2827
async def transcript_process(
2928
transcript_id: str,
30-
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
29+
user: Annotated[
30+
Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode)
31+
],
3132
) -> ProcessStatus:
32-
if not user and not settings.PUBLIC_MODE:
33-
raise HTTPException(status_code=401, detail="Not authenticated")
34-
3533
user_id = user["sub"] if user else None
3634
transcript = await transcripts_controller.get_by_id_for_http(
3735
transcript_id, user_id=user_id

0 commit comments

Comments
 (0)