mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-03 01:22:17 +03:00
Enhance user roles to limit camera access (#20024)
* update config for roles and add validator * ensure admin and viewer are never overridden * add class method to user to retrieve all allowed cameras * enforce config roles in auth api endpoints * add camera access api dependency functions * protect review endpoints * protect preview endpoints * rename param name for better fastapi injection matching * remove unneeded * protect export endpoints * protect event endpoints * protect media endpoints * update auth hook for allowed cameras * update default app view * ensure anonymous user always returns all cameras * limit cameras in explore * cameras is already a list * limit cameras in review/history * limit cameras in live view * limit cameras in camera groups * only show face library and classification in sidebar for admin * remove check in delete reviews since admin role is required, no need to check camera access. fixes failing test * pass request with camera access for tests * more async * camera access tests * fix proxy auth tests * allowed cameras for review tests * combine event tests and refactor for camera access * fix post validation for roles * don't limit roles in create user dialog * fix triggers endpoints no need to run require camera access dep since the required role is admin * fix type * create and edit role dialogs * delete role dialog * fix role change dialog * update settings view for roles * i18n changes * minor spacing tweaks * docs * use badges and camera name label component * clarify docs * display all cameras badge for admin and viewer * i18n fix * use validator to prevent reserved and empty roles from being assigned * split users and roles into separate tabs in settings * tweak docs * clarify docs * change icon * don't memoize roles always recalculate on component render
This commit is contained in:
+69
-21
@@ -4,6 +4,7 @@ import datetime
|
||||
import logging
|
||||
from functools import reduce
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import pandas as pd
|
||||
from fastapi import APIRouter, Request
|
||||
@@ -12,7 +13,12 @@ from fastapi.responses import JSONResponse
|
||||
from peewee import Case, DoesNotExist, IntegrityError, fn, operator
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
|
||||
from frigate.api.auth import get_current_user, require_role
|
||||
from frigate.api.auth import (
|
||||
get_allowed_cameras_for_filter,
|
||||
get_current_user,
|
||||
require_camera_access,
|
||||
require_role,
|
||||
)
|
||||
from frigate.api.defs.query.review_query_parameters import (
|
||||
ReviewActivityMotionQueryParams,
|
||||
ReviewQueryParams,
|
||||
@@ -41,6 +47,7 @@ router = APIRouter(tags=[Tags.review])
|
||||
async def review(
|
||||
params: ReviewQueryParams = Depends(),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
):
|
||||
if isinstance(current_user, JSONResponse):
|
||||
return current_user
|
||||
@@ -65,8 +72,14 @@ async def review(
|
||||
]
|
||||
|
||||
if cameras != "all":
|
||||
camera_list = cameras.split(",")
|
||||
clauses.append((ReviewSegment.camera << camera_list))
|
||||
requested = set(cameras.split(","))
|
||||
filtered = requested.intersection(allowed_cameras)
|
||||
if not filtered:
|
||||
return JSONResponse(content=[])
|
||||
camera_list = list(filtered)
|
||||
else:
|
||||
camera_list = allowed_cameras
|
||||
clauses.append((ReviewSegment.camera << camera_list))
|
||||
|
||||
if labels != "all":
|
||||
# use matching so segments with multiple labels
|
||||
@@ -140,7 +153,7 @@ async def review(
|
||||
|
||||
|
||||
@router.get("/review_ids", response_model=list[ReviewSegmentResponse])
|
||||
def review_ids(ids: str):
|
||||
async def review_ids(request: Request, ids: str):
|
||||
ids = ids.split(",")
|
||||
|
||||
if not ids:
|
||||
@@ -149,6 +162,18 @@ def review_ids(ids: str):
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
for review_id in ids:
|
||||
try:
|
||||
review = ReviewSegment.get(ReviewSegment.id == review_id)
|
||||
await require_camera_access(review.camera, request=request)
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content=(
|
||||
{"success": False, "message": f"Review {review_id} not found"}
|
||||
),
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
try:
|
||||
reviews = (
|
||||
ReviewSegment.select().where(ReviewSegment.id << ids).dicts().iterator()
|
||||
@@ -165,6 +190,7 @@ def review_ids(ids: str):
|
||||
async def review_summary(
|
||||
params: ReviewSummaryQueryParams = Depends(),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
):
|
||||
if isinstance(current_user, JSONResponse):
|
||||
return current_user
|
||||
@@ -181,8 +207,14 @@ async def review_summary(
|
||||
clauses = [(ReviewSegment.start_time > day_ago)]
|
||||
|
||||
if cameras != "all":
|
||||
camera_list = cameras.split(",")
|
||||
clauses.append((ReviewSegment.camera << camera_list))
|
||||
requested = set(cameras.split(","))
|
||||
filtered = requested.intersection(allowed_cameras)
|
||||
if not filtered:
|
||||
return JSONResponse(content={})
|
||||
camera_list = list(filtered)
|
||||
else:
|
||||
camera_list = allowed_cameras
|
||||
clauses.append((ReviewSegment.camera << camera_list))
|
||||
|
||||
if labels != "all":
|
||||
# use matching so segments with multiple labels
|
||||
@@ -276,8 +308,14 @@ async def review_summary(
|
||||
clauses = []
|
||||
|
||||
if cameras != "all":
|
||||
camera_list = cameras.split(",")
|
||||
clauses.append((ReviewSegment.camera << camera_list))
|
||||
requested = set(cameras.split(","))
|
||||
filtered = requested.intersection(allowed_cameras)
|
||||
if not filtered:
|
||||
return JSONResponse(content={})
|
||||
camera_list = list(filtered)
|
||||
else:
|
||||
camera_list = allowed_cameras
|
||||
clauses.append((ReviewSegment.camera << camera_list))
|
||||
|
||||
if labels != "all":
|
||||
# use matching so segments with multiple labels
|
||||
@@ -380,6 +418,7 @@ async def review_summary(
|
||||
|
||||
@router.post("/reviews/viewed", response_model=GenericResponse)
|
||||
async def set_multiple_reviewed(
|
||||
request: Request,
|
||||
body: ReviewModifyMultipleBody,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
@@ -390,6 +429,8 @@ async def set_multiple_reviewed(
|
||||
|
||||
for review_id in body.ids:
|
||||
try:
|
||||
review = ReviewSegment.get(ReviewSegment.id == review_id)
|
||||
await require_camera_access(review.camera, request=request)
|
||||
review_status = UserReviewStatus.get(
|
||||
UserReviewStatus.user_id == user_id,
|
||||
UserReviewStatus.review_segment == review_id,
|
||||
@@ -471,7 +512,10 @@ def delete_reviews(body: ReviewModifyMultipleBody):
|
||||
@router.get(
|
||||
"/review/activity/motion", response_model=list[ReviewActivityMotionResponse]
|
||||
)
|
||||
def motion_activity(params: ReviewActivityMotionQueryParams = Depends()):
|
||||
def motion_activity(
|
||||
params: ReviewActivityMotionQueryParams = Depends(),
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
):
|
||||
"""Get motion and audio activity."""
|
||||
cameras = params.cameras
|
||||
before = params.before or datetime.datetime.now().timestamp()
|
||||
@@ -486,8 +530,14 @@ def motion_activity(params: ReviewActivityMotionQueryParams = Depends()):
|
||||
clauses.append((Recordings.motion > 0))
|
||||
|
||||
if cameras != "all":
|
||||
camera_list = cameras.split(",")
|
||||
requested = set(cameras.split(","))
|
||||
filtered = requested.intersection(allowed_cameras)
|
||||
if not filtered:
|
||||
return JSONResponse(content=[])
|
||||
camera_list = list(filtered)
|
||||
clauses.append((Recordings.camera << camera_list))
|
||||
else:
|
||||
clauses.append((Recordings.camera << allowed_cameras))
|
||||
|
||||
data: list[Recordings] = (
|
||||
Recordings.select(
|
||||
@@ -545,15 +595,13 @@ def motion_activity(params: ReviewActivityMotionQueryParams = Depends()):
|
||||
|
||||
|
||||
@router.get("/review/event/{event_id}", response_model=ReviewSegmentResponse)
|
||||
def get_review_from_event(event_id: str):
|
||||
async def get_review_from_event(request: Request, event_id: str):
|
||||
try:
|
||||
return JSONResponse(
|
||||
model_to_dict(
|
||||
ReviewSegment.get(
|
||||
ReviewSegment.data["detections"].cast("text") % f'*"{event_id}"*'
|
||||
)
|
||||
)
|
||||
review = ReviewSegment.get(
|
||||
ReviewSegment.data["detections"].cast("text") % f'*"{event_id}"*'
|
||||
)
|
||||
await require_camera_access(review.camera, request=request)
|
||||
return JSONResponse(model_to_dict(review))
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Review item not found"},
|
||||
@@ -562,11 +610,11 @@ def get_review_from_event(event_id: str):
|
||||
|
||||
|
||||
@router.get("/review/{review_id}", response_model=ReviewSegmentResponse)
|
||||
def get_review(review_id: str):
|
||||
async def get_review(request: Request, review_id: str):
|
||||
try:
|
||||
return JSONResponse(
|
||||
content=model_to_dict(ReviewSegment.get(ReviewSegment.id == review_id))
|
||||
)
|
||||
review = ReviewSegment.get(ReviewSegment.id == review_id)
|
||||
await require_camera_access(review.camera, request=request)
|
||||
return JSONResponse(content=model_to_dict(review))
|
||||
except DoesNotExist:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Review item not found"},
|
||||
|
||||
Reference in New Issue
Block a user