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
This commit is contained in:
Josh Hawkins
2026-09-12 07:30:04 -06:00
committed by Nicolas Mowen
parent 41bc1a5844
commit e2da7aae99
9 changed files with 194 additions and 18 deletions
+134 -1
View File
@@ -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)