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:
Josh Hawkins
2025-09-12 05:19:29 -06:00
committed by GitHub
parent ba650af6f2
commit ed1e3a7c9a
41 changed files with 2286 additions and 739 deletions
+128 -60
View File
@@ -8,6 +8,7 @@ import random
import string
from functools import reduce
from pathlib import Path
from typing import List
from urllib.parse import unquote
import cv2
@@ -19,7 +20,11 @@ from pathvalidate import sanitize_filename
from peewee import JOIN, DoesNotExist, fn, operator
from playhouse.shortcuts import model_to_dict
from frigate.api.auth import require_role
from frigate.api.auth import (
get_allowed_cameras_for_filter,
require_camera_access,
require_role,
)
from frigate.api.defs.query.events_query_parameters import (
DEFAULT_TIME_RANGE,
EventsQueryParams,
@@ -61,7 +66,10 @@ router = APIRouter(tags=[Tags.events])
@router.get("/events", response_model=list[EventResponse])
def events(params: EventsQueryParams = Depends()):
def events(
params: EventsQueryParams = Depends(),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
camera = params.camera
cameras = params.cameras
@@ -135,8 +143,14 @@ def events(params: EventsQueryParams = Depends()):
clauses.append((Event.camera == camera))
if cameras != "all":
camera_list = cameras.split(",")
clauses.append((Event.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((Event.camera << camera_list))
if labels != "all":
label_list = labels.split(",")
@@ -321,9 +335,17 @@ def events(params: EventsQueryParams = Depends()):
@router.get("/events/explore", response_model=list[EventResponse])
def events_explore(limit: int = 10):
def events_explore(
limit: int = 10,
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
# get distinct labels for all events
distinct_labels = Event.select(Event.label).distinct().order_by(Event.label)
distinct_labels = (
Event.select(Event.label)
.where(Event.camera << allowed_cameras)
.distinct()
.order_by(Event.label)
)
label_counts = {}
@@ -334,14 +356,18 @@ def events_explore(limit: int = 10):
# get most recent events for this label
label_events = (
Event.select()
.where(Event.label == label)
.where((Event.label == label) & (Event.camera << allowed_cameras))
.order_by(Event.start_time.desc())
.limit(limit)
.iterator()
)
# count total events for this label
label_counts[label] = Event.select().where(Event.label == label).count()
label_counts[label] = (
Event.select()
.where((Event.label == label) & (Event.camera << allowed_cameras))
.count()
)
yield from label_events
@@ -394,7 +420,7 @@ def events_explore(limit: int = 10):
@router.get("/event_ids", response_model=list[EventResponse])
def event_ids(ids: str):
async def event_ids(ids: str, request: Request):
ids = ids.split(",")
if not ids:
@@ -403,6 +429,16 @@ def event_ids(ids: str):
status_code=400,
)
for event_id in ids:
try:
event = Event.get(Event.id == event_id)
await require_camera_access(event.camera, request=request)
except DoesNotExist:
return JSONResponse(
content=({"success": False, "message": f"Event {event_id} not found"}),
status_code=404,
)
try:
events = Event.select().where(Event.id << ids).dicts().iterator()
return JSONResponse(list(events))
@@ -413,7 +449,11 @@ def event_ids(ids: str):
@router.get("/events/search")
def events_search(request: Request, params: EventsSearchQueryParams = Depends()):
def events_search(
request: Request,
params: EventsSearchQueryParams = Depends(),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
query = params.query
search_type = params.search_type
include_thumbnails = params.include_thumbnails
@@ -486,7 +526,13 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
event_filters = []
if cameras != "all":
event_filters.append((Event.camera << cameras.split(",")))
requested = set(cameras.split(","))
filtered = requested.intersection(allowed_cameras)
if not filtered:
return JSONResponse(content=[])
event_filters.append((Event.camera << list(filtered)))
else:
event_filters.append((Event.camera << allowed_cameras))
if labels != "all":
event_filters.append((Event.label << labels.split(",")))
@@ -739,7 +785,10 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
@router.get("/events/summary")
def events_summary(params: EventsSummaryQueryParams = Depends()):
def events_summary(
params: EventsSummaryQueryParams = Depends(),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
tz_name = params.timezone
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(tz_name)
has_clip = params.has_clip
@@ -771,7 +820,7 @@ def events_summary(params: EventsSummaryQueryParams = Depends()):
Event.zones,
fn.COUNT(Event.id).alias("count"),
)
.where(reduce(operator.and_, clauses))
.where(reduce(operator.and_, clauses) & (Event.camera << allowed_cameras))
.group_by(
Event.camera,
Event.label,
@@ -786,9 +835,11 @@ def events_summary(params: EventsSummaryQueryParams = Depends()):
@router.get("/events/{event_id}", response_model=EventResponse)
def event(event_id: str):
async def event(event_id: str, request: Request):
try:
return model_to_dict(Event.get(Event.id == event_id))
event = Event.get(Event.id == event_id)
await require_camera_access(event.camera, request=request)
return model_to_dict(event)
except DoesNotExist:
return JSONResponse(content="Event not found", status_code=404)
@@ -817,7 +868,7 @@ def set_retain(event_id: str):
@router.post("/events/{event_id}/plus", response_model=EventUploadPlusResponse)
def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = None):
async def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = None):
if not request.app.frigate_config.plus_api.is_active():
message = "PLUS_API_KEY environment variable is not set"
logger.error(message)
@@ -835,6 +886,7 @@ def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = None):
try:
event = Event.get(Event.id == event_id)
await require_camera_access(event.camera, request=request)
except DoesNotExist:
message = f"Event {event_id} not found"
logger.error(message)
@@ -929,7 +981,7 @@ def send_to_plus(request: Request, event_id: str, body: SubmitPlusBody = None):
@router.put("/events/{event_id}/false_positive", response_model=EventUploadPlusResponse)
def false_positive(request: Request, event_id: str):
async def false_positive(request: Request, event_id: str):
if not request.app.frigate_config.plus_api.is_active():
message = "PLUS_API_KEY environment variable is not set"
logger.error(message)
@@ -945,6 +997,7 @@ def false_positive(request: Request, event_id: str):
try:
event = Event.get(Event.id == event_id)
await require_camera_access(event.camera, request=request)
except DoesNotExist:
message = f"Event {event_id} not found"
logger.error(message)
@@ -1022,9 +1075,10 @@ def false_positive(request: Request, event_id: str):
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def delete_retain(event_id: str):
async def delete_retain(event_id: str, request: Request):
try:
event = Event.get(Event.id == event_id)
await require_camera_access(event.camera, request=request)
except DoesNotExist:
return JSONResponse(
content=({"success": False, "message": "Event " + event_id + " not found"}),
@@ -1045,13 +1099,14 @@ def delete_retain(event_id: str):
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def set_sub_label(
async def set_sub_label(
request: Request,
event_id: str,
body: EventsSubLabelBody,
):
try:
event: Event = Event.get(Event.id == event_id)
await require_camera_access(event.camera, request=request)
except DoesNotExist:
event = None
@@ -1099,13 +1154,14 @@ def set_sub_label(
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def set_plate(
async def set_plate(
request: Request,
event_id: str,
body: EventsLPRBody,
):
try:
event: Event = Event.get(Event.id == event_id)
await require_camera_access(event.camera, request=request)
except DoesNotExist:
event = None
@@ -1154,13 +1210,14 @@ def set_plate(
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def set_description(
async def set_description(
request: Request,
event_id: str,
body: EventsDescriptionBody,
):
try:
event: Event = Event.get(Event.id == event_id)
await require_camera_access(event.camera, request=request)
except DoesNotExist:
return JSONResponse(
content=({"success": False, "message": "Event " + event_id + " not found"}),
@@ -1205,11 +1262,12 @@ def set_description(
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def regenerate_description(
async def regenerate_description(
request: Request, event_id: str, params: RegenerateQueryParameters = Depends()
):
try:
event: Event = Event.get(Event.id == event_id)
await require_camera_access(event.camera, request=request)
except DoesNotExist:
return JSONResponse(
content=({"success": False, "message": "Event " + event_id + " not found"}),
@@ -1280,9 +1338,10 @@ def generate_description_embedding(
)
def delete_single_event(event_id: str, request: Request) -> dict:
async def delete_single_event(event_id: str, request: Request) -> dict:
try:
event = Event.get(Event.id == event_id)
await require_camera_access(event.camera, request=request)
except DoesNotExist:
return {"success": False, "message": f"Event {event_id} not found"}
@@ -1312,8 +1371,8 @@ def delete_single_event(event_id: str, request: Request) -> dict:
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def delete_event(request: Request, event_id: str):
result = delete_single_event(event_id, request)
async def delete_event(request: Request, event_id: str):
result = await delete_single_event(event_id, request)
status_code = 200 if result["success"] else 404
return JSONResponse(content=result, status_code=status_code)
@@ -1323,7 +1382,7 @@ def delete_event(request: Request, event_id: str):
response_model=EventMultiDeleteResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def delete_events(request: Request, body: EventsDeleteBody):
async def delete_events(request: Request, body: EventsDeleteBody):
if not body.event_ids:
return JSONResponse(
content=({"success": False, "message": "No event IDs provided."}),
@@ -1334,7 +1393,7 @@ def delete_events(request: Request, body: EventsDeleteBody):
not_found_events = []
for event_id in body.event_ids:
result = delete_single_event(event_id, request)
result = await delete_single_event(event_id, request)
if result["success"]:
deleted_events.append(event_id)
else:
@@ -1410,8 +1469,10 @@ def create_event(
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
)
def end_event(request: Request, event_id: str, body: EventsEndBody):
async def end_event(request: Request, event_id: str, body: EventsEndBody):
try:
event: Event = Event.get(Event.id == event_id)
await require_camera_access(event.camera, request=request)
end_time = body.end_time or datetime.datetime.now().timestamp()
request.app.event_metadata_updater.publish(
(event_id, end_time), EventMetadataTypeEnum.manual_event_end.value
@@ -1438,7 +1499,7 @@ def end_event(request: Request, event_id: str, body: EventsEndBody):
def create_trigger_embedding(
request: Request,
body: TriggerEmbeddingBody,
camera: str,
camera_name: str,
name: str,
):
try:
@@ -1454,13 +1515,13 @@ def create_trigger_embedding(
# Check if trigger already exists
if (
Trigger.select()
.where(Trigger.camera == camera, Trigger.name == name)
.where(Trigger.camera == camera_name, Trigger.name == name)
.exists()
):
return JSONResponse(
content={
"success": False,
"message": f"Trigger {camera}:{name} already exists",
"message": f"Trigger {camera_name}:{name} already exists",
},
status_code=400,
)
@@ -1530,28 +1591,29 @@ def create_trigger_embedding(
# Save image to the triggers directory
try:
os.makedirs(
os.path.join(TRIGGER_DIR, sanitize_filename(camera)), exist_ok=True
os.path.join(TRIGGER_DIR, sanitize_filename(camera_name)),
exist_ok=True,
)
with open(
os.path.join(
TRIGGER_DIR,
sanitize_filename(camera),
sanitize_filename(camera_name),
f"{sanitize_filename(body.data)}.webp",
),
"wb",
) as f:
f.write(thumbnail)
logger.debug(
f"Writing thumbnail for trigger with data {body.data} in {camera}."
f"Writing thumbnail for trigger with data {body.data} in {camera_name}."
)
except Exception as e:
logger.error(e.with_traceback())
logger.error(
f"Failed to write thumbnail for trigger with data {body.data} in {camera}"
f"Failed to write thumbnail for trigger with data {body.data} in {camera_name}"
)
Trigger.create(
camera=camera,
camera=camera_name,
name=name,
type=body.type,
data=body.data,
@@ -1565,7 +1627,7 @@ def create_trigger_embedding(
return JSONResponse(
content={
"success": True,
"message": f"Trigger created successfully for {camera}:{name}",
"message": f"Trigger created successfully for {camera_name}:{name}",
},
status_code=200,
)
@@ -1582,13 +1644,13 @@ def create_trigger_embedding(
@router.put(
"/trigger/embedding/{camera}/{name}",
"/trigger/embedding/{camera_name}/{name}",
response_model=dict,
dependencies=[Depends(require_role(["admin"]))],
)
def update_trigger_embedding(
request: Request,
camera: str,
camera_name: str,
name: str,
body: TriggerEmbeddingBody,
):
@@ -1609,7 +1671,9 @@ def update_trigger_embedding(
embedding = context.generate_description_embedding(body.data)
elif body.type == "thumbnail":
webp_file = sanitize_filename(body.data) + ".webp"
webp_path = os.path.join(TRIGGER_DIR, sanitize_filename(camera), webp_file)
webp_path = os.path.join(
TRIGGER_DIR, sanitize_filename(camera_name), webp_file
)
try:
event: Event = Event.get(Event.id == body.data)
@@ -1656,7 +1720,9 @@ def update_trigger_embedding(
)
# Check if trigger exists for upsert
trigger = Trigger.get_or_none(Trigger.camera == camera, Trigger.name == name)
trigger = Trigger.get_or_none(
Trigger.camera == camera_name, Trigger.name == name
)
if trigger:
# Update existing trigger
@@ -1665,17 +1731,17 @@ def update_trigger_embedding(
os.remove(
os.path.join(
TRIGGER_DIR,
sanitize_filename(camera),
sanitize_filename(camera_name),
f"{trigger.data}.webp",
)
)
logger.debug(
f"Deleted thumbnail for trigger with data {trigger.data} in {camera}."
f"Deleted thumbnail for trigger with data {trigger.data} in {camera_name}."
)
except Exception as e:
logger.error(e.with_traceback())
logger.error(
f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera}"
f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera_name}"
)
Trigger.update(
@@ -1685,11 +1751,11 @@ def update_trigger_embedding(
threshold=body.threshold,
triggering_event_id="",
last_triggered=None,
).where(Trigger.camera == camera, Trigger.name == name).execute()
).where(Trigger.camera == camera_name, Trigger.name == name).execute()
else:
# Create new trigger (for rename case)
Trigger.create(
camera=camera,
camera=camera_name,
name=name,
type=body.type,
data=body.data,
@@ -1703,7 +1769,7 @@ def update_trigger_embedding(
if body.type == "thumbnail":
# Save image to the triggers directory
try:
camera_path = os.path.join(TRIGGER_DIR, sanitize_filename(camera))
camera_path = os.path.join(TRIGGER_DIR, sanitize_filename(camera_name))
os.makedirs(camera_path, exist_ok=True)
with open(
os.path.join(camera_path, f"{sanitize_filename(body.data)}.webp"),
@@ -1711,18 +1777,18 @@ def update_trigger_embedding(
) as f:
f.write(thumbnail)
logger.debug(
f"Writing thumbnail for trigger with data {body.data} in {camera}."
f"Writing thumbnail for trigger with data {body.data} in {camera_name}."
)
except Exception as e:
logger.error(e.with_traceback())
logger.error(
f"Failed to write thumbnail for trigger with data {body.data} in {camera}"
f"Failed to write thumbnail for trigger with data {body.data} in {camera_name}"
)
return JSONResponse(
content={
"success": True,
"message": f"Trigger updated successfully for {camera}:{name}",
"message": f"Trigger updated successfully for {camera_name}:{name}",
},
status_code=200,
)
@@ -1739,36 +1805,38 @@ def update_trigger_embedding(
@router.delete(
"/trigger/embedding/{camera}/{name}",
"/trigger/embedding/{camera_name}/{name}",
response_model=dict,
dependencies=[Depends(require_role(["admin"]))],
)
def delete_trigger_embedding(
request: Request,
camera: str,
camera_name: str,
name: str,
):
try:
trigger = Trigger.get_or_none(Trigger.camera == camera, Trigger.name == name)
trigger = Trigger.get_or_none(
Trigger.camera == camera_name, Trigger.name == name
)
if trigger is None:
return JSONResponse(
content={
"success": False,
"message": f"Trigger {camera}:{name} not found",
"message": f"Trigger {camera_name}:{name} not found",
},
status_code=500,
)
deleted = (
Trigger.delete()
.where(Trigger.camera == camera, Trigger.name == name)
.where(Trigger.camera == camera_name, Trigger.name == name)
.execute()
)
if deleted == 0:
return JSONResponse(
content={
"success": False,
"message": f"Error deleting trigger {camera}:{name}",
"message": f"Error deleting trigger {camera_name}:{name}",
},
status_code=401,
)
@@ -1776,22 +1844,22 @@ def delete_trigger_embedding(
try:
os.remove(
os.path.join(
TRIGGER_DIR, sanitize_filename(camera), f"{trigger.data}.webp"
TRIGGER_DIR, sanitize_filename(camera_name), f"{trigger.data}.webp"
)
)
logger.debug(
f"Deleted thumbnail for trigger with data {trigger.data} in {camera}."
f"Deleted thumbnail for trigger with data {trigger.data} in {camera_name}."
)
except Exception as e:
logger.error(e.with_traceback())
logger.error(
f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera}"
f"Failed to delete thumbnail for trigger with data {trigger.data} in {camera_name}"
)
return JSONResponse(
content={
"success": True,
"message": f"Trigger deleted successfully for {camera}:{name}",
"message": f"Trigger deleted successfully for {camera_name}:{name}",
},
status_code=200,
)