From e2da7aae996b5f7d1f648267d620e9e2879db514 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Mon, 31 Aug 2026 09:34:15 -0500
Subject: [PATCH] Add a deny option for the proxy default role (#24145)
* backend
* frontend
* docs
* fix none default role casing and name reserved roles in the error
* reserve every casing of none as a role name
---
docs/docs/configuration/authentication.md | 18 ++-
docs/static/frigate-api.yaml | 3 +
frigate/api/auth.py | 21 ++-
frigate/config/auth.py | 11 +-
frigate/config/proxy.py | 12 +-
frigate/test/test_proxy_auth.py | 135 +++++++++++++++++-
web/public/locales/en/config/global.json | 2 +-
web/public/locales/en/views/settings.json | 3 +-
.../theme/widgets/DefaultRoleWidget.tsx | 7 +-
9 files changed, 194 insertions(+), 18 deletions(-)
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 (