Files
computer-playbook/tests/unit/python/plugins/lookup/test_sso.py
T
kevinveenbirkenbachandClaude Opus 5 b764b00ba3 fix(keycloak): forward the access token only to consumers that read it
The SSO gate handed X-Forwarded-Access-Token to every oauth2-fronted upstream, so each of the 28 gated roles received a full-scope bearer for the signed-in user while exactly one, n8n, decodes it. The token also grows with the realm's registered origins, and LAM's Apache rejects a header field above 8190 bytes (Moodle#0, run 33689701895).

Consumers now declare services.sso.oauth2.pass_access_token. Without it oauth2-proxy already answers the auth request without the token, and the gate blanks the upstream header rather than dropping the directive, because nginx inherits proxy_set_header as an array and an unset header would let a client-supplied one through. The new lint keeps declaration and use in step in both directions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 09:20:19 +02:00

220 lines
7.6 KiB
Python

"""Unit tests for the ``sso`` lookup plugin.
The plugin is a thin wrapper around
``utils.roles.applications.services.sso.get_sso_config`` — these tests
focus on the lookup-shape contract (term parsing, want-path selection,
error paths). Resolver semantics are covered by
``tests/unit/python/utils/roles/applications/services/test_sso.py``.
"""
import importlib
import importlib.util
import sys
import unittest
import unittest.mock as mock
from ansible.errors import AnsibleError
from . import PROJECT_ROOT
def _load_module(rel_path: str, name: str):
for key in (
"utils.roles.applications.config",
"utils.roles.applications",
"utils",
"utils.cache.applications",
"utils.cache",
"utils.roles.applications.services.sso",
"utils.roles.applications.services",
):
sys.modules.pop(key, None)
importlib.import_module("utils.roles.applications.config")
importlib.import_module("utils.cache.applications")
importlib.import_module("utils.roles.applications.services.sso")
path = PROJECT_ROOT / rel_path
spec = importlib.util.spec_from_file_location(name, str(path))
mod = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(mod)
return mod
def _apps(
*,
enabled=False,
shared=False,
flavor=None,
oauth2_origin_host=None,
oauth2_origin_port=None,
oauth2_acl=None,
oauth2_allowed_groups=None,
oauth2_pass_access_token=None,
include_app=True,
):
if not include_app:
return {}
sso: dict = {"enabled": enabled, "shared": shared}
if flavor is not None:
sso["flavor"] = flavor
oauth2_sub: dict = {}
if oauth2_origin_host is not None or oauth2_origin_port is not None:
oauth2_sub["origin"] = {
**({"host": oauth2_origin_host} if oauth2_origin_host is not None else {}),
**({"port": oauth2_origin_port} if oauth2_origin_port is not None else {}),
}
if oauth2_acl is not None:
oauth2_sub["acl"] = oauth2_acl
if oauth2_allowed_groups is not None:
oauth2_sub["allowed_groups"] = oauth2_allowed_groups
if oauth2_pass_access_token is not None:
oauth2_sub["pass_access_token"] = oauth2_pass_access_token
if oauth2_sub:
sso["oauth2"] = oauth2_sub
return {"web-app-x": {"services": {"sso": sso}}}
class SsoLookupTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.mod = _load_module("plugins/lookup/sso.py", "sso")
def _run(self, applications, terms):
lk = self.mod.LookupModule()
lk._loader = mock.MagicMock()
with mock.patch.object(self.mod, "lookup_loader") as loader_mock:
loader_mock.get.return_value = mock.MagicMock(
run=lambda *_a, **_k: [applications]
)
return lk.run(terms, variables={"applications": applications})
def test_zero_terms_raises(self):
with self.assertRaises(AnsibleError):
self._run(_apps(), [])
def test_three_terms_raises(self):
with self.assertRaises(AnsibleError):
self._run(_apps(), ["web-app-x", "is_enabled", "extra"])
def test_empty_application_id_raises(self):
with self.assertRaises(AnsibleError):
self._run(_apps(), [""])
def test_unknown_want_path_raises(self):
with self.assertRaises(AnsibleError) as ctx:
self._run(_apps(enabled=True), ["web-app-x", "bogus_key"])
self.assertIn("unknown want_path", str(ctx.exception))
def test_default_returns_full_dict(self):
result = self._run(_apps(enabled=True, flavor="oauth2"), ["web-app-x"])
self.assertEqual(len(result), 1)
payload = result[0]
self.assertEqual(payload["enabled"], True)
self.assertEqual(payload["flavor"], "oauth2")
self.assertTrue(payload["is_proxy_gated"])
def test_explicit_all_returns_full_dict(self):
result = self._run(_apps(enabled=True), ["web-app-x", "all"])
self.assertEqual(len(result), 1)
self.assertIsInstance(result[0], dict)
def test_is_enabled_returns_bool(self):
result = self._run(_apps(enabled=True), ["web-app-x", "is_enabled"])
self.assertEqual(result, [True])
def test_is_proxy_gated_true_for_oauth2(self):
result = self._run(
_apps(enabled=True, flavor="oauth2"), ["web-app-x", "is_proxy_gated"]
)
self.assertEqual(result, [True])
def test_pass_access_token_defaults_to_false(self):
result = self._run(
_apps(enabled=True, flavor="oauth2"),
["web-app-x", "oauth2_pass_access_token"],
)
self.assertEqual(result, [False])
def test_pass_access_token_reads_the_declared_opt_in(self):
result = self._run(
_apps(enabled=True, flavor="oauth2", oauth2_pass_access_token=True),
["web-app-x", "oauth2_pass_access_token"],
)
self.assertEqual(result, [True])
def test_pass_access_token_accepts_a_rendered_string(self):
result = self._run(
_apps(enabled=True, flavor="oauth2", oauth2_pass_access_token="True"),
["web-app-x", "oauth2_pass_access_token"],
)
self.assertEqual(result, [True])
def test_pass_access_token_rejects_a_rendered_false_string(self):
result = self._run(
_apps(enabled=True, flavor="oauth2", oauth2_pass_access_token="False"),
["web-app-x", "oauth2_pass_access_token"],
)
self.assertEqual(result, [False])
def test_is_proxy_gated_false_for_oidc(self):
result = self._run(
_apps(enabled=True, flavor="oidc"), ["web-app-x", "is_proxy_gated"]
)
self.assertEqual(result, [False])
def test_is_proxy_gated_false_when_disabled(self):
result = self._run(
_apps(enabled=False, flavor="oauth2"), ["web-app-x", "is_proxy_gated"]
)
self.assertEqual(result, [False])
def test_flavor_returns_string(self):
result = self._run(
_apps(enabled=True, flavor="oauth2"), ["web-app-x", "flavor"]
)
self.assertEqual(result, ["oauth2"])
def test_oauth2_origin_host_returns_value(self):
result = self._run(
_apps(enabled=True, flavor="oauth2", oauth2_origin_host="application"),
["web-app-x", "oauth2_origin_host"],
)
self.assertEqual(result, ["application"])
def test_oauth2_origin_port_stringified(self):
result = self._run(
_apps(enabled=True, flavor="oauth2", oauth2_origin_port=8000),
["web-app-x", "oauth2_origin_port"],
)
self.assertEqual(result, ["8000"])
def test_oauth2_acl_returns_dict(self):
acl = {"whitelist": ["/api/", "/static/"]}
result = self._run(
_apps(enabled=True, flavor="oauth2", oauth2_acl=acl),
["web-app-x", "oauth2_acl"],
)
self.assertEqual(result, [acl])
def test_oauth2_allowed_groups_returns_list(self):
groups = ["/roles/web-app-x/admin"]
result = self._run(
_apps(enabled=True, flavor="oauth2", oauth2_allowed_groups=groups),
["web-app-x", "oauth2_allowed_groups"],
)
self.assertEqual(result, [groups])
def test_missing_app_returns_defaults(self):
result = self._run({}, ["web-app-x", "is_enabled"])
self.assertEqual(result, [False])
def test_empty_want_path_treated_as_all(self):
result = self._run(_apps(enabled=True), ["web-app-x", " "])
self.assertEqual(len(result), 1)
self.assertIsInstance(result[0], dict)
if __name__ == "__main__":
unittest.main()