diff --git a/docs/docs/configuration/authentication.md b/docs/docs/configuration/authentication.md index 13638c511b..a27d86a80d 100644 --- a/docs/docs/configuration/authentication.md +++ b/docs/docs/configuration/authentication.md @@ -216,9 +216,9 @@ A default role can be provided. Any value in the mapped `role` header will overr Navigate to and set the default role. -| Field | Description | -| ---------------- | ------------------------------------------------------------- | -| **Default role** | Fallback role when no role header is present (e.g., `viewer`) | +| Field | Description | +| ---------------- | ---------------------------------------------------------------------------------------------------- | +| **Default role** | Fallback role when no role header is present (e.g., `viewer`), or `None (deny access)` to reject unmapped users | @@ -232,6 +232,14 @@ proxy: +Setting `default_role` to `none` denies access instead of falling back to a role. Any proxy-authenticated user whose headers do not match an explicit `role_map` entry receives a 403 response. This is useful when the upstream proxy authenticates a broader set of users than should reach Frigate, so that only mapped groups are allowed in. + +```yaml +proxy: + ... + default_role: none +``` + ## 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`, `viewer`, or custom). This is configurable via YAML in the configuration file: @@ -257,7 +265,7 @@ 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 no mapping matches, Frigate falls back to `default_role` if configured, or denies access if `default_role` is `none`. - If `role_map` is not defined, Frigate assumes the role header directly contains `admin`, `viewer`, or a custom role name. **Note on matching semantics:** @@ -331,7 +339,7 @@ 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**: Arbitrary role names (alphanumeric, dots/underscores) with specific camera permissions. These extend the system for granular access (e.g., "operator" for select cameras). The names `admin`, `viewer`, and `none` are reserved and cannot be used. ### Custom Roles and Camera Access diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml index 5d0e0adbe0..0bfadd8331 100644 --- a/docs/static/frigate-api.yaml +++ b/docs/static/frigate-api.yaml @@ -62,6 +62,9 @@ paths: type: string '401': description: Authentication Failed + '403': + description: Access Denied (proxy user resolved to a default role of + 'none') security: [] x-required-role: public /profile: diff --git a/frigate/api/auth.py b/frigate/api/auth.py index 6d6ff30d4e..81c88d80fa 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -497,6 +497,7 @@ def resolve_role( Admin matches short-circuit to admin. - 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 if it's valid in config_roles, else 'viewer'. + The literal value 'none' is a valid default and means access should be denied. Args: headers (dict): Incoming request headers (case-insensitive). @@ -509,10 +510,17 @@ def resolve_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" + # Validate default_role against config; fallback to 'viewer' if invalid. + # "none" is a sentinel meaning "deny access when no mapping matches"; it is + # reserved in AuthConfig.validate_roles so it is never a configured role. + validated_default = ( + default_role + if default_role in config_roles or default_role == "none" + else "viewer" + ) if not config_roles: - validated_default = "viewer" # Edge case: no roles defined + # Edge case: no roles defined + validated_default = "none" if default_role == "none" else "viewer" if not role_header: logger.debug( @@ -617,6 +625,9 @@ def resolve_role( }, }, 401: {"description": "Authentication Failed"}, + 403: { + "description": "Access Denied (proxy user resolved to a default role of 'none')" + }, }, ) def auth(request: Request): @@ -666,6 +677,10 @@ def auth(request: Request): config_roles_set = set(auth_config.roles.keys()) role = resolve_role(request.headers, proxy_config, config_roles_set) + if role == "none": + logger.debug("Resolved role is 'none', denying access") + return Response("", status_code=403) + success_response.headers["remote-role"] = role deny_status = deny_response_for_media_uri(original_url, role, frigate_config) diff --git a/frigate/config/auth.py b/frigate/config/auth.py index 04beeb7757..845ec45707 100644 --- a/frigate/config/auth.py +++ b/frigate/config/auth.py @@ -78,11 +78,14 @@ class AuthConfig(FrigateBaseModel): 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: + # 'none' is the deny sentinel for proxy.default_role, where it is matched + # case-insensitively, so every casing of it has to be reserved here + used_reserved = sorted( + r for r in v if r in ("admin", "viewer") or r.lower() == "none" + ) + if used_reserved: raise ValueError( - f"Reserved roles {reserved_roles} cannot be used as custom roles." + f"Reserved role name(s) {', '.join(used_reserved)} cannot be used as custom roles." ) # Ensure no role has an empty camera list diff --git a/frigate/config/proxy.py b/frigate/config/proxy.py index 196110520b..939585f62c 100644 --- a/frigate/config/proxy.py +++ b/frigate/config/proxy.py @@ -43,7 +43,7 @@ class ProxyConfig(FrigateBaseModel): default_role: str | None = Field( default="viewer", title="Default role", - description="Default role assigned to proxy-authenticated users when no role mapping applies.", + description="Default role assigned to proxy-authenticated users when no role mapping applies. Set to 'none' to deny access to unmapped users.", ) separator: str | None = Field( default=",", @@ -51,6 +51,16 @@ class ProxyConfig(FrigateBaseModel): description="Character used to split multiple values provided in proxy headers.", ) + @field_validator("default_role", mode="before") + @classmethod + def normalize_deny_sentinel(cls, v): + # Fail closed on capitalization: an unnormalized "None" would miss the + # sentinel and fall back to viewer, granting the access it was meant to + # deny. Other role names stay case-sensitive. + if isinstance(v, str) and v.strip().lower() == "none": + return "none" + return v + @field_validator("separator", mode="before") @classmethod def validate_separator_length(cls, v): diff --git a/frigate/test/test_proxy_auth.py b/frigate/test/test_proxy_auth.py index e4d2c9ce96..1833fd26b7 100644 --- a/frigate/test/test_proxy_auth.py +++ b/frigate/test/test_proxy_auth.py @@ -1,7 +1,9 @@ import unittest +from pydantic import ValidationError + from frigate.api.auth import resolve_role -from frigate.config import HeaderMappingConfig, ProxyConfig +from frigate.config import AuthConfig, HeaderMappingConfig, ProxyConfig from frigate.config.env import FRIGATE_ENV_VARS @@ -94,6 +96,137 @@ class TestProxyRoleResolution(unittest.TestCase): self.assertEqual(role, self.proxy_config.default_role) +class TestDefaultRoleNone(unittest.TestCase): + def setUp(self): + self.proxy_config = ProxyConfig( + auth_secret=None, + default_role="none", + separator="|", + header_map=HeaderMappingConfig( + user="x-remote-user", + role="x-remote-role", + role_map={ + "admin": ["group_admin"], + "viewer": ["group_viewer"], + }, + ), + ) + self.config_roles = list(["admin", "viewer"]) + + def test_default_role_none_no_match(self): + """Unmatched groups resolve to 'none' when default_role is 'none'.""" + headers = {"x-remote-role": "group_unknown"} + role = resolve_role(headers, self.proxy_config, self.config_roles) + self.assertEqual(role, "none") + + def test_default_role_none_with_match(self): + """Matched groups still resolve normally when default_role is 'none'.""" + headers = {"x-remote-role": "group_admin"} + role = resolve_role(headers, self.proxy_config, self.config_roles) + self.assertEqual(role, "admin") + + def test_default_role_none_missing_header(self): + """A missing role header resolves to 'none'.""" + headers = {} + role = resolve_role(headers, self.proxy_config, self.config_roles) + self.assertEqual(role, "none") + + def test_default_role_none_empty_header(self): + """An empty role header resolves to 'none'.""" + headers = {"x-remote-role": ""} + role = resolve_role(headers, self.proxy_config, self.config_roles) + self.assertEqual(role, "none") + + def test_default_role_none_no_role_map(self): + """An invalid direct role name resolves to 'none' without a role_map.""" + config = ProxyConfig( + auth_secret=None, + default_role="none", + separator="|", + header_map=HeaderMappingConfig( + user="x-remote-user", + role="x-remote-role", + role_map=None, + ), + ) + headers = {"x-remote-role": "notarole"} + role = resolve_role(headers, config, self.config_roles) + self.assertEqual(role, "none") + + def test_default_role_none_no_role_header_configured(self): + """Proxy configs without a role header resolve to 'none'.""" + config = ProxyConfig( + auth_secret=None, + default_role="none", + separator="|", + header_map=HeaderMappingConfig(user="x-remote-user"), + ) + role = resolve_role({}, config, self.config_roles) + self.assertEqual(role, "none") + + def test_default_role_none_no_roles_configured(self): + """'none' survives the empty config_roles edge case.""" + headers = {"x-remote-role": "group_admin"} + role = resolve_role(headers, self.proxy_config, set()) + self.assertEqual(role, "none") + + def test_default_role_none_is_case_insensitive(self): + """Capitalized spellings must deny, not fall back to viewer.""" + for spelling in ("None", "NONE", "nOnE", " none "): + with self.subTest(default_role=spelling): + config = ProxyConfig( + auth_secret=None, + default_role=spelling, + separator="|", + header_map=HeaderMappingConfig( + user="x-remote-user", + role="x-remote-role", + role_map={"admin": ["group_admin"]}, + ), + ) + self.assertEqual(config.default_role, "none") + role = resolve_role( + {"x-remote-role": "group_unknown"}, config, self.config_roles + ) + self.assertEqual(role, "none") + + def test_other_role_names_stay_case_sensitive(self): + """Only the deny sentinel is normalized; role names are untouched.""" + config = ProxyConfig(default_role="Operator") + self.assertEqual(config.default_role, "Operator") + + +class TestReservedRoleNames(unittest.TestCase): + def test_reserved_names_rejected(self): + """admin, viewer, and the 'none' deny sentinel cannot be custom roles.""" + for name in ("admin", "viewer", "none"): + with self.subTest(role=name): + with self.assertRaises(ValidationError): + AuthConfig(roles={name: ["front_door"]}) + + def test_custom_role_still_allowed(self): + config = AuthConfig(roles={"operator": ["front_door"]}) + self.assertEqual(config.roles["operator"], ["front_door"]) + + def test_error_names_the_offending_role(self): + """The message must say which name to rename, in a stable order.""" + with self.assertRaises(ValidationError) as ctx: + AuthConfig(roles={"none": ["front_door"], "admin": ["front_door"]}) + self.assertIn("admin, none", str(ctx.exception)) + + def test_none_reserved_in_every_casing(self): + """proxy.default_role folds case, so a 'None' role would be unreachable.""" + for name in ("None", "NONE", "nOnE"): + with self.subTest(role=name): + with self.assertRaises(ValidationError): + AuthConfig(roles={name: ["front_door"]}) + + def test_case_variant_of_a_normal_role_still_allowed(self): + """Only 'none' folds case; other role names are untouched.""" + config = AuthConfig(roles={"Operator": ["front_door"]}) + self.assertEqual(config.roles["Operator"], ["front_door"]) + + class TestProxyAuthSecretEnvString(unittest.TestCase): def setUp(self): self._original_env_vars = dict(FRIGATE_ENV_VARS) diff --git a/web/public/locales/en/config/global.json b/web/public/locales/en/config/global.json index af1ed4fcbe..b9149dd203 100644 --- a/web/public/locales/en/config/global.json +++ b/web/public/locales/en/config/global.json @@ -212,7 +212,7 @@ }, "default_role": { "label": "Default role", - "description": "Default role assigned to proxy-authenticated users when no role mapping applies." + "description": "Default role assigned to proxy-authenticated users when no role mapping applies. Set to 'none' to deny access to unmapped users." }, "separator": { "label": "Separator character", diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 32338a0e92..44d8bfb609 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1738,7 +1738,8 @@ }, "defaultRole": { "admin": "Admin", - "viewer": "Viewer" + "viewer": "Viewer", + "none": "None (deny access)" } }, "globalConfig": { diff --git a/web/src/components/config-form/theme/widgets/DefaultRoleWidget.tsx b/web/src/components/config-form/theme/widgets/DefaultRoleWidget.tsx index a8925784a7..d79cd7d4e8 100644 --- a/web/src/components/config-form/theme/widgets/DefaultRoleWidget.tsx +++ b/web/src/components/config-form/theme/widgets/DefaultRoleWidget.tsx @@ -12,6 +12,7 @@ import type { ConfigFormContext } from "@/types/configForm"; import { getSizedFieldClassName } from "../utils"; const BUILT_IN_ROLES = ["admin", "viewer"]; +const NONE_ROLE = "none"; export function DefaultRoleWidget(props: WidgetProps) { const { id, value, disabled, readonly, onChange, schema, options, registry } = @@ -25,13 +26,15 @@ export function DefaultRoleWidget(props: WidgetProps) { const configured = Object.keys(formContext?.fullConfig?.auth?.roles ?? {}); // Keep admin/viewer first, then any custom roles in config order. const custom = configured.filter((r) => !BUILT_IN_ROLES.includes(r)); - return [...BUILT_IN_ROLES, ...custom]; + return [...BUILT_IN_ROLES, ...custom, NONE_ROLE]; }, [formContext]); const selectedValue = typeof value === "string" && value ? value : "viewer"; const getLabel = (role: string) => - BUILT_IN_ROLES.includes(role) ? t(`configForm.defaultRole.${role}`) : role; + BUILT_IN_ROLES.includes(role) || role === NONE_ROLE + ? t(`configForm.defaultRole.${role}`) + : role; return (