diff --git a/docs/docs/configuration/authentication.md b/docs/docs/configuration/authentication.md index 7e77229b6..a23e0117f 100644 --- a/docs/docs/configuration/authentication.md +++ b/docs/docs/configuration/authentication.md @@ -124,7 +124,7 @@ proxy: role: x-forwarded-groups ``` -Frigate supports both `admin` and `viewer` roles (see below). When using port `8971`, Frigate validates these headers and subsequent requests use the headers `remote-user` and `remote-role` for authorization. +Frigate supports `admin`, `viewer`, and custom roles (see below). When using port `8971`, Frigate validates these headers and subsequent requests use the headers `remote-user` and `remote-role` for authorization. A default role can be provided. Any value in the mapped `role` header will override the default. @@ -136,7 +136,7 @@ proxy: ## Role mapping -In some environments, upstream identity providers (OIDC, SAML, LDAP, etc.) do not pass a Frigate-compatible role directly, but instead pass one or more group claims. To handle this, Frigate supports a `role_map` that translates upstream group names into Frigate’s internal roles (`admin` or `viewer`). +In some environments, upstream identity providers (OIDC, SAML, LDAP, etc.) do not pass a Frigate-compatible role directly, but instead pass one or more group claims. To handle this, Frigate supports a `role_map` that translates upstream group names into Frigate’s internal roles (`admin`, `viewer`, or custom). ```yaml proxy: @@ -150,14 +150,17 @@ proxy: - access-level-security viewer: - camera-viewer + operator: # Custom role mapping + - operators ``` In this example: - If the proxy passes a role header containing `sysadmins` or `access-level-security`, the user is assigned the `admin` role. - If the proxy passes a role header containing `camera-viewer`, the user is assigned the `viewer` role. +- If the proxy passes a role header containing `operators`, the user is assigned the `operator` custom role. - If no mapping matches, Frigate falls back to `default_role` if configured. -- If `role_map` is not defined, Frigate assumes the role header directly contains `admin` or `viewer`. +- If `role_map` is not defined, Frigate assumes the role header directly contains `admin`, `viewer`, or a custom role name. #### Port Considerations @@ -167,6 +170,7 @@ In this example: - The `remote-role` header determines the user’s privileges: - **admin** → Full access (user management, configuration changes). - **viewer** → Read-only access. + - **Custom roles** → Read-only access limited to the cameras defined in `auth.roles[role]`. - Ensure your **proxy sends both user and role headers** for proper role enforcement. **Unauthenticated Port (5000)** @@ -212,6 +216,41 @@ Frigate supports user roles to control access to certain features in the UI and - **admin**: Full access to all features, including user management and configuration. - **viewer**: Read-only access to the UI and API, including viewing cameras, review items, and historical footage. Configuration editor and settings in the UI are inaccessible. +- **Custom Roles**: Arbitrary role names (alphanumeric, dots/underscores) with specific camera permissions. These extend the system for granular access (e.g., "operator" for select cameras). + +### Custom Roles and Camera Access + +The viewer role provides read-only access to all cameras in the UI and API. Custom roles allow admins to limit read-only access to specific cameras. Each role specifies an array of allowed camera names. If a user is assigned a custom role, their account is like the **viewer** role - they can only view Live, Review/History, Explore, and Export for the designated cameras. Backend API endpoints enforce this server-side (e.g., returning 403 for unauthorized cameras), and the frontend UI filters content accordingly (e.g., camera dropdowns show only permitted options). + +### Role Configuration Example + +```yaml +cameras: + front_door: + # ... camera config + side_yard: + # ... camera config + garage: + # ... camera config + +auth: + enabled: true + roles: + operator: # Custom role + - front_door + - garage # Operator can access front and garage + neighbor: + - side_yard +``` + +If you want to provide access to all cameras to a specific user, just use the **viewer** role. + +### Managing User Roles + +1. Log in as an **admin** user via port `8971` (preferred), or unauthenticated via port `5000`. +2. Navigate to **Settings**. +3. In the **Users** section, edit a user’s role by selecting from available roles (admin, viewer, or custom). +4. In the **Roles** section, add/edit/delete custom roles (select cameras via switches). Deleting a role auto-reassigns users to "viewer". ### Role Enforcement diff --git a/frigate/api/auth.py b/frigate/api/auth.py index f78a460c0..14fd804f7 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -11,7 +11,7 @@ import secrets import time from datetime import datetime from pathlib import Path -from typing import List +from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.responses import JSONResponse, RedirectResponse @@ -33,7 +33,6 @@ from frigate.models import User logger = logging.getLogger(__name__) router = APIRouter(tags=[Tags.auth]) -VALID_ROLES = ["admin", "viewer"] class RateLimiter: @@ -204,6 +203,7 @@ async def get_current_user(request: Request): def require_role(required_roles: List[str]): async def role_checker(request: Request): proxy_config: ProxyConfig = request.app.frigate_config.proxy + config_roles = list(request.app.frigate_config.auth.roles.keys()) # Get role from header (could be comma-separated) role_header = request.headers.get("remote-role") @@ -217,12 +217,12 @@ def require_role(required_roles: List[str]): if not roles: raise HTTPException(status_code=403, detail="Role not provided") - # enforce VALID_ROLES - valid_roles = [r for r in roles if r in VALID_ROLES] + # enforce config roles + valid_roles = [r for r in roles if r in config_roles] if not valid_roles: raise HTTPException( status_code=403, - detail=f"No valid roles found in {roles}. Required: {', '.join(required_roles)}", + detail=f"No valid roles found in {roles}. Required: {', '.join(required_roles)}. Available: {', '.join(config_roles)}", ) if not any(role in required_roles for role in valid_roles): @@ -238,7 +238,9 @@ def require_role(required_roles: List[str]): return role_checker -def resolve_role(headers: dict, proxy_config: ProxyConfig) -> str: +def resolve_role( + headers: dict, proxy_config: ProxyConfig, config_roles: set[str] +) -> str: """ Determine the effective role for a request based on proxy headers and configuration. @@ -247,31 +249,40 @@ def resolve_role(headers: dict, proxy_config: ProxyConfig) -> str: - If a role_map is configured, treat the header as group claims (split by proxy_config.separator) and map to roles. - If no role_map is configured, treat the header as role names directly. - 2. If no valid role is found, return proxy_config.default_role. + 2. If no valid role is found, return proxy_config.default_role if it's valid in config_roles, else 'viewer'. Args: headers (dict): Incoming request headers (case-insensitive). proxy_config (ProxyConfig): Proxy configuration. + config_roles (set[str]): Set of valid roles from config. Returns: - str: Resolved role (always one of VALID_ROLES). + str: Resolved role (one of config_roles or validated default). """ - role = proxy_config.default_role + default_role = proxy_config.default_role role_header = proxy_config.header_map.role + # Validate default_role against config; fallback to 'viewer' if invalid + validated_default = default_role if default_role in config_roles else "viewer" + if not config_roles: + validated_default = "viewer" # Edge case: no roles defined + if not role_header: logger.debug( - "No role header configured in proxy_config.header_map. Returning default role '%s'.", - role, + "No role header configured in proxy_config.header_map. Returning validated default role '%s'.", + validated_default, ) - return role + return validated_default raw_value = headers.get(role_header, "") logger.debug("Raw role header value from '%s': %r", role_header, raw_value) if not raw_value: - logger.debug("Role header missing or empty. Returning default role '%s'.", role) - return role + logger.debug( + "Role header missing or empty. Returning validated default role '%s'.", + validated_default, + ) + return validated_default # role_map configured, treat header as group claims if proxy_config.header_map.role_map: @@ -288,16 +299,18 @@ def resolve_role(headers: dict, proxy_config: ProxyConfig) -> str: logger.debug("Matched roles from role_map: %s", matched_roles) if matched_roles: - resolved = next((r for r in VALID_ROLES if r in matched_roles), role) + resolved = next( + (r for r in config_roles if r in matched_roles), validated_default + ) logger.debug("Resolved role (with role_map) to '%s'.", resolved) return resolved logger.debug( - "No role_map match for groups '%s'. Using default role '%s'.", + "No role_map match for groups '%s'. Using validated default role '%s'.", raw_value, - proxy_config.default_role, + validated_default, ) - return role + return validated_default # no role_map, treat as role names directly roles_from_header = [ @@ -306,14 +319,14 @@ def resolve_role(headers: dict, proxy_config: ProxyConfig) -> str: logger.debug("Parsed roles directly from header: %s", roles_from_header) resolved = next( - (r for r in VALID_ROLES if r in roles_from_header), - proxy_config.default_role, + (r for r in config_roles if r in roles_from_header), + validated_default, ) - if resolved == proxy_config.default_role and roles_from_header: + if resolved == validated_default and roles_from_header: logger.debug( - "Provided proxy role header values '%s' did not contain a valid role. Using default role '%s'.", + "Provided proxy role header values '%s' did not contain a valid role. Using validated default role '%s'.", raw_value, - proxy_config.default_role, + validated_default, ) else: logger.debug("Resolved role (direct header) to '%s'.", resolved) @@ -358,7 +371,8 @@ def auth(request: Request): ) # parse header and resolve a valid role - role = resolve_role(request.headers, proxy_config) + config_roles_set = set(auth_config.roles.keys()) + role = resolve_role(request.headers, proxy_config, config_roles_set) success_response.headers["remote-role"] = role return success_response @@ -452,7 +466,13 @@ def profile(request: Request): username = request.headers.get("remote-user", "anonymous") role = request.headers.get("remote-role", "viewer") - return JSONResponse(content={"username": username, "role": role}) + all_camera_names = set(request.app.frigate_config.cameras.keys()) + roles_dict = request.app.frigate_config.auth.roles + allowed_cameras = User.get_allowed_cameras(role, roles_dict, all_camera_names) + + return JSONResponse( + content={"username": username, "role": role, "allowed_cameras": allowed_cameras} + ) @router.get("/logout") @@ -483,8 +503,12 @@ def login(request: Request, body: AppPostLoginBody): password_hash = db_user.password_hash if verify_password(password, password_hash): role = getattr(db_user, "role", "viewer") - if role not in VALID_ROLES: - role = "viewer" # Enforce valid roles + config_roles_set = set(request.app.frigate_config.auth.roles.keys()) + if role not in config_roles_set: + logger.warning( + f"User {db_user.username} has an invalid role {role}, falling back to 'viewer'." + ) + role = "viewer" expiration = int(time.time()) + JWT_SESSION_LENGTH encoded_jwt = create_encoded_jwt(user, role, expiration, request.app.jwt_token) response = Response("", 200) @@ -509,11 +533,17 @@ def create_user( body: AppPostUsersBody, ): HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations + config_roles = list(request.app.frigate_config.auth.roles.keys()) if not re.match("^[A-Za-z0-9._]+$", body.username): return JSONResponse(content={"message": "Invalid username"}, status_code=400) - role = body.role if body.role in VALID_ROLES else "viewer" + if body.role not in config_roles: + return JSONResponse( + content={"message": f"Role must be one of: {', '.join(config_roles)}"}, + status_code=400, + ) + role = body.role or "viewer" password_hash = hash_password(body.password, iterations=HASH_ITERATIONS) User.insert( { @@ -584,10 +614,52 @@ async def update_role( return JSONResponse( content={"message": "Cannot modify admin user's role"}, status_code=403 ) - if body.role not in VALID_ROLES: + config_roles = list(request.app.frigate_config.auth.roles.keys()) + if body.role not in config_roles: return JSONResponse( - content={"message": "Role must be 'admin' or 'viewer'"}, status_code=400 + content={"message": f"Role must be one of: {', '.join(config_roles)}"}, + status_code=400, ) User.set_by_id(username, {User.role: body.role}) return JSONResponse(content={"success": True}) + + +async def require_camera_access( + camera_name: Optional[str] = None, + request: Request = None, +): + """Dependency to enforce camera access based on user role.""" + if camera_name is None: + return # For lists, filter later + + current_user = await get_current_user(request) + if isinstance(current_user, JSONResponse): + return current_user + + role = current_user["role"] + all_camera_names = set(request.app.frigate_config.cameras.keys()) + roles_dict = request.app.frigate_config.auth.roles + allowed_cameras = User.get_allowed_cameras(role, roles_dict, all_camera_names) + + # Admin or full access bypasses + if role == "admin" or not roles_dict.get(role): + return + + if camera_name not in allowed_cameras: + raise HTTPException( + status_code=403, + detail=f"Access denied to camera '{camera_name}'. Allowed: {allowed_cameras}", + ) + + +async def get_allowed_cameras_for_filter(request: Request): + """Dependency to get allowed_cameras for filtering lists.""" + current_user = await get_current_user(request) + if isinstance(current_user, JSONResponse): + return [] # Unauthorized: no cameras + + role = current_user["role"] + all_camera_names = set(request.app.frigate_config.cameras.keys()) + roles_dict = request.app.frigate_config.auth.roles + return User.get_allowed_cameras(role, roles_dict, all_camera_names) diff --git a/frigate/api/event.py b/frigate/api/event.py index 9557672d2..1ff570b7f 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -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, ) diff --git a/frigate/api/export.py b/frigate/api/export.py index 160434c68..08fc6b1c5 100644 --- a/frigate/api/export.py +++ b/frigate/api/export.py @@ -4,6 +4,7 @@ import logging import random import string from pathlib import Path +from typing import List import psutil from fastapi import APIRouter, Depends, Request @@ -11,7 +12,11 @@ from fastapi.responses import JSONResponse from peewee import DoesNotExist 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.request.export_recordings_body import ExportRecordingsBody from frigate.api.defs.request.export_rename_body import ExportRenameBody from frigate.api.defs.tags import Tags @@ -30,12 +35,23 @@ router = APIRouter(tags=[Tags.export]) @router.get("/exports") -def get_exports(): - exports = Export.select().order_by(Export.date.desc()).dicts().iterator() +def get_exports( + allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), +): + exports = ( + Export.select() + .where(Export.camera << allowed_cameras) + .order_by(Export.date.desc()) + .dicts() + .iterator() + ) return JSONResponse(content=[e for e in exports]) -@router.post("/export/{camera_name}/start/{start_time}/end/{end_time}") +@router.post( + "/export/{camera_name}/start/{start_time}/end/{end_time}", + dependencies=[Depends(require_camera_access)], +) def export_recording( request: Request, camera_name: str, @@ -134,9 +150,10 @@ def export_recording( @router.patch( "/export/{event_id}/rename", dependencies=[Depends(require_role(["admin"]))] ) -def export_rename(event_id: str, body: ExportRenameBody): +async def export_rename(event_id: str, body: ExportRenameBody, request: Request): try: export: Export = Export.get(Export.id == event_id) + await require_camera_access(export.camera, request=request) except DoesNotExist: return JSONResponse( content=( @@ -162,9 +179,10 @@ def export_rename(event_id: str, body: ExportRenameBody): @router.delete("/export/{event_id}", dependencies=[Depends(require_role(["admin"]))]) -def export_delete(event_id: str): +async def export_delete(event_id: str, request: Request): try: export: Export = Export.get(Export.id == event_id) + await require_camera_access(export.camera, request=request) except DoesNotExist: return JSONResponse( content=( @@ -215,9 +233,11 @@ def export_delete(event_id: str): @router.get("/exports/{export_id}") -def get_export(export_id: str): +async def get_export(export_id: str, request: Request): try: - return JSONResponse(content=model_to_dict(Export.get(Export.id == export_id))) + export = Export.get(Export.id == export_id) + await require_camera_access(export.camera, request=request) + return JSONResponse(content=model_to_dict(export)) except DoesNotExist: return JSONResponse( content={"success": False, "message": "Export not found"}, diff --git a/frigate/api/media.py b/frigate/api/media.py index 8c0943b2e..d22943d93 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -10,19 +10,19 @@ import time from datetime import datetime, timedelta, timezone from functools import reduce from pathlib import Path as FilePath -from typing import Any +from typing import Any, List from urllib.parse import unquote import cv2 import numpy as np import pytz -from fastapi import APIRouter, Path, Query, Request, Response -from fastapi.params import Depends +from fastapi import APIRouter, Depends, Path, Query, Request, Response from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from pathvalidate import sanitize_filename from peewee import DoesNotExist, fn, operator from tzlocal import get_localzone_name +from frigate.api.auth import get_allowed_cameras_for_filter, require_camera_access from frigate.api.defs.query.media_query_parameters import ( Extension, MediaEventsSnapshotQueryParams, @@ -50,12 +50,11 @@ from frigate.util.path import get_event_thumbnail_bytes logger = logging.getLogger(__name__) - router = APIRouter(tags=[Tags.media]) -@router.get("/{camera_name}") -def mjpeg_feed( +@router.get("/{camera_name}", dependencies=[Depends(require_camera_access)]) +async def mjpeg_feed( request: Request, camera_name: str, params: MediaMjpegFeedQueryParams = Depends(), @@ -111,7 +110,7 @@ def imagestream( ) -@router.get("/{camera_name}/ptz/info") +@router.get("/{camera_name}/ptz/info", dependencies=[Depends(require_camera_access)]) async def camera_ptz_info(request: Request, camera_name: str): if camera_name in request.app.frigate_config.cameras: # Schedule get_camera_info in the OnvifController's event loop @@ -127,8 +126,10 @@ async def camera_ptz_info(request: Request, camera_name: str): ) -@router.get("/{camera_name}/latest.{extension}") -def latest_frame( +@router.get( + "/{camera_name}/latest.{extension}", dependencies=[Depends(require_camera_access)] +) +async def latest_frame( request: Request, camera_name: str, extension: Extension, @@ -236,8 +237,11 @@ def latest_frame( ) -@router.get("/{camera_name}/recordings/{frame_time}/snapshot.{format}") -def get_snapshot_from_recording( +@router.get( + "/{camera_name}/recordings/{frame_time}/snapshot.{format}", + dependencies=[Depends(require_camera_access)], +) +async def get_snapshot_from_recording( request: Request, camera_name: str, frame_time: float, @@ -323,8 +327,10 @@ def get_snapshot_from_recording( ) -@router.post("/{camera_name}/plus/{frame_time}") -def submit_recording_snapshot_to_plus( +@router.post( + "/{camera_name}/plus/{frame_time}", dependencies=[Depends(require_camera_access)] +) +async def submit_recording_snapshot_to_plus( request: Request, camera_name: str, frame_time: str ): if camera_name not in request.app.frigate_config.cameras: @@ -412,11 +418,23 @@ def get_recordings_storage_usage(request: Request): @router.get("/recordings/summary") -def all_recordings_summary(params: MediaRecordingsSummaryQueryParams = Depends()): +def all_recordings_summary( + request: Request, + params: MediaRecordingsSummaryQueryParams = Depends(), + allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), +): """Returns true/false by day indicating if recordings exist""" hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(params.timezone) cameras = params.cameras + if cameras != "all": + requested = set(unquote(cameras).split(",")) + filtered = requested.intersection(allowed_cameras) + if not filtered: + return JSONResponse(content={}) + cameras = ",".join(filtered) + else: + cameras = allowed_cameras query = ( Recordings.select( @@ -445,7 +463,7 @@ def all_recordings_summary(params: MediaRecordingsSummaryQueryParams = Depends() ) if cameras != "all": - query = query.where(Recordings.camera << cameras.split(",")) + query = query.where(Recordings.camera << cameras) recording_days = query.namedtuples() days = {day.day: True for day in recording_days} @@ -453,8 +471,10 @@ def all_recordings_summary(params: MediaRecordingsSummaryQueryParams = Depends() return JSONResponse(content=days) -@router.get("/{camera_name}/recordings/summary") -def recordings_summary(camera_name: str, timezone: str = "utc"): +@router.get( + "/{camera_name}/recordings/summary", dependencies=[Depends(require_camera_access)] +) +async def recordings_summary(camera_name: str, timezone: str = "utc"): """Returns hourly summary for recordings of given camera""" hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(timezone) recording_groups = ( @@ -515,8 +535,8 @@ def recordings_summary(camera_name: str, timezone: str = "utc"): return JSONResponse(content=list(days.values())) -@router.get("/{camera_name}/recordings") -def recordings( +@router.get("/{camera_name}/recordings", dependencies=[Depends(require_camera_access)]) +async def recordings( camera_name: str, after: float = (datetime.now() - timedelta(hours=1)).timestamp(), before: float = datetime.now().timestamp(), @@ -546,9 +566,22 @@ def recordings( @router.get("/recordings/unavailable", response_model=list[dict]) -def no_recordings(params: MediaRecordingsAvailabilityQueryParams = Depends()): +async def no_recordings( + request: Request, + params: MediaRecordingsAvailabilityQueryParams = Depends(), + allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), +): """Get time ranges with no recordings.""" cameras = params.cameras + if cameras != "all": + requested = set(unquote(cameras).split(",")) + filtered = requested.intersection(allowed_cameras) + if not filtered: + return JSONResponse(content=[]) + cameras = ",".join(filtered) + else: + cameras = allowed_cameras + before = params.before or datetime.datetime.now().timestamp() after = ( params.after @@ -560,6 +593,8 @@ def no_recordings(params: MediaRecordingsAvailabilityQueryParams = Depends()): if cameras != "all": camera_list = cameras.split(",") clauses.append((Recordings.camera << camera_list)) + else: + camera_list = allowed_cameras # Get recording start times data: list[Recordings] = ( @@ -607,9 +642,10 @@ def no_recordings(params: MediaRecordingsAvailabilityQueryParams = Depends()): @router.get( "/{camera_name}/start/{start_ts}/end/{end_ts}/clip.mp4", + dependencies=[Depends(require_camera_access)], description="For iOS devices, use the master.m3u8 HLS link instead of clip.mp4. Safari does not reliably process progressive mp4 files.", ) -def recording_clip( +async def recording_clip( request: Request, camera_name: str, start_ts: float, @@ -705,9 +741,10 @@ def recording_clip( @router.get( "/vod/{camera_name}/start/{start_ts}/end/{end_ts}", + dependencies=[Depends(require_camera_access)], description="Returns an HLS playlist for the specified timestamp-range on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback.", ) -def vod_ts(camera_name: str, start_ts: float, end_ts: float): +async def vod_ts(camera_name: str, start_ts: float, end_ts: float): recordings = ( Recordings.select( Recordings.path, @@ -782,6 +819,7 @@ def vod_ts(camera_name: str, start_ts: float, end_ts: float): @router.get( "/vod/{year_month}/{day}/{hour}/{camera_name}", + dependencies=[Depends(require_camera_access)], description="Returns an HLS playlist for the specified date-time on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback.", ) def vod_hour_no_timezone(year_month: str, day: int, hour: int, camera_name: str): @@ -793,6 +831,7 @@ def vod_hour_no_timezone(year_month: str, day: int, hour: int, camera_name: str) @router.get( "/vod/{year_month}/{day}/{hour}/{camera_name}/{tz_name}", + dependencies=[Depends(require_camera_access)], description="Returns an HLS playlist for the specified date-time (with timezone) on the specified camera. Append /master.m3u8 or /index.m3u8 for HLS playback.", ) def vod_hour(year_month: str, day: int, hour: int, camera_name: str, tz_name: str): @@ -812,7 +851,8 @@ def vod_hour(year_month: str, day: int, hour: int, camera_name: str, tz_name: st "/vod/event/{event_id}", description="Returns an HLS playlist for the specified object. Append /master.m3u8 or /index.m3u8 for HLS playback.", ) -def vod_event( +async def vod_event( + request: Request, event_id: str, padding: int = Query(0, description="Padding to apply to the vod."), ): @@ -828,15 +868,7 @@ def vod_event( status_code=404, ) - if not event.has_clip: - logger.error(f"Event does not have recordings: {event_id}") - return JSONResponse( - content={ - "success": False, - "message": "Recordings not available.", - }, - status_code=404, - ) + await require_camera_access(event.camera, request=request) end_ts = ( datetime.now().timestamp() @@ -861,7 +893,7 @@ def vod_event( "/events/{event_id}/snapshot.jpg", description="Returns a snapshot image for the specified object id. NOTE: The query params only take affect while the event is in-progress. Once the event has ended the snapshot configuration is used.", ) -def event_snapshot( +async def event_snapshot( request: Request, event_id: str, params: MediaEventsSnapshotQueryParams = Depends(), @@ -871,6 +903,7 @@ def event_snapshot( try: event = Event.get(Event.id == event_id, Event.end_time != None) event_complete = True + await require_camera_access(event.camera, request=request) if not event.has_snapshot: return JSONResponse( content={"success": False, "message": "Snapshot not available"}, @@ -899,6 +932,7 @@ def event_snapshot( height=params.height, quality=params.quality, ) + await require_camera_access(camera_state.name, request=request) except Exception: return JSONResponse( content={"success": False, "message": "Ongoing event not found"}, @@ -932,7 +966,7 @@ def event_snapshot( @router.get("/events/{event_id}/thumbnail.{extension}") -def event_thumbnail( +async def event_thumbnail( request: Request, event_id: str, extension: Extension, @@ -945,6 +979,7 @@ def event_thumbnail( event_complete = False try: event: Event = Event.get(Event.id == event_id) + await require_camera_access(event.camera, request=request) if event.end_time is not None: event_complete = True @@ -1007,7 +1042,7 @@ def event_thumbnail( ) -@router.get("/{camera_name}/grid.jpg") +@router.get("/{camera_name}/grid.jpg", dependencies=[Depends(require_camera_access)]) def grid_snapshot( request: Request, camera_name: str, color: str = "green", font_scale: float = 0.5 ): @@ -1254,7 +1289,10 @@ def event_preview(request: Request, event_id: str): return preview_gif(request, event.camera, start_ts, end_ts) -@router.get("/{camera_name}/start/{start_ts}/end/{end_ts}/preview.gif") +@router.get( + "/{camera_name}/start/{start_ts}/end/{end_ts}/preview.gif", + dependencies=[Depends(require_camera_access)], +) def preview_gif( request: Request, camera_name: str, @@ -1410,7 +1448,10 @@ def preview_gif( ) -@router.get("/{camera_name}/start/{start_ts}/end/{end_ts}/preview.mp4") +@router.get( + "/{camera_name}/start/{start_ts}/end/{end_ts}/preview.mp4", + dependencies=[Depends(require_camera_access)], +) def preview_mp4( request: Request, camera_name: str, @@ -1650,8 +1691,13 @@ def preview_thumbnail(file_name: str): ####################### dynamic routes ########################### -@router.get("/{camera_name}/{label}/best.jpg") -@router.get("/{camera_name}/{label}/thumbnail.jpg") +@router.get( + "/{camera_name}/{label}/best.jpg", dependencies=[Depends(require_camera_access)] +) +@router.get( + "/{camera_name}/{label}/thumbnail.jpg", + dependencies=[Depends(require_camera_access)], +) def label_thumbnail(request: Request, camera_name: str, label: str): label = unquote(label) event_query = Event.select(fn.MAX(Event.id)).where(Event.camera == camera_name) @@ -1673,7 +1719,9 @@ def label_thumbnail(request: Request, camera_name: str, label: str): ) -@router.get("/{camera_name}/{label}/clip.mp4") +@router.get( + "/{camera_name}/{label}/clip.mp4", dependencies=[Depends(require_camera_access)] +) def label_clip(request: Request, camera_name: str, label: str): label = unquote(label) event_query = Event.select(fn.MAX(Event.id)).where( @@ -1692,7 +1740,9 @@ def label_clip(request: Request, camera_name: str, label: str): ) -@router.get("/{camera_name}/{label}/snapshot.jpg") +@router.get( + "/{camera_name}/{label}/snapshot.jpg", dependencies=[Depends(require_camera_access)] +) def label_snapshot(request: Request, camera_name: str, label: str): """Returns the snapshot image from the latest event for the given camera and label combo""" label = unquote(label) diff --git a/frigate/api/preview.py b/frigate/api/preview.py index 2db2326ab..531c1e09e 100644 --- a/frigate/api/preview.py +++ b/frigate/api/preview.py @@ -5,9 +5,10 @@ import os from datetime import datetime, timedelta, timezone import pytz -from fastapi import APIRouter +from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse +from frigate.api.auth import require_camera_access from frigate.api.defs.tags import Tags from frigate.const import BASE_DIR, CACHE_DIR, PREVIEW_FRAME_TYPE from frigate.models import Previews @@ -18,7 +19,10 @@ logger = logging.getLogger(__name__) router = APIRouter(tags=[Tags.preview]) -@router.get("/preview/{camera_name}/start/{start_ts}/end/{end_ts}") +@router.get( + "/preview/{camera_name}/start/{start_ts}/end/{end_ts}", + dependencies=[Depends(require_camera_access)], +) def preview_ts(camera_name: str, start_ts: float, end_ts: float): """Get all mp4 previews relevant for time period.""" if camera_name != "all": @@ -71,7 +75,10 @@ def preview_ts(camera_name: str, start_ts: float, end_ts: float): return JSONResponse(content=clips, status_code=200) -@router.get("/preview/{year_month}/{day}/{hour}/{camera_name}/{tz_name}") +@router.get( + "/preview/{year_month}/{day}/{hour}/{camera_name}/{tz_name}", + dependencies=[Depends(require_camera_access)], +) def preview_hour(year_month: str, day: int, hour: int, camera_name: str, tz_name: str): """Get all mp4 previews relevant for time period given the timezone""" parts = year_month.split("-") @@ -86,7 +93,10 @@ def preview_hour(year_month: str, day: int, hour: int, camera_name: str, tz_name return preview_ts(camera_name, start_ts, end_ts) -@router.get("/preview/{camera_name}/start/{start_ts}/end/{end_ts}/frames") +@router.get( + "/preview/{camera_name}/start/{start_ts}/end/{end_ts}/frames", + dependencies=[Depends(require_camera_access)], +) def get_preview_frames_from_cache(camera_name: str, start_ts: float, end_ts: float): """Get list of cached preview frames""" preview_dir = os.path.join(CACHE_DIR, "preview_frames") diff --git a/frigate/api/review.py b/frigate/api/review.py index 2ff97eeea..bddc1c0b7 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -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"}, diff --git a/frigate/config/auth.py b/frigate/config/auth.py index a202fb1af..fd5d0e394 100644 --- a/frigate/config/auth.py +++ b/frigate/config/auth.py @@ -1,6 +1,6 @@ -from typing import Optional +from typing import Dict, List, Optional -from pydantic import Field +from pydantic import Field, field_validator, model_validator from .base import FrigateBaseModel @@ -34,3 +34,41 @@ class AuthConfig(FrigateBaseModel): ) # As of Feb 2023, OWASP recommends 600000 iterations for PBKDF2-SHA256 hash_iterations: int = Field(default=600000, title="Password hash iterations") + roles: Dict[str, List[str]] = Field( + default_factory=dict, + title="Role to camera mappings. Empty list grants access to all cameras.", + ) + + @field_validator("roles") + @classmethod + def validate_roles(cls, v: Dict[str, List[str]]) -> Dict[str, List[str]]: + # Ensure role names are valid (alphanumeric with underscores) + for role in v.keys(): + if not role.replace("_", "").isalnum(): + raise ValueError( + f"Invalid role name '{role}'. Must be alphanumeric with underscores." + ) + + # Ensure 'admin' and 'viewer' are not used as custom role names + reserved_roles = {"admin", "viewer"} + if v.keys() & reserved_roles: + raise ValueError( + f"Reserved roles {reserved_roles} cannot be used as custom roles." + ) + + # Ensure no role has an empty camera list + for role, allowed_cameras in v.items(): + if not allowed_cameras: + raise ValueError( + f"Role '{role}' has no cameras assigned. Custom roles must have at least one camera." + ) + + return v + + @model_validator(mode="after") + def ensure_default_roles(self): + # Ensure admin and viewer are never overridden + self.roles["admin"] = [] + self.roles["viewer"] = [] + + return self diff --git a/frigate/config/config.py b/frigate/config/config.py index dd84639d3..23f77aa9d 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -719,6 +719,18 @@ class FrigateConfig(FrigateBaseModel): "Frigate+ is configured but clean snapshots are not enabled, submissions to Frigate+ will not be possible./" ) + # Validate auth roles against cameras + camera_names = set(self.cameras.keys()) + + for role, allowed_cameras in self.auth.roles.items(): + invalid_cameras = [ + cam for cam in allowed_cameras if cam not in camera_names + ] + if invalid_cameras: + logger.warning( + f"Role '{role}' references non-existent cameras: {invalid_cameras}. " + ) + return self @field_validator("cameras") diff --git a/frigate/models.py b/frigate/models.py index 61889fd1e..59188128b 100644 --- a/frigate/models.py +++ b/frigate/models.py @@ -135,6 +135,18 @@ class User(Model): password_hash = CharField(null=False, max_length=120) notification_tokens = JSONField() + @classmethod + def get_allowed_cameras( + cls, role: str, roles_dict: dict[str, list[str]], all_camera_names: set[str] + ) -> list[str]: + if role not in roles_dict: + return [] # Invalid role grants no access + allowed = roles_dict[role] + if not allowed: # Empty list means all cameras + return list(all_camera_names) + + return [cam for cam in allowed if cam in all_camera_names] + class Trigger(Model): camera = CharField(max_length=20) diff --git a/frigate/test/http_api/base_http_test.py b/frigate/test/http_api/base_http_test.py index e0e5fbf03..99c44d1c0 100644 --- a/frigate/test/http_api/base_http_test.py +++ b/frigate/test/http_api/base_http_test.py @@ -112,7 +112,7 @@ class BaseTestHttp(unittest.TestCase): except OSError: pass - def create_app(self, stats=None): + def create_app(self, stats=None, event_metadata_publisher=None): return create_fastapi_app( FrigateConfig(**self.minimal_config), self.db, @@ -121,7 +121,7 @@ class BaseTestHttp(unittest.TestCase): None, None, stats, - None, + event_metadata_publisher, None, ) @@ -134,12 +134,13 @@ class BaseTestHttp(unittest.TestCase): top_score: int = 100, score: int = 0, data: Json = {}, + camera: str = "front_door", ) -> Event: """Inserts a basic event model with a given id.""" return Event.insert( id=id, label="Mock", - camera="front_door", + camera=camera, start_time=start_time, end_time=end_time, top_score=top_score, @@ -158,15 +159,23 @@ class BaseTestHttp(unittest.TestCase): def insert_mock_review_segment( self, id: str, - start_time: float = datetime.datetime.now().timestamp(), - end_time: float = datetime.datetime.now().timestamp() + 20, + start_time: float | None = None, + end_time: float | None = None, severity: SeverityEnum = SeverityEnum.alert, - data: Json = {}, + data: dict | None = None, + camera: str = "front_door", ) -> ReviewSegment: """Inserts a review segment model with a given id.""" + if start_time is None: + start_time = datetime.datetime.now().timestamp() + if end_time is None: + end_time = start_time + 20 + if data is None: + data = {} + return ReviewSegment.insert( id=id, - camera="front_door", + camera=camera, start_time=start_time, end_time=end_time, severity=severity, diff --git a/frigate/test/http_api/test_http_camera_access.py b/frigate/test/http_api/test_http_camera_access.py new file mode 100644 index 000000000..db5446bff --- /dev/null +++ b/frigate/test/http_api/test_http_camera_access.py @@ -0,0 +1,169 @@ +from unittest.mock import patch + +from fastapi import HTTPException, Request +from fastapi.testclient import TestClient + +from frigate.api.auth import ( + get_allowed_cameras_for_filter, + get_current_user, +) +from frigate.models import Event, Recordings, ReviewSegment +from frigate.test.http_api.base_http_test import BaseTestHttp + + +class TestCameraAccessEventReview(BaseTestHttp): + def setUp(self): + super().setUp([Event, ReviewSegment, Recordings]) + self.app = super().create_app() + + # Mock get_current_user to return valid user for all tests + async def mock_get_current_user(): + return {"username": "test_user", "role": "user"} + + self.app.dependency_overrides[get_current_user] = mock_get_current_user + + def tearDown(self): + self.app.dependency_overrides.clear() + super().tearDown() + + def test_event_camera_access(self): + super().insert_mock_event("event1", camera="front_door") + super().insert_mock_event("event2", camera="back_door") + + self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [ + "front_door" + ] + with TestClient(self.app) as client: + resp = client.get("/events") + assert resp.status_code == 200 + ids = [e["id"] for e in resp.json()] + assert "event1" in ids + assert "event2" not in ids + + self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [ + "front_door", + "back_door", + ] + with TestClient(self.app) as client: + resp = client.get("/events") + assert resp.status_code == 200 + ids = [e["id"] for e in resp.json()] + assert "event1" in ids and "event2" in ids + + def test_review_camera_access(self): + super().insert_mock_review_segment("rev1", camera="front_door") + super().insert_mock_review_segment("rev2", camera="back_door") + + self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [ + "front_door" + ] + with TestClient(self.app) as client: + resp = client.get("/review") + assert resp.status_code == 200 + ids = [r["id"] for r in resp.json()] + assert "rev1" in ids + assert "rev2" not in ids + + self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [ + "front_door", + "back_door", + ] + with TestClient(self.app) as client: + resp = client.get("/review") + assert resp.status_code == 200 + ids = [r["id"] for r in resp.json()] + assert "rev1" in ids and "rev2" in ids + + def test_event_single_access(self): + super().insert_mock_event("event1", camera="front_door") + + # Allowed + async def mock_require_allowed(camera: str, request: Request = None): + if camera == "front_door": + return + raise HTTPException(status_code=403, detail="Access denied") + + with patch("frigate.api.event.require_camera_access", mock_require_allowed): + with TestClient(self.app) as client: + resp = client.get("/events/event1") + assert resp.status_code == 200 + assert resp.json()["id"] == "event1" + + # Disallowed + async def mock_require_disallowed(camera: str, request: Request = None): + raise HTTPException(status_code=403, detail="Access denied") + + with patch("frigate.api.event.require_camera_access", mock_require_disallowed): + with TestClient(self.app) as client: + resp = client.get("/events/event1") + assert resp.status_code == 403 + + def test_review_single_access(self): + super().insert_mock_review_segment("rev1", camera="front_door") + + # Allowed + async def mock_require_allowed(camera: str, request: Request = None): + if camera == "front_door": + return + raise HTTPException(status_code=403, detail="Access denied") + + with patch("frigate.api.review.require_camera_access", mock_require_allowed): + with TestClient(self.app) as client: + resp = client.get("/review/rev1") + assert resp.status_code == 200 + assert resp.json()["id"] == "rev1" + + # Disallowed + async def mock_require_disallowed(camera: str, request: Request = None): + raise HTTPException(status_code=403, detail="Access denied") + + with patch("frigate.api.review.require_camera_access", mock_require_disallowed): + with TestClient(self.app) as client: + resp = client.get("/review/rev1") + assert resp.status_code == 403 + + def test_event_search_access(self): + super().insert_mock_event("event1", camera="front_door") + super().insert_mock_event("event2", camera="back_door") + + self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [ + "front_door" + ] + with TestClient(self.app) as client: + resp = client.get("/events", params={"cameras": "all"}) + assert resp.status_code == 200 + ids = [e["id"] for e in resp.json()] + assert "event1" in ids + assert "event2" not in ids + + self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [ + "front_door", + "back_door", + ] + with TestClient(self.app) as client: + resp = client.get("/events", params={"cameras": "all"}) + assert resp.status_code == 200 + ids = [e["id"] for e in resp.json()] + assert "event1" in ids and "event2" in ids + + def test_event_summary_access(self): + super().insert_mock_event("event1", camera="front_door") + super().insert_mock_event("event2", camera="back_door") + + self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [ + "front_door" + ] + with TestClient(self.app) as client: + resp = client.get("/events/summary") + assert resp.status_code == 200 + summary_list = resp.json() + assert len(summary_list) == 1 + + self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [ + "front_door", + "back_door", + ] + with TestClient(self.app) as client: + resp = client.get("/events/summary") + summary_list = resp.json() + assert len(summary_list) == 2 diff --git a/frigate/test/http_api/test_http_event.py b/frigate/test/http_api/test_http_event.py index e3f41fdc3..4ac4f458d 100644 --- a/frigate/test/http_api/test_http_event.py +++ b/frigate/test/http_api/test_http_event.py @@ -1,16 +1,34 @@ from datetime import datetime +from typing import Any +from unittest.mock import Mock from fastapi.testclient import TestClient +from playhouse.shortcuts import model_to_dict -from frigate.models import Event, Recordings, ReviewSegment +from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user +from frigate.comms.event_metadata_updater import EventMetadataPublisher +from frigate.models import Event, Recordings, ReviewSegment, Timeline from frigate.test.http_api.base_http_test import BaseTestHttp class TestHttpApp(BaseTestHttp): def setUp(self): - super().setUp([Event, Recordings, ReviewSegment]) + super().setUp([Event, Recordings, ReviewSegment, Timeline]) self.app = super().create_app() + # Mock auth to bypass camera access for tests + async def mock_get_current_user(request: Any): + return {"username": "test_user", "role": "admin"} + + self.app.dependency_overrides[get_current_user] = mock_get_current_user + self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [ + "front_door" + ] + + def tearDown(self): + self.app.dependency_overrides.clear() + super().tearDown() + #################################################################################################################### ################################### GET /events Endpoint ######################################################### #################################################################################################################### @@ -135,3 +153,143 @@ class TestHttpApp(BaseTestHttp): assert len(events) == 2 assert events[0]["id"] == id assert events[1]["id"] == id2 + + def test_get_good_event(self): + id = "123456.random" + + with TestClient(self.app) as client: + super().insert_mock_event(id) + event = client.get(f"/events/{id}").json() + + assert event + assert event["id"] == id + assert event["id"] == model_to_dict(Event.get(Event.id == id))["id"] + + def test_get_bad_event(self): + id = "123456.random" + bad_id = "654321.other" + + with TestClient(self.app) as client: + super().insert_mock_event(id) + event_response = client.get(f"/events/{bad_id}") + assert event_response.status_code == 404 + assert event_response.json() == "Event not found" + + def test_delete_event(self): + id = "123456.random" + + with TestClient(self.app) as client: + super().insert_mock_event(id) + event = client.get(f"/events/{id}").json() + assert event + assert event["id"] == id + response = client.delete(f"/events/{id}", headers={"remote-role": "admin"}) + assert response.status_code == 200 + event_after_delete = client.get(f"/events/{id}") + assert event_after_delete.status_code == 404 + + def test_event_retention(self): + id = "123456.random" + + with TestClient(self.app) as client: + super().insert_mock_event(id) + client.post(f"/events/{id}/retain", headers={"remote-role": "admin"}) + event = client.get(f"/events/{id}").json() + assert event + assert event["id"] == id + assert event["retain_indefinitely"] is True + client.delete(f"/events/{id}/retain", headers={"remote-role": "admin"}) + event = client.get(f"/events/{id}").json() + assert event + assert event["id"] == id + assert event["retain_indefinitely"] is False + + def test_event_time_filtering(self): + morning_id = "123456.random" + evening_id = "654321.random" + morning = 1656590400 # 06/30/2022 6 am (GMT) + evening = 1656633600 # 06/30/2022 6 pm (GMT) + + with TestClient(self.app) as client: + super().insert_mock_event(morning_id, morning) + super().insert_mock_event(evening_id, evening) + # both events come back + events = client.get("/events").json() + print("events!!!", events) + assert events + assert len(events) == 2 + # morning event is excluded + events = client.get( + "/events", + params={"time_range": "07:00,24:00"}, + ).json() + assert events + assert len(events) == 1 + # evening event is excluded + events = client.get( + "/events", + params={"time_range": "00:00,18:00"}, + ).json() + assert events + assert len(events) == 1 + + def test_set_delete_sub_label(self): + mock_event_updater = Mock(spec=EventMetadataPublisher) + app = super().create_app(event_metadata_publisher=mock_event_updater) + id = "123456.random" + sub_label = "sub" + + def update_event(payload: Any, topic: str): + event = Event.get(id=id) + event.sub_label = payload[1] + event.save() + + mock_event_updater.publish.side_effect = update_event + + with TestClient(app) as client: + super().insert_mock_event(id) + new_sub_label_response = client.post( + f"/events/{id}/sub_label", + json={"subLabel": sub_label}, + headers={"remote-role": "admin"}, + ) + assert new_sub_label_response.status_code == 200 + event = client.get(f"/events/{id}").json() + assert event + assert event["id"] == id + assert event["sub_label"] == sub_label + empty_sub_label_response = client.post( + f"/events/{id}/sub_label", + json={"subLabel": ""}, + headers={"remote-role": "admin"}, + ) + assert empty_sub_label_response.status_code == 200 + event = client.get(f"/events/{id}").json() + assert event + assert event["id"] == id + assert event["sub_label"] == None + + def test_sub_label_list(self): + mock_event_updater = Mock(spec=EventMetadataPublisher) + app = super().create_app(event_metadata_publisher=mock_event_updater) + app.event_metadata_publisher = mock_event_updater + id = "123456.random" + sub_label = "sub" + + def update_event(payload: Any, _: str): + event = Event.get(id=id) + event.sub_label = payload[1] + event.save() + + mock_event_updater.publish.side_effect = update_event + + with TestClient(app) as client: + super().insert_mock_event(id) + client.post( + f"/events/{id}/sub_label", + json={"subLabel": sub_label}, + headers={"remote-role": "admin"}, + ) + sub_labels = client.get("/sub_labels").json() + assert sub_labels + assert sub_labels == [sub_label] diff --git a/frigate/test/http_api/test_http_review.py b/frigate/test/http_api/test_http_review.py index 469e012b2..731438e04 100644 --- a/frigate/test/http_api/test_http_review.py +++ b/frigate/test/http_api/test_http_review.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta from fastapi.testclient import TestClient from peewee import DoesNotExist -from frigate.api.auth import get_current_user +from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user from frigate.models import Event, Recordings, ReviewSegment, UserReviewStatus from frigate.review.types import SeverityEnum from frigate.test.http_api.base_http_test import BaseTestHttp @@ -21,6 +21,10 @@ class TestHttpReview(BaseTestHttp): self.app.dependency_overrides[get_current_user] = mock_get_current_user + self.app.dependency_overrides[get_allowed_cameras_for_filter] = lambda: [ + "front_door" + ] + def tearDown(self): self.app.dependency_overrides.clear() super().tearDown() diff --git a/frigate/test/test_http.py b/frigate/test/test_http.py deleted file mode 100644 index 6d60932a5..000000000 --- a/frigate/test/test_http.py +++ /dev/null @@ -1,329 +0,0 @@ -import datetime -import logging -import os -import unittest -from typing import Any -from unittest.mock import Mock - -from fastapi.testclient import TestClient -from peewee_migrate import Router -from playhouse.shortcuts import model_to_dict -from playhouse.sqlite_ext import SqliteExtDatabase -from playhouse.sqliteq import SqliteQueueDatabase - -from frigate.api.fastapi_app import create_fastapi_app -from frigate.comms.event_metadata_updater import EventMetadataPublisher -from frigate.config import FrigateConfig -from frigate.const import BASE_DIR, CACHE_DIR -from frigate.models import Event, Recordings, Timeline -from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS - - -class TestHttp(unittest.TestCase): - def setUp(self): - # setup clean database for each test run - migrate_db = SqliteExtDatabase("test.db") - del logging.getLogger("peewee_migrate").handlers[:] - router = Router(migrate_db) - router.run() - migrate_db.close() - self.db = SqliteQueueDatabase(TEST_DB) - models = [Event, Recordings, Timeline] - self.db.bind(models) - - self.minimal_config = { - "mqtt": {"host": "mqtt"}, - "cameras": { - "front_door": { - "ffmpeg": { - "inputs": [ - {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]} - ] - }, - "detect": { - "height": 1080, - "width": 1920, - "fps": 5, - }, - } - }, - } - self.test_stats = { - "camera_fps": 5.0, - "process_fps": 5.0, - "skipped_fps": 0.0, - "detection_fps": 13.7, - "detectors": { - "cpu1": { - "detection_start": 0.0, - "inference_speed": 91.43, - "pid": 42, - }, - "cpu2": { - "detection_start": 0.0, - "inference_speed": 84.99, - "pid": 44, - }, - }, - "front_door": { - "camera_fps": 0.0, - "capture_pid": 53, - "detection_fps": 0.0, - "pid": 52, - "process_fps": 0.0, - "skipped_fps": 0.0, - }, - "service": { - "storage": { - "/dev/shm": { - "free": 50.5, - "mount_type": "tmpfs", - "total": 67.1, - "used": 16.6, - }, - os.path.join(BASE_DIR, "clips"): { - "free": 42429.9, - "mount_type": "ext4", - "total": 244529.7, - "used": 189607.0, - }, - os.path.join(BASE_DIR, "recordings"): { - "free": 0.2, - "mount_type": "ext4", - "total": 8.0, - "used": 7.8, - }, - CACHE_DIR: { - "free": 976.8, - "mount_type": "tmpfs", - "total": 1000.0, - "used": 23.2, - }, - }, - "uptime": 101113, - "version": "0.10.1", - "latest_version": "0.11", - }, - } - - def tearDown(self): - if not self.db.is_closed(): - self.db.close() - - try: - for file in TEST_DB_CLEANUPS: - os.remove(file) - except OSError: - pass - - def __init_app(self, updater: Any | None = None) -> Any: - return create_fastapi_app( - FrigateConfig(**self.minimal_config), - self.db, - None, - None, - None, - None, - None, - updater, - None, - ) - - def test_get_good_event(self): - app = self.__init_app() - id = "123456.random" - - with TestClient(app) as client: - _insert_mock_event(id) - event = client.get(f"/events/{id}").json() - - assert event - assert event["id"] == id - assert event["id"] == model_to_dict(Event.get(Event.id == id))["id"] - - def test_get_bad_event(self): - app = self.__init_app() - id = "123456.random" - bad_id = "654321.other" - - with TestClient(app) as client: - _insert_mock_event(id) - event_response = client.get(f"/events/{bad_id}") - assert event_response.status_code == 404 - assert event_response.json() == "Event not found" - - def test_delete_event(self): - app = self.__init_app() - id = "123456.random" - - with TestClient(app) as client: - _insert_mock_event(id) - event = client.get(f"/events/{id}").json() - assert event - assert event["id"] == id - client.delete(f"/events/{id}", headers={"remote-role": "admin"}) - event = client.get(f"/events/{id}").json() - assert event == "Event not found" - - def test_event_retention(self): - app = self.__init_app() - id = "123456.random" - - with TestClient(app) as client: - _insert_mock_event(id) - client.post(f"/events/{id}/retain", headers={"remote-role": "admin"}) - event = client.get(f"/events/{id}").json() - assert event - assert event["id"] == id - assert event["retain_indefinitely"] is True - client.delete(f"/events/{id}/retain", headers={"remote-role": "admin"}) - event = client.get(f"/events/{id}").json() - assert event - assert event["id"] == id - assert event["retain_indefinitely"] is False - - def test_event_time_filtering(self): - app = self.__init_app() - morning_id = "123456.random" - evening_id = "654321.random" - morning = 1656590400 # 06/30/2022 6 am (GMT) - evening = 1656633600 # 06/30/2022 6 pm (GMT) - - with TestClient(app) as client: - _insert_mock_event(morning_id, morning) - _insert_mock_event(evening_id, evening) - # both events come back - events = client.get("/events").json() - assert events - assert len(events) == 2 - # morning event is excluded - events = client.get( - "/events", - params={"time_range": "07:00,24:00"}, - ).json() - assert events - # assert len(events) == 1 - # evening event is excluded - events = client.get( - "/events", - params={"time_range": "00:00,18:00"}, - ).json() - assert events - assert len(events) == 1 - - def test_set_delete_sub_label(self): - mock_event_updater = Mock(spec=EventMetadataPublisher) - app = app = self.__init_app(updater=mock_event_updater) - id = "123456.random" - sub_label = "sub" - - def update_event(payload: Any, topic: str): - event = Event.get(id=id) - event.sub_label = payload[1] - event.save() - - mock_event_updater.publish.side_effect = update_event - - with TestClient(app) as client: - _insert_mock_event(id) - new_sub_label_response = client.post( - f"/events/{id}/sub_label", - json={"subLabel": sub_label}, - headers={"remote-role": "admin"}, - ) - assert new_sub_label_response.status_code == 200 - event = client.get(f"/events/{id}").json() - assert event - assert event["id"] == id - assert event["sub_label"] == sub_label - empty_sub_label_response = client.post( - f"/events/{id}/sub_label", - json={"subLabel": ""}, - headers={"remote-role": "admin"}, - ) - assert empty_sub_label_response.status_code == 200 - event = client.get(f"/events/{id}").json() - assert event - assert event["id"] == id - assert event["sub_label"] == None - - def test_sub_label_list(self): - mock_event_updater = Mock(spec=EventMetadataPublisher) - app = self.__init_app(updater=mock_event_updater) - id = "123456.random" - sub_label = "sub" - - def update_event(payload: Any, _: str): - event = Event.get(id=id) - event.sub_label = payload[1] - event.save() - - mock_event_updater.publish.side_effect = update_event - - with TestClient(app) as client: - _insert_mock_event(id) - client.post( - f"/events/{id}/sub_label", - json={"subLabel": sub_label}, - headers={"remote-role": "admin"}, - ) - sub_labels = client.get("/sub_labels").json() - assert sub_labels - assert sub_labels == [sub_label] - - def test_config(self): - app = self.__init_app() - - with TestClient(app) as client: - config = client.get("/config").json() - assert config - assert config["cameras"]["front_door"] - - def test_recordings(self): - app = self.__init_app() - id = "123456.random" - - with TestClient(app) as client: - _insert_mock_recording(id) - response = client.get("/front_door/recordings") - assert response.status_code == 200 - recording = response.json() - assert recording - assert recording[0]["id"] == id - - -def _insert_mock_event( - id: str, - start_time: datetime.datetime = datetime.datetime.now().timestamp(), -) -> Event: - """Inserts a basic event model with a given id.""" - return Event.insert( - id=id, - label="Mock", - camera="front_door", - start_time=start_time, - end_time=start_time + 20, - top_score=100, - false_positive=False, - zones=list(), - thumbnail="", - region=[], - box=[], - area=0, - has_clip=True, - has_snapshot=True, - ).execute() - - -def _insert_mock_recording(id: str) -> Event: - """Inserts a basic recording model with a given id.""" - return Recordings.insert( - id=id, - camera="front_door", - path=f"/recordings/{id}", - start_time=datetime.datetime.now().timestamp() - 60, - end_time=datetime.datetime.now().timestamp() - 50, - duration=10, - motion=True, - objects=True, - ).execute() diff --git a/frigate/test/test_proxy_auth.py b/frigate/test/test_proxy_auth.py index cc39004c4..61955486a 100644 --- a/frigate/test/test_proxy_auth.py +++ b/frigate/test/test_proxy_auth.py @@ -19,61 +19,60 @@ class TestProxyRoleResolution(unittest.TestCase): }, ), ) + self.config_roles = list(["admin", "viewer"]) def test_role_map_single_group_match(self): headers = {"x-remote-role": "group_admin"} - role = resolve_role(headers, self.proxy_config) + role = resolve_role(headers, self.proxy_config, self.config_roles) self.assertEqual(role, "admin") def test_role_map_multiple_groups(self): - headers = {"x-remote-role": "group_viewer|group_admin"} - role = resolve_role(headers, self.proxy_config) - # admin should win since VALID_ROLES priority puts it before viewer + headers = {"x-remote-role": "group_admin|group_viewer"} + role = resolve_role(headers, self.proxy_config, self.config_roles) self.assertEqual(role, "admin") def test_direct_role_header_with_separator(self): config = self.proxy_config config.header_map.role_map = None # disable role_map - headers = {"x-remote-role": "viewer|admin"} - role = resolve_role(headers, config) - # admin should be chosen since it appears in VALID_ROLES + headers = {"x-remote-role": "admin|viewer"} + role = resolve_role(headers, config, self.config_roles) self.assertEqual(role, "admin") def test_invalid_role_header(self): config = self.proxy_config config.header_map.role_map = None headers = {"x-remote-role": "notarole"} - role = resolve_role(headers, config) + role = resolve_role(headers, config, self.config_roles) self.assertEqual(role, config.default_role) def test_missing_role_header(self): headers = {} - role = resolve_role(headers, self.proxy_config) + role = resolve_role(headers, self.proxy_config, self.config_roles) self.assertEqual(role, self.proxy_config.default_role) def test_empty_role_header(self): headers = {"x-remote-role": ""} - role = resolve_role(headers, self.proxy_config) + role = resolve_role(headers, self.proxy_config, self.config_roles) self.assertEqual(role, self.proxy_config.default_role) def test_whitespace_groups(self): headers = {"x-remote-role": " | group_admin | "} - role = resolve_role(headers, self.proxy_config) + role = resolve_role(headers, self.proxy_config, self.config_roles) self.assertEqual(role, "admin") def test_mixed_valid_and_invalid_groups(self): headers = {"x-remote-role": "bogus|group_viewer"} - role = resolve_role(headers, self.proxy_config) + role = resolve_role(headers, self.proxy_config, self.config_roles) self.assertEqual(role, "viewer") def test_case_insensitive_role_direct(self): config = self.proxy_config config.header_map.role_map = None headers = {"x-remote-role": "AdMiN"} - role = resolve_role(headers, config) + role = resolve_role(headers, config, self.config_roles) self.assertEqual(role, "admin") def test_role_map_no_match_falls_back(self): headers = {"x-remote-role": "group_unknown"} - role = resolve_role(headers, self.proxy_config) + role = resolve_role(headers, self.proxy_config, self.config_roles) self.assertEqual(role, self.proxy_config.default_role) diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index e7c06b133..7d31180a9 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -556,7 +556,68 @@ "admin": "Admin", "adminDesc": "Full access to all features.", "viewer": "Viewer", - "viewerDesc": "Limited to Live dashboards, Review, Explore, and Exports only." + "viewerDesc": "Limited to Live dashboards, Review, Explore, and Exports only.", + "customDesc": "Custom role with specific camera access." + } + } + } + }, + "roles": { + "management": { + "title": "Viewer Role Management", + "desc": "Manage custom viewer roles and their camera access permissions for this Frigate instance." + }, + "addRole": "Add Role", + "table": { + "role": "Role", + "cameras": "Cameras", + "actions": "Actions", + "noRoles": "No custom roles found.", + "editCameras": "Edit Cameras", + "deleteRole": "Delete Role" + }, + "toast": { + "success": { + "createRole": "Role {{role}} created successfully", + "updateCameras": "Cameras updated for role {{role}}", + "deleteRole": "Role {{role}} deleted successfully", + "userRolesUpdated": "{{count}} user(s) assigned to this role have been updated to 'viewer', which has access to all cameras." + }, + "error": { + "createRoleFailed": "Failed to create role: {{errorMessage}}", + "updateCamerasFailed": "Failed to update cameras: {{errorMessage}}", + "deleteRoleFailed": "Failed to delete role: {{errorMessage}}", + "userUpdateFailed": "Failed to update user roles: {{errorMessage}}" + } + }, + "dialog": { + "createRole": { + "title": "Create New Role", + "desc": "Add a new role and specify camera access permissions." + }, + "editCameras": { + "title": "Edit Role Cameras", + "desc": "Update camera access for the role {{role}}." + }, + "deleteRole": { + "title": "Delete Role", + "desc": "This action cannot be undone. This will permanently delete the role and assign any users with this role to the 'viewer' role, which will give viewer access to all cameras.", + "warn": "Are you sure you want to delete {{role}}?", + "deleting": "Deleting..." + }, + "form": { + "role": { + "title": "Role Name", + "placeholder": "Enter role name", + "desc": "Only letters, numbers, periods and underscores allowed.", + "roleIsRequired": "Role name is required", + "roleOnlyInclude": "Role name may only include letters, numbers, . or _", + "roleExists": "A role with this name already exists." + }, + "cameras": { + "title": "Cameras", + "desc": "Select cameras this role has access to. At least one camera is required.", + "required": "At least one camera must be selected." } } } diff --git a/web/src/App.tsx b/web/src/App.tsx index cd7906e97..2fbfa4c99 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -47,6 +47,9 @@ function App() { } function DefaultAppView() { + const { data: config } = useSWR("config", { + revalidateOnFocus: false, + }); return (
{isDesktop && } @@ -64,7 +67,15 @@ function DefaultAppView() { } + element={ + + } > } /> } /> diff --git a/web/src/components/auth/ProtectedRoute.tsx b/web/src/components/auth/ProtectedRoute.tsx index c35fdaebc..18dc50d53 100644 --- a/web/src/components/auth/ProtectedRoute.tsx +++ b/web/src/components/auth/ProtectedRoute.tsx @@ -6,7 +6,7 @@ import ActivityIndicator from "../indicators/activity-indicator"; export default function ProtectedRoute({ requiredRoles, }: { - requiredRoles: ("admin" | "viewer")[]; + requiredRoles: string[]; }) { const { auth } = useContext(AuthContext); diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index 6e20687cc..365fc8ccf 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -77,6 +77,8 @@ import { DialogTrigger } from "@radix-ui/react-dialog"; import { useStreamingSettings } from "@/context/streaming-settings-provider"; import { Trans, useTranslation } from "react-i18next"; import { CameraNameLabel } from "../camera/CameraNameLabel"; +import { useAllowedCameras } from "@/hooks/use-allowed-cameras"; +import { useIsCustomRole } from "@/hooks/use-is-custom-role"; type CameraGroupSelectorProps = { className?: string; @@ -650,6 +652,9 @@ export function CameraGroupEdit({ allGroupsStreamingSettings[editingGroup?.[0] ?? ""], ); + const allowedCameras = useAllowedCameras(); + const isCustomRole = useIsCustomRole(); + const [openCamera, setOpenCamera] = useState(); const birdseyeConfig = useMemo(() => config?.birdseye, [config]); @@ -837,12 +842,17 @@ export function CameraGroupEdit({ {t("group.cameras.desc")} {[ - ...(birdseyeConfig?.enabled ? ["birdseye"] : []), - ...Object.keys(config?.cameras ?? {}).sort( - (a, b) => - (config?.cameras[a]?.ui?.order ?? 0) - - (config?.cameras[b]?.ui?.order ?? 0), - ), + ...(birdseyeConfig?.enabled && + (!isCustomRole || "birdseye" in allowedCameras) + ? ["birdseye"] + : []), + ...Object.keys(config?.cameras ?? {}) + .filter((camera) => allowedCameras.includes(camera)) + .sort( + (a, b) => + (config?.cameras[a]?.ui?.order ?? 0) - + (config?.cameras[b]?.ui?.order ?? 0), + ), ].map((camera) => (
diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index f2234b359..ba5f2eb22 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -25,6 +25,7 @@ import { CamerasFilterButton } from "./CamerasFilterButton"; import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog"; import { useTranslation } from "react-i18next"; import { getTranslatedLabel } from "@/utils/i18n"; +import { useAllowedCameras } from "@/hooks/use-allowed-cameras"; const REVIEW_FILTERS = [ "cameras", @@ -72,6 +73,7 @@ export default function ReviewFilterGroup({ setMotionOnly, }: ReviewFilterGroupProps) { const { data: config } = useSWR("config"); + const allowedCameras = useAllowedCameras(); const allLabels = useMemo(() => { if (filterList?.labels) { @@ -83,7 +85,9 @@ export default function ReviewFilterGroup({ } const labels = new Set(); - const cameras = filter?.cameras || Object.keys(config.cameras); + const cameras = (filter?.cameras || allowedCameras).filter((camera) => + allowedCameras.includes(camera), + ); cameras.forEach((camera) => { if (camera == "birdseye") { @@ -106,7 +110,7 @@ export default function ReviewFilterGroup({ }); return [...labels].sort(); - }, [config, filterList, filter]); + }, [config, filterList, filter, allowedCameras]); const allZones = useMemo(() => { if (filterList?.zones) { @@ -118,7 +122,9 @@ export default function ReviewFilterGroup({ } const zones = new Set(); - const cameras = filter?.cameras || Object.keys(config.cameras); + const cameras = (filter?.cameras || allowedCameras).filter((camera) => + allowedCameras.includes(camera), + ); cameras.forEach((camera) => { if (camera == "birdseye") { @@ -134,11 +140,11 @@ export default function ReviewFilterGroup({ }); return [...zones].sort(); - }, [config, filterList, filter]); + }, [config, filterList, filter, allowedCameras]); const filterValues = useMemo( () => ({ - cameras: Object.keys(config?.cameras ?? {}).sort( + cameras: allowedCameras.sort( (a, b) => (config?.cameras[a]?.ui?.order ?? 0) - (config?.cameras[b]?.ui?.order ?? 0), @@ -146,7 +152,7 @@ export default function ReviewFilterGroup({ labels: Object.values(allLabels || {}), zones: Object.values(allZones || {}), }), - [config, allLabels, allZones], + [config, allLabels, allZones, allowedCameras], ); const groups = useMemo(() => { diff --git a/web/src/components/filter/SearchFilterGroup.tsx b/web/src/components/filter/SearchFilterGroup.tsx index 1702fcc2a..b96ed7dd7 100644 --- a/web/src/components/filter/SearchFilterGroup.tsx +++ b/web/src/components/filter/SearchFilterGroup.tsx @@ -24,9 +24,9 @@ import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog"; import SearchFilterDialog from "../overlay/dialog/SearchFilterDialog"; import { CalendarRangeFilterButton } from "./CalendarFilterButton"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; - import { useTranslation } from "react-i18next"; import { getTranslatedLabel } from "@/utils/i18n"; +import { useAllowedCameras } from "@/hooks/use-allowed-cameras"; type SearchFilterGroupProps = { className: string; @@ -46,6 +46,7 @@ export default function SearchFilterGroup({ const { data: config } = useSWR("config", { revalidateOnFocus: false, }); + const allowedCameras = useAllowedCameras(); const allLabels = useMemo(() => { if (filterList?.labels) { @@ -57,7 +58,9 @@ export default function SearchFilterGroup({ } const labels = new Set(); - const cameras = filter?.cameras || Object.keys(config.cameras); + const cameras = (filter?.cameras || allowedCameras).filter((camera) => + allowedCameras.includes(camera), + ); cameras.forEach((camera) => { if (camera == "birdseye") { @@ -87,7 +90,7 @@ export default function SearchFilterGroup({ }); return [...labels].sort(); - }, [config, filterList, filter]); + }, [config, filterList, filter, allowedCameras]); const allZones = useMemo(() => { if (filterList?.zones) { @@ -99,7 +102,9 @@ export default function SearchFilterGroup({ } const zones = new Set(); - const cameras = filter?.cameras || Object.keys(config.cameras); + const cameras = (filter?.cameras || allowedCameras).filter((camera) => + allowedCameras.includes(camera), + ); cameras.forEach((camera) => { if (camera == "birdseye") { @@ -118,16 +123,16 @@ export default function SearchFilterGroup({ }); return [...zones].sort(); - }, [config, filterList, filter]); + }, [config, filterList, filter, allowedCameras]); const filterValues = useMemo( () => ({ - cameras: Object.keys(config?.cameras || {}), + cameras: allowedCameras, labels: Object.values(allLabels || {}), zones: Object.values(allZones || {}), search_type: ["thumbnail", "description"] as SearchSource[], }), - [config, allLabels, allZones], + [allLabels, allZones, allowedCameras], ); const availableSortTypes = useMemo(() => { diff --git a/web/src/components/overlay/CreateRoleDialog.tsx b/web/src/components/overlay/CreateRoleDialog.tsx new file mode 100644 index 000000000..d45dd6d2a --- /dev/null +++ b/web/src/components/overlay/CreateRoleDialog.tsx @@ -0,0 +1,228 @@ +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { Switch } from "@/components/ui/switch"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import { useEffect, useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useTranslation } from "react-i18next"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { CameraNameLabel } from "../camera/CameraNameLabel"; + +type CreateRoleOverlayProps = { + show: boolean; + config: FrigateConfig; + onCreate: (role: string, cameras: string[]) => void; + onCancel: () => void; +}; + +export default function CreateRoleDialog({ + show, + config, + onCreate, + onCancel, +}: CreateRoleOverlayProps) { + const { t } = useTranslation(["views/settings"]); + const [isLoading, setIsLoading] = useState(false); + + const cameras = Object.keys(config.cameras || {}); + + const existingRoles = Object.keys(config.auth?.roles || {}); + + const formSchema = z.object({ + role: z + .string() + .min(1, t("roles.dialog.form.role.roleIsRequired")) + .regex(/^[A-Za-z0-9._]+$/, { + message: t("roles.dialog.form.role.roleOnlyInclude"), + }) + .refine((role) => !existingRoles.includes(role), { + message: t("roles.dialog.form.role.roleExists"), + }), + cameras: z + .array(z.string()) + .min(1, t("roles.dialog.form.cameras.required")), + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + mode: "onChange", + defaultValues: { + role: "", + cameras: [], + }, + }); + + const onSubmit = async (values: z.infer) => { + setIsLoading(true); + try { + await onCreate(values.role, values.cameras); + form.reset(); + } catch (error) { + // Error handled in parent + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + if (!show) { + form.reset({ + role: "", + cameras: [], + }); + } + }, [show, form]); + + const handleCancel = () => { + form.reset({ + role: "", + cameras: [], + }); + onCancel(); + }; + + return ( + + + + {t("roles.dialog.createRole.title")} + + {t("roles.dialog.createRole.desc")} + + + +
+ + ( + + + {t("roles.dialog.form.role.title")} + + + + + + {t("roles.dialog.form.role.desc")} + + + + )} + /> + +
+ {t("roles.dialog.form.cameras.title")} + + {t("roles.dialog.form.cameras.desc")} + +
+ {cameras.map((camera) => ( + { + return ( + +
+ + + +
+ + { + return checked + ? field.onChange([ + ...(field.value as string[]), + camera, + ]) + : field.onChange( + (field.value as string[])?.filter( + (value: string) => value !== camera, + ) || [], + ); + }} + /> + +
+ ); + }} + /> + ))} +
+ +
+ + +
+
+ + +
+
+
+ + +
+
+ ); +} diff --git a/web/src/components/overlay/CreateTriggerDialog.tsx b/web/src/components/overlay/CreateTriggerDialog.tsx index 4d21dc6c8..6742ad136 100644 --- a/web/src/components/overlay/CreateTriggerDialog.tsx +++ b/web/src/components/overlay/CreateTriggerDialog.tsx @@ -190,7 +190,7 @@ export default function CreateTriggerDialog({
void; + onCreate: (user: string, password: string, role: string) => void; onCancel: () => void; }; @@ -123,7 +123,7 @@ export default function CreateUserDialog({ void; + onDelete: () => void; +}; + +export default function DeleteRoleDialog({ + show, + role, + onCancel, + onDelete, +}: DeleteRoleDialogProps) { + const { t } = useTranslation("views/settings"); + const [isLoading, setIsLoading] = useState(false); + + const handleDelete = async () => { + setIsLoading(true); + try { + await onDelete(); + } catch (error) { + // Error handled in parent + } finally { + setIsLoading(false); + } + }; + + return ( + + + + {t("roles.dialog.deleteRole.title")} + + , + }} + /> + + + +
+
+

+ }} + > + roles.dialog.deleteRole.warn + +

+
+
+ + +
+
+ + +
+
+
+
+
+ ); +} diff --git a/web/src/components/overlay/EditRoleCamerasDialog.tsx b/web/src/components/overlay/EditRoleCamerasDialog.tsx new file mode 100644 index 000000000..7aee546df --- /dev/null +++ b/web/src/components/overlay/EditRoleCamerasDialog.tsx @@ -0,0 +1,195 @@ +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { Switch } from "@/components/ui/switch"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Trans, useTranslation } from "react-i18next"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; + +type EditRoleCamerasOverlayProps = { + show: boolean; + config: FrigateConfig; + role: string; + currentCameras: string[]; + onSave: (cameras: string[]) => void; + onCancel: () => void; +}; + +export default function EditRoleCamerasDialog({ + show, + config, + role, + currentCameras, + onSave, + onCancel, +}: EditRoleCamerasOverlayProps) { + const { t } = useTranslation(["views/settings"]); + const [isLoading, setIsLoading] = useState(false); + + const cameras = Object.keys(config.cameras || {}); + + const formSchema = z.object({ + cameras: z + .array(z.string()) + .min(1, t("roles.dialog.form.cameras.required")), + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + mode: "onChange", + defaultValues: { + cameras: currentCameras, + }, + }); + + const onSubmit = async (values: z.infer) => { + setIsLoading(true); + try { + await onSave(values.cameras); + form.reset(); + } catch (error) { + // Error handled in parent + } finally { + setIsLoading(false); + } + }; + + const handleCancel = () => { + form.reset({ + cameras: currentCameras, + }); + onCancel(); + }; + + return ( + + + + + {t("roles.dialog.editCameras.title", { role })} + + + }} + > + roles.dialog.editCameras.desc + + + + + + +
+ {t("roles.dialog.form.cameras.title")} + + {t("roles.dialog.form.cameras.desc")} + +
+ {cameras.map((camera) => ( + { + return ( + +
+ + + +
+ + { + return checked + ? field.onChange([ + ...(field.value as string[]), + camera, + ]) + : field.onChange( + (field.value as string[])?.filter( + (value: string) => value !== camera, + ) || [], + ); + }} + /> + +
+ ); + }} + /> + ))} +
+ +
+ + +
+
+ + +
+
+
+ + +
+
+ ); +} diff --git a/web/src/components/overlay/RoleChangeDialog.tsx b/web/src/components/overlay/RoleChangeDialog.tsx index ed9e73436..c8b356665 100644 --- a/web/src/components/overlay/RoleChangeDialog.tsx +++ b/web/src/components/overlay/RoleChangeDialog.tsx @@ -1,5 +1,5 @@ import { Trans, useTranslation } from "react-i18next"; -import { Button } from "../ui/button"; +import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, @@ -7,22 +7,23 @@ import { DialogFooter, DialogHeader, DialogTitle, -} from "../ui/dialog"; +} from "@/components/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from "../ui/select"; +} from "@/components/ui/select"; import { useState } from "react"; import { LuShield, LuUser } from "react-icons/lu"; type RoleChangeDialogProps = { show: boolean; username: string; - currentRole: "admin" | "viewer"; - onSave: (role: "admin" | "viewer") => void; + currentRole: string; + availableRoles: string[]; + onSave: (role: string) => void; onCancel: () => void; }; @@ -30,13 +31,12 @@ export default function RoleChangeDialog({ show, username, currentRole, + availableRoles, onSave, onCancel, }: RoleChangeDialogProps) { const { t } = useTranslation(["views/settings"]); - const [selectedRole, setSelectedRole] = useState<"admin" | "viewer">( - currentRole, - ); + const [selectedRole, setSelectedRole] = useState(currentRole); return ( @@ -73,31 +73,46 @@ export default function RoleChangeDialog({ : {t("users.dialog.changeRole.roleInfo.viewerDesc")} + {availableRoles + .filter((role) => role !== "admin" && role !== "viewer") + .map((role) => ( +
  • + {role}:{" "} + {t("users.dialog.changeRole.roleInfo.customDesc")} +
  • + ))}
    - - -
    - - {t("role.admin", { ns: "common" })} -
    -
    - -
    - - {t("role.viewer", { ns: "common" })} -
    -
    + {availableRoles.map((role) => ( + +
    + {role === "admin" ? ( + + ) : role === "viewer" ? ( + + ) : ( + + )} + + {role === "admin" + ? t("role.admin", { ns: "common" }) + : role === "viewer" + ? t("role.viewer", { ns: "common" }) + : role} + +
    +
    + ))}
    @@ -108,6 +123,7 @@ export default function RoleChangeDialog({ + // Users section + const UsersSection = ( + <> +
    +
    + + {t("users.management.title")} + +

    + {t("users.management.desc")} +

    -
    -
    -
    - - + + +
    +
    +
    +
    + + + + {t("users.table.username")} + + {t("users.table.role")} + + {t("users.table.actions")} + + + + + {users.length === 0 ? ( - - {t("users.table.username")} - - {t("users.table.role")} - - {t("users.table.actions")} - + + {t("users.table.noUsers")} + - - - {users.length === 0 ? ( - - - {t("users.table.noUsers")} + ) : ( + users.map((user) => ( + + +
    + {user.username === "admin" ? ( + + ) : ( + + )} + {user.username} +
    -
    - ) : ( - users.map((user) => ( - - -
    - {user.username === "admin" ? ( - - ) : ( - - )} - {user.username} -
    -
    - - - {t("role." + (user.role || "viewer"), { - ns: "common", - })} - - - - -
    - {user.username !== "admin" && ( + + + {t("role." + (user.role || "viewer"), { + ns: "common", + })} + + + + +
    + {user.username !== "admin" && + user.username !== "viewer" && ( + + +

    {t("users.updatePassword")}

    +
    +
    + + {user.username !== "admin" && ( -

    {t("users.updatePassword")}

    +

    {t("users.table.deleteUser")}

    - - {user.username !== "admin" && ( - - - - - -

    {t("users.table.deleteUser")}

    -
    -
    - )} -
    -
    -
    - - )) - )} - -
    -
    + )} +
    + + + + )) + )} + +
    - setShowSetPassword(false)} @@ -376,10 +583,218 @@ export default function AuthenticationView() { show={showRoleChange} username={selectedUser} currentRole={selectedUserRole} - onSave={(role) => onChangeRole(selectedUser, role)} + availableRoles={availableRoles} + onSave={(role) => onChangeRole(selectedUser!, role)} onCancel={() => setShowRoleChange(false)} /> )} + + ); + + // Roles section + const RolesSection = ( + <> +
    +
    + + {t("roles.management.title")} + +

    + {t("roles.management.desc")} +

    +
    + +
    +
    +
    +
    + + + + + {t("roles.table.role")} + + {t("roles.table.cameras")} + + {t("roles.table.actions")} + + + + + {roles.length === 0 ? ( + + + {t("roles.table.noRoles")} + + + ) : ( + roles.map((roleData) => ( + + + {roleData.name} + + + {roleData.cameras.length === 0 ? ( + + {t("menu.live.allCameras", { ns: "common" })} + + ) : roleData.cameras.length > 5 ? ( + + {roleData.cameras.length} cameras + + ) : ( +
    + {roleData.cameras.map((camera) => ( + + + + ))} +
    + )} +
    + + +
    + {roleData.name !== "admin" && + roleData.name !== "viewer" && ( + <> + + + + + +

    {t("roles.table.editCameras")}

    +
    +
    + + + + + + +

    {t("roles.table.deleteRole")}

    +
    +
    + + )} +
    +
    +
    +
    + )) + )} +
    +
    +
    +
    +
    + setShowCreateRole(false)} + /> + {selectedRole && ( + { + setShowEditRole(false); + setSelectedRole(undefined); + setCurrentRoleCameras([]); + }} + /> + )} + { + setShowDeleteRole(false); + setSelectedRoleForDelete(""); + }} + onDelete={async () => { + if (selectedRoleForDelete) { + try { + await onDeleteRole(selectedRoleForDelete); + } catch (error) { + // Error handling is already done in onDeleteRole + } + } + }} + /> + + ); + + return ( +
    + +
    + {section === "users" && UsersSection} + {section === "roles" && RolesSection} + {!section && ( + <> + {UsersSection} + + {RolesSection} + + )} +
    ); } diff --git a/web/src/views/settings/RolesView.tsx b/web/src/views/settings/RolesView.tsx new file mode 100644 index 000000000..5afbf2eb0 --- /dev/null +++ b/web/src/views/settings/RolesView.tsx @@ -0,0 +1,5 @@ +import AuthenticationView from "./AuthenticationView"; + +export default function RolesView() { + return ; +} diff --git a/web/src/views/settings/UsersView.tsx b/web/src/views/settings/UsersView.tsx new file mode 100644 index 000000000..a30d7356f --- /dev/null +++ b/web/src/views/settings/UsersView.tsx @@ -0,0 +1,5 @@ +import AuthenticationView from "./AuthenticationView"; + +export default function UsersView() { + return ; +}