diff --git a/frigate/api/auth.py b/frigate/api/auth.py index 737f3706b..9d921ab6a 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -62,8 +62,8 @@ def require_admin_by_default(): "/", "/version", "/config/schema.json", - "/metrics", # Authenticated user endpoints (allow_any_authenticated) + "/metrics", "/stats", "/stats/history", "/config", @@ -76,22 +76,28 @@ def require_admin_by_default(): "/recognized_license_plates", "/timeline", "/timeline/hourly", - "/events/summary", "/recordings/storage", "/recordings/summary", "/recordings/unavailable", "/go2rtc/streams", + "/event_ids", + "/events", + "/exports", } # Path prefixes that should be exempt (for paths with parameters) EXEMPT_PREFIXES = ( "/logs/", # /logs/{service} - "/review", # /review, /review/{id}, /review_ids, /review/summary, etc. + "/review", # /review, /review/{id}, /review/summary, /review_ids, etc. "/reviews/", # /reviews/viewed, /reviews/delete - "/events/", # /events/{id}/thumbnail, etc. (camera-scoped) + "/events/", # /events/{id}/thumbnail, /events/summary, etc. (camera-scoped) + "/export/", # /export/{camera}/start/..., /export/{id}/rename, /export/{id} "/go2rtc/streams/", # /go2rtc/streams/{camera} "/users/", # /users/{username}/password (has own auth) "/preview/", # /preview/{file}/thumbnail.jpg + "/exports/", # /exports/{export_id} + "/vod/", # /vod/{camera_name}/... + "/notifications/", # /notifications/pubkey, /notifications/register ) async def admin_checker(request: Request): @@ -105,6 +111,24 @@ def require_admin_by_default(): if path.startswith(EXEMPT_PREFIXES): return + # Dynamic camera path exemption: + # Any path whose first segment matches a configured camera name should + # bypass the global admin requirement. These endpoints enforce access + # via route-level dependencies (e.g. require_camera_access) to ensure + # per-camera authorization. This allows non-admin authenticated users + # (e.g. viewer role) to access camera-specific resources without + # needing admin privileges. + try: + if path.startswith("/"): + first_segment = path.split("/", 2)[1] + if ( + first_segment + and first_segment in request.app.frigate_config.cameras + ): + return + except Exception: + pass + # For all other paths, require admin role # Port 5000 (internal) requests have admin role set automatically role = request.headers.get("remote-role") @@ -113,7 +137,7 @@ def require_admin_by_default(): raise HTTPException( status_code=403, - detail="Admin role required for this endpoint", + detail="Access denied. A user with the admin role is required.", ) return admin_checker diff --git a/frigate/api/event.py b/frigate/api/event.py index e85758181..8e966d98b 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -70,6 +70,7 @@ router = APIRouter(tags=[Tags.events]) @router.get( "/events", response_model=list[EventResponse], + dependencies=[Depends(allow_any_authenticated())], summary="Get events", description="Returns a list of events.", ) @@ -344,6 +345,7 @@ def events( @router.get( "/events/explore", response_model=list[EventResponse], + dependencies=[Depends(allow_any_authenticated())], summary="Get summary of objects.", description="""Gets a summary of objects from the database. Returns a list of objects with a max of `limit` objects for each label. @@ -436,6 +438,7 @@ def events_explore( @router.get( "/event_ids", response_model=list[EventResponse], + dependencies=[Depends(allow_any_authenticated())], summary="Get events by ids.", description="""Gets events by a list of ids. Returns a list of events. @@ -469,6 +472,7 @@ async def event_ids(ids: str, request: Request): @router.get( "/events/search", + dependencies=[Depends(allow_any_authenticated())], summary="Search events.", description="""Searches for events in the database. Returns a list of events. @@ -919,6 +923,7 @@ def events_summary( @router.get( "/events/{event_id}", response_model=EventResponse, + dependencies=[Depends(allow_any_authenticated())], summary="Get event by id.", description="Gets an event by its id.", ) @@ -962,6 +967,7 @@ def set_retain(event_id: str): @router.post( "/events/{event_id}/plus", response_model=EventUploadPlusResponse, + dependencies=[Depends(require_role(["admin"]))], summary="Send event to Frigate+.", description="""Sends an event to Frigate+. Returns a success message or an error if the event is not found. @@ -1102,6 +1108,7 @@ async def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = N @router.put( "/events/{event_id}/false_positive", response_model=EventUploadPlusResponse, + dependencies=[Depends(require_role(["admin"]))], summary="Submit false positive to Frigate+", description="""Submit an event as a false positive to Frigate+. This endpoint is the same as the standard Frigate+ submission endpoint, diff --git a/frigate/api/export.py b/frigate/api/export.py index d7b314ab2..24fed93b0 100644 --- a/frigate/api/export.py +++ b/frigate/api/export.py @@ -14,6 +14,7 @@ from peewee import DoesNotExist from playhouse.shortcuts import model_to_dict from frigate.api.auth import ( + allow_any_authenticated, get_allowed_cameras_for_filter, require_camera_access, require_role, @@ -44,6 +45,7 @@ router = APIRouter(tags=[Tags.export]) @router.get( "/exports", response_model=ExportsResponse, + dependencies=[Depends(allow_any_authenticated())], summary="Get exports", description="""Gets all exports from the database for cameras the user has access to. Returns a list of exports ordered by date (most recent first).""", @@ -272,6 +274,7 @@ async def export_delete(event_id: str, request: Request): @router.get( "/exports/{export_id}", response_model=ExportModel, + dependencies=[Depends(allow_any_authenticated())], summary="Get a single export", description="""Gets a specific export by ID. The user must have access to the camera associated with the export.""", diff --git a/frigate/api/media.py b/frigate/api/media.py index 4dc5c7714..61c1e2b96 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -945,6 +945,7 @@ async def vod_hour( @router.get( "/vod/event/{event_id}", + dependencies=[Depends(allow_any_authenticated())], description="Returns an HLS playlist for the specified object. Append /master.m3u8 or /index.m3u8 for HLS playback.", ) async def vod_event( diff --git a/frigate/api/notification.py b/frigate/api/notification.py index 3d3a3eab0..502e76dbd 100644 --- a/frigate/api/notification.py +++ b/frigate/api/notification.py @@ -5,11 +5,12 @@ import os from typing import Any from cryptography.hazmat.primitives import serialization -from fastapi import APIRouter, Request +from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse from peewee import DoesNotExist from py_vapid import Vapid01, utils +from frigate.api.auth import allow_any_authenticated from frigate.api.defs.tags import Tags from frigate.const import CONFIG_DIR from frigate.models import User @@ -21,6 +22,7 @@ router = APIRouter(tags=[Tags.notifications]) @router.get( "/notifications/pubkey", + dependencies=[Depends(allow_any_authenticated())], summary="Get VAPID public key", description="""Gets the VAPID public key for the notifications. Returns the public key or an error if notifications are not enabled. @@ -47,6 +49,7 @@ def get_vapid_pub_key(request: Request): @router.post( "/notifications/register", + dependencies=[Depends(allow_any_authenticated())], summary="Register notifications", description="""Registers a notifications subscription. Returns a success message or an error if the subscription is not provided. diff --git a/frigate/api/review.py b/frigate/api/review.py index b19f10f2b..5f6fbc13b 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -577,7 +577,9 @@ def delete_reviews(body: ReviewModifyMultipleBody): @router.get( - "/review/activity/motion", response_model=list[ReviewActivityMotionResponse] + "/review/activity/motion", + response_model=list[ReviewActivityMotionResponse], + dependencies=[Depends(allow_any_authenticated())], ) def motion_activity( params: ReviewActivityMotionQueryParams = Depends(), @@ -739,6 +741,7 @@ async def set_not_reviewed( @router.post( "/review/summarize/start/{start_ts}/end/{end_ts}", + dependencies=[Depends(allow_any_authenticated())], description="Use GenAI to summarize review items over a period of time.", ) def generate_review_summary(request: Request, start_ts: float, end_ts: float):