mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-25 19:08:58 +03:00
* backend * frontend * docs * fix none default role casing and name reserved roles in the error * reserve every casing of none as a role name
264 lines
11 KiB
Python
264 lines
11 KiB
Python
import unittest
|
|
|
|
from pydantic import ValidationError
|
|
|
|
from frigate.api.auth import resolve_role
|
|
from frigate.config import AuthConfig, HeaderMappingConfig, ProxyConfig
|
|
from frigate.config.env import FRIGATE_ENV_VARS
|
|
|
|
|
|
class TestProxyRoleResolution(unittest.TestCase):
|
|
def setUp(self):
|
|
self.proxy_config = ProxyConfig(
|
|
auth_secret=None,
|
|
default_role="viewer",
|
|
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_role_map_single_group_match(self):
|
|
headers = {"x-remote-role": "group_admin"}
|
|
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_admin|group_viewer"}
|
|
role = resolve_role(headers, self.proxy_config, self.config_roles)
|
|
self.assertEqual(role, "admin")
|
|
|
|
def test_role_map_or_matching(self):
|
|
config = self.proxy_config
|
|
config.header_map.role_map = {
|
|
"admin": ["group_admin", "group_privileged"],
|
|
}
|
|
|
|
# OR semantics: a single matching group should map to the role
|
|
headers = {"x-remote-role": "group_admin"}
|
|
role = resolve_role(headers, config, self.config_roles)
|
|
self.assertEqual(role, "admin")
|
|
|
|
headers = {"x-remote-role": "group_admin|group_privileged"}
|
|
role = resolve_role(headers, 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": "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, self.config_roles)
|
|
self.assertEqual(role, config.default_role)
|
|
|
|
def test_missing_role_header(self):
|
|
headers = {}
|
|
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, 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, 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, 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, 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, self.config_roles)
|
|
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)
|
|
|
|
def tearDown(self):
|
|
FRIGATE_ENV_VARS.clear()
|
|
FRIGATE_ENV_VARS.update(self._original_env_vars)
|
|
|
|
def test_auth_secret_env_substitution(self):
|
|
"""auth_secret resolves FRIGATE_ env vars via EnvString."""
|
|
FRIGATE_ENV_VARS["FRIGATE_PROXY_SECRET"] = "my_secret_value"
|
|
config = ProxyConfig(auth_secret="{FRIGATE_PROXY_SECRET}")
|
|
self.assertEqual(config.auth_secret, "my_secret_value")
|
|
|
|
def test_auth_secret_env_embedded_in_string(self):
|
|
"""auth_secret resolves env vars embedded in a larger string."""
|
|
FRIGATE_ENV_VARS["FRIGATE_SECRET_PART"] = "abc123"
|
|
config = ProxyConfig(auth_secret="prefix-{FRIGATE_SECRET_PART}-suffix")
|
|
self.assertEqual(config.auth_secret, "prefix-abc123-suffix")
|
|
|
|
def test_auth_secret_plain_string(self):
|
|
"""auth_secret accepts a plain string without substitution."""
|
|
config = ProxyConfig(auth_secret="literal_secret")
|
|
self.assertEqual(config.auth_secret, "literal_secret")
|
|
|
|
def test_auth_secret_none(self):
|
|
"""auth_secret defaults to None."""
|
|
config = ProxyConfig()
|
|
self.assertIsNone(config.auth_secret)
|
|
|
|
def test_auth_secret_unknown_var_raises(self):
|
|
"""auth_secret raises KeyError for unknown env var references."""
|
|
with self.assertRaises(Exception):
|
|
ProxyConfig(auth_secret="{FRIGATE_NONEXISTENT_VAR}")
|