API access improvements (#23183)

* restrict viewer access to logs, labels, and go2rtc stream list

* filter stats data for non admins

* track creator on vlm watch jobs and scope view/cancel to admin or creator

* add shortcut for admins in /stats
This commit is contained in:
Josh Hawkins 2026-05-13 10:40:29 -05:00 committed by GitHub
parent e20fc521b1
commit bd1fc1cc72
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 126 additions and 13 deletions

View File

@ -96,11 +96,46 @@ def version():
@router.get("/stats", dependencies=[Depends(allow_any_authenticated())]) @router.get("/stats", dependencies=[Depends(allow_any_authenticated())])
def stats(request: Request): def stats(
return JSONResponse(content=request.app.stats_emitter.get_latest_stats()) request: Request,
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
stats_data = request.app.stats_emitter.get_latest_stats()
# Admins see the full snapshot
if request.headers.get("remote-role") == "admin":
return JSONResponse(content=stats_data)
allowed_set = set(allowed_cameras)
# Shallow-copy so we don't mutate the cached stats history entry.
filtered = {**stats_data}
cameras = stats_data.get("cameras")
if cameras is not None:
filtered["cameras"] = {
name: data for name, data in cameras.items() if name in allowed_set
}
bandwidth = stats_data.get("bandwidth_usages")
if bandwidth is not None:
filtered["bandwidth_usages"] = {
name: data for name, data in bandwidth.items() if name in allowed_set
}
# cmdline can leak camera URLs/paths; strip but keep cpu/mem so
# client-side problem heuristics still work.
cpu_usages = stats_data.get("cpu_usages")
if cpu_usages is not None:
filtered["cpu_usages"] = {
pid: {k: v for k, v in usage.items() if k != "cmdline"}
for pid, usage in cpu_usages.items()
}
return JSONResponse(content=filtered)
@router.get("/stats/history", dependencies=[Depends(allow_any_authenticated())]) @router.get("/stats/history", dependencies=[Depends(require_role(["admin"]))])
def stats_history(request: Request, keys: str = None): def stats_history(request: Request, keys: str = None):
if keys: if keys:
keys = keys.split(",") keys = keys.split(",")
@ -835,7 +870,7 @@ def nvinfo():
@router.get( @router.get(
"/logs/{service}", "/logs/{service}",
tags=[Tags.logs], tags=[Tags.logs],
dependencies=[Depends(allow_any_authenticated())], dependencies=[Depends(require_role(["admin"]))],
) )
async def logs( async def logs(
service: str = Path(enum=["frigate", "nginx", "go2rtc"]), service: str = Path(enum=["frigate", "nginx", "go2rtc"]),
@ -1040,12 +1075,27 @@ def get_media_sync_status(job_id: str):
@router.get("/labels", dependencies=[Depends(allow_any_authenticated())]) @router.get("/labels", dependencies=[Depends(allow_any_authenticated())])
def get_labels(camera: str = ""): def get_labels(
camera: str = "",
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
try: try:
if camera: if camera:
if camera not in allowed_cameras:
return JSONResponse(
content={
"success": False,
"message": f"Access denied to camera '{camera}'",
},
status_code=403,
)
events = Event.select(Event.label).where(Event.camera == camera).distinct() events = Event.select(Event.label).where(Event.camera == camera).distinct()
else: else:
events = Event.select(Event.label).distinct() events = (
Event.select(Event.label)
.where(Event.camera << allowed_cameras)
.distinct()
)
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
return JSONResponse( return JSONResponse(
@ -1058,9 +1108,16 @@ def get_labels(camera: str = ""):
@router.get("/sub_labels", dependencies=[Depends(allow_any_authenticated())]) @router.get("/sub_labels", dependencies=[Depends(allow_any_authenticated())])
def get_sub_labels(split_joined: Optional[int] = None): def get_sub_labels(
split_joined: Optional[int] = None,
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
try: try:
events = Event.select(Event.sub_label).distinct() events = (
Event.select(Event.sub_label)
.where(Event.camera << allowed_cameras)
.distinct()
)
except Exception: except Exception:
return JSONResponse( return JSONResponse(
content=({"success": False, "message": "Failed to get sub_labels"}), content=({"success": False, "message": "Failed to get sub_labels"}),

View File

@ -19,7 +19,9 @@ from zeep.exceptions import Fault, TransportError
from zeep.transports import AsyncTransport from zeep.transports import AsyncTransport
from frigate.api.auth import ( from frigate.api.auth import (
_get_stream_owner_cameras,
allow_any_authenticated, allow_any_authenticated,
get_current_user,
require_go2rtc_stream_access, require_go2rtc_stream_access,
require_role, require_role,
) )
@ -31,6 +33,7 @@ from frigate.config.camera.updater import (
CameraConfigUpdateTopic, CameraConfigUpdateTopic,
) )
from frigate.config.env import substitute_frigate_vars from frigate.config.env import substitute_frigate_vars
from frigate.models import User
from frigate.util.builtin import clean_camera_user_pass from frigate.util.builtin import clean_camera_user_pass
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
from frigate.util.config import find_config_file from frigate.util.config import find_config_file
@ -66,7 +69,7 @@ def _is_valid_host(host: str) -> bool:
@router.get("/go2rtc/streams", dependencies=[Depends(allow_any_authenticated())]) @router.get("/go2rtc/streams", dependencies=[Depends(allow_any_authenticated())])
def go2rtc_streams(): async def go2rtc_streams(request: Request):
r = requests.get("http://127.0.0.1:1984/api/streams") r = requests.get("http://127.0.0.1:1984/api/streams")
if not r.ok: if not r.ok:
logger.error("Failed to fetch streams from go2rtc") logger.error("Failed to fetch streams from go2rtc")
@ -75,6 +78,24 @@ def go2rtc_streams():
status_code=500, status_code=500,
) )
stream_data = r.json() stream_data = r.json()
# Roles with an explicit camera list see only streams owned by an allowed
# camera. Admin and full-access roles (no list / empty list) see all streams.
current_user = await get_current_user(request)
if not isinstance(current_user, JSONResponse):
role = current_user["role"]
roles_dict = request.app.frigate_config.auth.roles
if role != "admin" and roles_dict.get(role):
all_camera_names = set(request.app.frigate_config.cameras.keys())
allowed_cameras = set(
User.get_allowed_cameras(role, roles_dict, all_camera_names)
)
stream_data = {
name: data
for name, data in stream_data.items()
if _get_stream_owner_cameras(request, name) & allowed_cameras
}
for data in stream_data.values(): for data in stream_data.values():
for producer in data.get("producers") or []: for producer in data.get("producers") or []:
producer["url"] = clean_camera_user_pass(producer.get("url", "")) producer["url"] = clean_camera_user_pass(producer.get("url", ""))
@ -966,7 +987,6 @@ async def onvif_probe(
probe = ffprobe_stream( probe = ffprobe_stream(
request.app.frigate_config.ffmpeg, test_uri, detailed=False request.app.frigate_config.ffmpeg, test_uri, detailed=False
) )
print(probe)
ok = probe is not None and getattr(probe, "returncode", 1) == 0 ok = probe is not None and getattr(probe, "returncode", 1) == 0
tested_candidates.append( tested_candidates.append(
{ {

View File

@ -10,7 +10,7 @@ from functools import reduce
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import cv2 import cv2
from fastapi import APIRouter, Body, Depends, Request from fastapi import APIRouter, Body, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
@ -1641,6 +1641,7 @@ async def start_vlm_monitor(
dispatcher=request.app.dispatcher, dispatcher=request.app.dispatcher,
labels=body.labels, labels=body.labels,
zones=body.zones, zones=body.zones,
username=request.headers.get("remote-user", ""),
) )
except RuntimeError as e: except RuntimeError as e:
logger.error("Failed to start VLM watch job: %s", e, exc_info=True) logger.error("Failed to start VLM watch job: %s", e, exc_info=True)
@ -1661,10 +1662,22 @@ async def start_vlm_monitor(
summary="Get current VLM watch job", summary="Get current VLM watch job",
description="Returns the current (or most recently completed) VLM watch job.", description="Returns the current (or most recently completed) VLM watch job.",
) )
async def get_vlm_monitor() -> JSONResponse: async def get_vlm_monitor(request: Request) -> JSONResponse:
job = get_vlm_watch_job() job = get_vlm_watch_job()
if job is None: if job is None:
return JSONResponse(content={"active": False}, status_code=200) return JSONResponse(content={"active": False}, status_code=200)
role = request.headers.get("remote-role", "viewer")
username = request.headers.get("remote-user", "")
# Admin and the job's creator always see the job. Other users only see it
# if they have access to the camera being watched; otherwise hide it.
if role != "admin" and username != job.username:
try:
await require_camera_access(job.camera, request=request)
except HTTPException:
return JSONResponse(content={"active": False}, status_code=200)
return JSONResponse(content={"active": True, **job.to_dict()}, status_code=200) return JSONResponse(content={"active": True, **job.to_dict()}, status_code=200)
@ -1674,7 +1687,27 @@ async def get_vlm_monitor() -> JSONResponse:
summary="Cancel the current VLM watch job", summary="Cancel the current VLM watch job",
description="Cancels the running watch job if one exists.", description="Cancels the running watch job if one exists.",
) )
async def cancel_vlm_monitor() -> JSONResponse: async def cancel_vlm_monitor(request: Request) -> JSONResponse:
job = get_vlm_watch_job()
if job is None:
return JSONResponse(
content={"success": False, "message": "No active watch job to cancel."},
status_code=404,
)
role = request.headers.get("remote-role", "viewer")
username = request.headers.get("remote-user", "")
# Admin can cancel any job; other users can only cancel jobs they started.
if role != "admin" and username != job.username:
return JSONResponse(
content={
"success": False,
"message": "Not authorized to cancel this watch job.",
},
status_code=403,
)
cancelled = stop_vlm_watch_job() cancelled = stop_vlm_watch_job()
if not cancelled: if not cancelled:
return JSONResponse( return JSONResponse(

View File

@ -45,6 +45,7 @@ class VLMWatchJob(Job):
last_reasoning: str = "" last_reasoning: str = ""
notification_message: str = "" notification_message: str = ""
iteration_count: int = 0 iteration_count: int = 0
username: str = ""
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
return asdict(self) return asdict(self)
@ -374,6 +375,7 @@ def start_vlm_watch_job(
dispatcher: Any, dispatcher: Any,
labels: list[str] | None = None, labels: list[str] | None = None,
zones: list[str] | None = None, zones: list[str] | None = None,
username: str = "",
) -> str: ) -> str:
"""Start a new VLM watch job. Returns the job ID. """Start a new VLM watch job. Returns the job ID.
@ -397,6 +399,7 @@ def start_vlm_watch_job(
max_duration_minutes=max_duration_minutes, max_duration_minutes=max_duration_minutes,
labels=labels or [], labels=labels or [],
zones=zones or [], zones=zones or [],
username=username,
) )
cancel_ev = threading.Event() cancel_ev = threading.Event()
_current_job = job _current_job = job