diff --git a/docs/docs/configuration/authentication.md b/docs/docs/configuration/authentication.md index bf878d6bd..7e77229b6 100644 --- a/docs/docs/configuration/authentication.md +++ b/docs/docs/configuration/authentication.md @@ -59,6 +59,7 @@ The default session length for user authentication in Frigate is 24 hours. This While the default provides a balance of security and convenience, you can customize this duration to suit your specific security requirements and user experience preferences. The session length is configured in seconds. The default value of `86400` will expire the authentication session after 24 hours. Some other examples: + - `0`: Setting the session length to 0 will require a user to log in every time they access the application or after a very short, immediate timeout. - `604800`: Setting the session length to 604800 will require a user to log in if the token is not refreshed for 7 days. @@ -133,6 +134,31 @@ proxy: default_role: viewer ``` +## 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`). + +```yaml +proxy: + ... + header_map: + user: x-forwarded-user + role: x-forwarded-groups + role_map: + admin: + - sysadmins + - access-level-security + viewer: + - camera-viewer +``` + +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 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`. + #### Port Considerations **Authenticated Port (8971)** diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index c1c512fb2..7b324801b 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -88,7 +88,13 @@ proxy: # See the docs for more info. header_map: user: x-forwarded-user - role: x-forwarded-role + role: x-forwarded-groups + role_map: + admin: + - sysadmins + - access-level-security + viewer: + - camera-viewer # Optional: Url for logging out a user. This sets the location of the logout url in # the UI. logout_url: /api/logout diff --git a/frigate/api/auth.py b/frigate/api/auth.py index 9459c4ac8..5bdd66926 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -217,15 +217,23 @@ def require_role(required_roles: List[str]): if not roles: raise HTTPException(status_code=403, detail="Role not provided") - # Check if any role matches required_roles - if not any(role in required_roles for role in roles): + # enforce VALID_ROLES + valid_roles = [r for r in roles if r in VALID_ROLES] + if not valid_roles: raise HTTPException( status_code=403, - detail=f"Role {', '.join(roles)} not authorized. Required: {', '.join(required_roles)}", + detail=f"No valid roles found in {roles}. Required: {', '.join(required_roles)}", ) - # Return the first matching role - return next((role for role in roles if role in required_roles), roles[0]) + if not any(role in required_roles for role in valid_roles): + raise HTTPException( + status_code=403, + detail=f"Role {', '.join(valid_roles)} not authorized. Required: {', '.join(required_roles)}", + ) + + return next( + (role for role in valid_roles if role in required_roles), valid_roles[0] + ) return role_checker @@ -266,22 +274,38 @@ def auth(request: Request): else "anonymous" ) + # start with default_role + role = proxy_config.default_role + + # first try: explicit role header role_header = proxy_config.header_map.role - role = ( - request.headers.get(role_header, default=proxy_config.default_role) - if role_header - else proxy_config.default_role - ) - - # if comma-separated with "admin", use "admin", - # if comma-separated with "viewer", use "viewer", - # else use default role - - roles = [r.strip() for r in role.split(proxy_config.separator)] if role else [] - success_response.headers["remote-role"] = next( - (r for r in VALID_ROLES if r in roles), proxy_config.default_role - ) + if role_header: + raw_value = request.headers.get(role_header, "") + if proxy_config.header_map.role_map and raw_value: + # treat as group claim + groups = [ + g.strip() + for g in raw_value.replace(" ", ",").split(",") + if g.strip() + ] + for ( + candidate_role, + required_groups, + ) in proxy_config.header_map.role_map.items(): + if any(group in groups for group in required_groups): + role = candidate_role + break + elif raw_value: + normalized_role = raw_value.strip().lower() + if normalized_role in VALID_ROLES: + role = normalized_role + else: + logger.warning( + f"Provided proxy role header contains invalid value '{raw_value}'. Using default role '{proxy_config.default_role}'." + ) + role = proxy_config.default_role + success_response.headers["remote-role"] = role return success_response # now apply authentication diff --git a/frigate/config/proxy.py b/frigate/config/proxy.py index 68bd400e7..a46b7b897 100644 --- a/frigate/config/proxy.py +++ b/frigate/config/proxy.py @@ -16,6 +16,10 @@ class HeaderMappingConfig(FrigateBaseModel): default=None, title="Header name from upstream proxy to identify user role.", ) + role_map: Optional[dict[str, list[str]]] = Field( + default_factory=dict, + title=("Mapping of Frigate roles to upstream group values. "), + ) class ProxyConfig(FrigateBaseModel):