Files
homelab/ci/images/authentik-gate-sync/test_controller.py
T
hermes-bot 8c85a49ae5
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Revert "Merge pull request 'feat(authentik-gate-sync): OIDC clients from OIDCClient resources' (#1535) from feat/gate-sync-oidc into main"
This reverts commit 09a03dfd76, reversing
changes made to e518b46045.
2026-11-01 10:44:22 +00:00

387 lines
17 KiB
Python

import copy
import unittest
import controller as c
LLAMA = {
"metadata": {
"name": "llama-server", "namespace": "ai", "uid": "u-1",
"labels": {"dns.renner.uno/public": "true", "kustomize.toolkit.fluxcd.io/name": "llama-server"},
"annotations": {
"cert-manager.io/cluster-issuer": "letsencrypt-cluster",
"auth.renner.uno/login-flow": "sso-authentication",
"auth.renner.uno/skip-path": r"^/(manifest\.webmanifest|sw\.js)$",
"traefik.ingress.kubernetes.io/router.middlewares":
"authentik-gate@kubernetescrd, ingress-foo@kubernetescrd",
},
},
"spec": {
"ingressClassName": "traefik",
"tls": [{"hosts": ["llm.renner.uno"], "secretName": "llama-server-tls"}],
"rules": [{"host": "llm.renner.uno", "http": {"paths": [
{"path": "/", "pathType": "Prefix",
"backend": {"service": {"name": "llama-server", "port": {"number": 8080}}}}]}}],
},
}
SERVICE = {"spec": {"selector": {"app.kubernetes.io/name": "llama-server"},
"ports": [{"name": "http", "port": 8080, "targetPort": "http"}]}}
def ing(ns, name):
return {"metadata": {"namespace": ns, "name": name}}
class Names(unittest.TestCase):
def test_unambiguous(self):
self.assertNotEqual(c.gate_name(ing("a-b", "c")), c.gate_name(ing("a", "b-c")))
def test_stable_and_readable(self):
self.assertEqual(c.gate_name(ing("ai", "llama-server")), c.gate_name(ing("ai", "llama-server")))
self.assertTrue(c.gate_name(ing("ai", "llama-server")).startswith("ai-llama-server-"))
def test_bounded(self):
n = c.gate_name(ing("n" * 63, "i" * 253))
self.assertLessEqual(len(n), 63)
self.assertNotIn("--", n)
def test_group_expression_is_a_literal(self):
expr = c.group_expression("x'); import os; os.system('id'); ('")
self.assertEqual(expr, "return ak_is_group_member(request.user, name="
+ repr("x'); import os; os.system('id'); ('") + ")")
compile(expr.replace("return ", "_ = "), "<expr>", "exec")
class GateSpec(unittest.TestCase):
def test_llama(self):
g = c.gate_spec(LLAMA)
self.assertEqual(g["name"], c.gate_name(LLAMA))
self.assertEqual((g["host"], g["scheme"], g["service"], g["port"]),
("llm.renner.uno", "https", "llama-server", 8080))
self.assertIsNone(g["group"])
self.assertEqual(g["login_flow"], "sso-authentication")
self.assertEqual(g["authorization_flow"], c.DEFAULT_AUTHZ_FLOW)
def test_group_and_flow_override(self):
ing = copy.deepcopy(LLAMA)
ing["metadata"]["annotations"]["auth.renner.uno/group"] = "Home Assistant Config Editors"
ing["metadata"]["annotations"]["auth.renner.uno/authorization-flow"] = "implicit"
g = c.gate_spec(ing)
self.assertEqual(g["group"], "Home Assistant Config Editors")
self.assertEqual(g["authorization_flow"], "implicit")
def test_rejects_two_hosts(self):
ing = copy.deepcopy(LLAMA)
ing["spec"]["rules"].append(copy.deepcopy(ing["spec"]["rules"][0]))
ing["spec"]["rules"][1]["host"] = "other.renner.uno"
with self.assertRaises(c.GateError):
c.gate_spec(ing)
def test_rejects_two_backends(self):
ing = copy.deepcopy(LLAMA)
paths = ing["spec"]["rules"][0]["http"]["paths"]
paths.append({"path": "/api", "pathType": "Prefix",
"backend": {"service": {"name": "other", "port": {"number": 80}}}})
with self.assertRaises(c.GateError):
c.gate_spec(ing)
def test_rejects_wildcard(self):
ing = copy.deepcopy(LLAMA)
ing["spec"]["rules"][0]["host"] = "*.renner.uno"
with self.assertRaises(c.GateError):
c.gate_spec(ing)
def test_shared_hosts(self):
other = copy.deepcopy(LLAMA)
other["metadata"]["name"] = "llama-admin"
self.assertEqual(c.shared_hosts([LLAMA, other]), {"llm.renner.uno"})
other["spec"]["rules"][0]["host"] = "admin.renner.uno"
self.assertEqual(c.shared_hosts([LLAMA, other]), set())
def test_gated_by_middleware_reference(self):
self.assertTrue(c.is_gated(LLAMA))
ing = copy.deepcopy(LLAMA)
ing["metadata"]["annotations"]["traefik.ingress.kubernetes.io/router.middlewares"] = \
"ingress-foo@kubernetescrd,authentik-gate-other@kubernetescrd"
self.assertFalse(c.is_gated(ing))
del ing["metadata"]["annotations"]
self.assertFalse(c.is_gated(ing))
class Backend(unittest.TestCase):
def test_named_target_port_stays_named(self):
g = c.gate_spec(LLAMA)
self.assertEqual(c.resolve_backend(g, SERVICE),
(8080, {"app.kubernetes.io/name": "llama-server"}, "http"))
cnp = c.backend_ingress_cnp(g, {"app.kubernetes.io/name": "llama-server"}, "http")
self.assertEqual(cnp["spec"]["ingress"][0]["toPorts"][0]["ports"][0]["port"], "http")
def test_numeric_target_port(self):
g = c.gate_spec(LLAMA)
svc = copy.deepcopy(SERVICE)
svc["spec"]["ports"][0]["targetPort"] = 9090
self.assertEqual(c.resolve_backend(g, svc)[2], 9090)
def test_missing_target_port_defaults_to_port(self):
g = c.gate_spec(LLAMA)
svc = copy.deepcopy(SERVICE)
del svc["spec"]["ports"][0]["targetPort"]
self.assertEqual(c.resolve_backend(g, svc)[2], 8080)
def test_service_without_selector_fails(self):
with self.assertRaises(c.GateError):
c.resolve_backend(c.gate_spec(LLAMA), {"spec": {"ports": SERVICE["spec"]["ports"]}})
def test_missing_port_fails(self):
g = c.gate_spec(LLAMA)
g["port"] = 1234
with self.assertRaises(c.GateError):
c.resolve_backend(g, SERVICE)
class Objects(unittest.TestCase):
def setUp(self):
self.g = c.gate_spec(LLAMA)
def test_backend_admits_traefik_only(self):
pods = {"app.kubernetes.io/name": "llama-server"}
i = c.backend_ingress_cnp(self.g, pods, 8080)
self.assertEqual(i["metadata"]["namespace"], "ai")
self.assertEqual(i["spec"]["ingress"][0]["fromEndpoints"],
[{"matchLabels": {"k8s:io.kubernetes.pod.namespace": "ingress",
"app.kubernetes.io/name": "traefik"}}])
self.assertEqual(i["spec"]["ingress"][0]["toPorts"][0]["ports"][0]["port"], "8080")
def test_provider_body(self):
flows = {c.DEFAULT_AUTHZ_FLOW: "az", "sso-authentication": "au",
c.INVALIDATION_FLOW: "inv"}
p = c.provider_body(self.g, flows)
self.assertEqual(p["mode"], "forward_single")
self.assertEqual(p["external_host"], "https://llm.renner.uno")
self.assertEqual(p["internal_host"], "")
self.assertEqual((p["authorization_flow"], p["authentication_flow"], p["invalidation_flow"]),
("az", "au", "inv"))
self.assertEqual(p["skip_path_regex"], r"^/(manifest\.webmanifest|sw\.js)$")
def test_drift(self):
self.assertEqual(c.drift({"a": 1, "b": 2}, {"a": 1, "b": 3}), {"b": 3})
def test_embedded_config_keeps_other_keys(self):
cfg = c.embedded_config({"log_level": "debug", "authentik_host": c.AK_URL})
self.assertEqual(cfg, {"log_level": "debug", "authentik_host": c.AK_BROWSER_URL,
"authentik_host_insecure": False, "authentik_host_browser": c.AK_BROWSER_URL})
class FakeAuthentik:
"""In-memory stand-in for the endpoints the controller uses; records calls."""
def __init__(self):
self.db = {"providers/proxy/": {}, "core/applications/": {}, "outposts/instances/": {},
"policies/bindings/": {}, "policies/expression/": {}, "core/groups/": {}}
self.calls = []
self.next = 1
def add(self, path, obj):
obj.setdefault("pk", self.next)
self.next += 1
self.db[path][obj["pk"]] = obj
return obj
def _split(self, path):
for coll in self.db:
if path.startswith(coll):
rest = path[len(coll):].strip("/")
return coll, rest or None
raise AssertionError(path)
def _find(self, coll, key):
if coll == "core/applications/":
return next((a for a in self.db[coll].values() if a["slug"] == key), None)
return self.db[coll].get(int(key) if str(key).isdigit() else key)
def list(self, path, **q):
out = []
for o in self.db[path].values():
ok = True
for k, v in q.items():
field = k.replace("__iexact", "")
ok &= str(o.get(field)).lower() == str(v).lower() if "__iexact" in k else o.get(field) == v
if ok:
out.append(copy.deepcopy(o))
return out
def one(self, path, **q):
found = self.list(path, **q)
return found[0] if found else None
def get(self, path):
coll, key = self._split(path)
o = self._find(coll, key)
return copy.deepcopy(o) if o else None
def req(self, method, path, body=None):
self.calls.append((method, path))
coll, key = self._split(path)
if method == "POST":
obj = self.add(coll, dict(body))
if coll == "core/applications/":
obj["pbm_uuid"] = f"pbm-{obj['pk']}"
self.db["providers/proxy/"][obj["provider"]]["assigned_application_slug"] = obj["slug"]
return copy.deepcopy(obj)
obj = self._find(coll, key)
if method == "PATCH":
# ProxyProviderSerializer.validate sees only the request body.
if coll == "providers/proxy/" and body.get("mode", "proxy") == "proxy" \
and body.get("internal_host", "") == "":
raise AssertionError("400 internal_host")
obj.update(body)
return copy.deepcopy(obj)
if method == "DELETE":
del self.db[coll][obj["pk"]]
if coll == "core/applications/":
self.db["providers/proxy/"][obj["provider"]]["assigned_application_slug"] = None
for b in [b for b in self.db["policies/bindings/"].values() if b["target"] == obj["pbm_uuid"]]:
del self.db["policies/bindings/"][b["pk"]]
return None
raise AssertionError(method)
FLOWS = {c.DEFAULT_AUTHZ_FLOW: "az", "sso-authentication": "au", c.INVALIDATION_FLOW: "inv"}
class Reconcile(unittest.TestCase):
def setUp(self):
self.ak = FakeAuthentik()
self.g = c.gate_spec(LLAMA)
self.embedded = self.ak.add("outposts/instances/", {
"name": "authentik Embedded Outpost", "managed": c.EMBEDDED_OUTPOST,
"providers": [], "config": {"log_level": "info"}})
def gate(self, group=None):
g = dict(self.g, group=group)
c.ensure_gate(self.ak, g, FLOWS)
return g
def embedded_providers(self):
return self.ak.get(f"outposts/instances/{self.embedded['pk']}/")["providers"]
def test_creates_owned_gate(self):
self.gate()
app = self.ak.get(f"core/applications/{self.g['name']}/")
self.assertEqual(app["meta_publisher"], c.MANAGER)
self.assertEqual(self.embedded_providers(), [app["provider"]])
self.assertEqual(self.ak.list("outposts/instances/", name__iexact=self.g["name"]), [])
self.assertEqual(self.ak.list("policies/bindings/"), [])
def test_keeps_other_providers_on_embedded_outpost(self):
self.ak.db["outposts/instances/"][self.embedded["pk"]]["providers"] = [999]
self.gate()
app = self.ak.get(f"core/applications/{self.g['name']}/")
self.assertEqual(self.embedded_providers(), [999, app["provider"]])
def test_removes_dedicated_outpost(self):
self.gate()
app = self.ak.get(f"core/applications/{self.g['name']}/")
self.ak.add("outposts/instances/", {"name": self.g["name"], "providers": [app["provider"]]})
shared = self.ak.add("outposts/instances/", {"name": self.g["name"].upper(),
"providers": [app["provider"], 999]})
self.gate()
self.assertEqual([o["pk"] for o in self.ak.list("outposts/instances/", name__iexact=self.g["name"])],
[shared["pk"]])
def test_embedded_lookup_ignores_unfiltered_results(self):
ldap = self.ak.add("outposts/instances/", {"name": "LDAP Outpost", "managed": None, "providers": [7]})
unfiltered = self.ak.list
self.ak.list = lambda path, **q: unfiltered(path) # a server that ignores the filter
self.assertEqual(c.embedded_outpost(self.ak)["pk"], self.embedded["pk"])
del self.ak.db["outposts/instances/"][self.embedded["pk"]]
with self.assertRaises(c.ApiError):
c.embedded_outpost(self.ak)
self.assertEqual(self.ak.get(f"outposts/instances/{ldap['pk']}/")["providers"], [7])
def test_embedded_config(self):
c.ensure_embedded_config(self.ak)
cfg = self.ak.get(f"outposts/instances/{self.embedded['pk']}/")["config"]
self.assertEqual((cfg["log_level"], cfg["authentik_host"], cfg["authentik_host_browser"]),
("info", c.AK_BROWSER_URL, c.AK_BROWSER_URL))
before = len(self.ak.calls)
c.ensure_embedded_config(self.ak)
self.assertEqual(self.ak.calls[before:], [])
def test_provider_moves_from_proxy_mode(self):
self.ak.add("providers/proxy/", {"name": self.g["name"], "mode": "proxy",
"internal_host": "http://llama-server.ai.svc.cluster.local:8080",
"assigned_application_slug": None})
self.gate()
provider = self.ak.one("providers/proxy/", name__iexact=self.g["name"])
self.assertEqual((provider["mode"], provider["internal_host"]), ("forward_single", ""))
def test_second_pass_is_a_noop(self):
self.gate()
before = len(self.ak.calls)
self.gate()
self.assertEqual(self.ak.calls[before:], [])
def test_flow_change_updates_provider(self):
self.gate()
self.g = dict(self.g, login_flow=None)
self.gate()
provider = self.ak.one("providers/proxy/", name__iexact=self.g["name"])
self.assertIsNone(provider["authentication_flow"])
def test_ignores_foreign_provider_names(self):
self.ak.add("providers/proxy/", {"name": "aaa-first-alphabetically", "assigned_application_slug": "x"})
self.gate()
self.assertEqual(len(self.ak.list("providers/proxy/")), 2)
def test_refuses_unowned_application(self):
self.ak.add("core/applications/", {"slug": self.g["name"], "meta_publisher": "", "provider": None})
with self.assertRaises(c.GateError):
self.gate()
def test_missing_group_is_a_gate_error(self):
with self.assertRaises(c.GateError):
self.gate(group="Nope")
def test_group_change_binds_before_unbinding(self):
self.ak.add("core/groups/", {"name": "A"})
self.ak.add("core/groups/", {"name": "B"})
self.gate(group="A")
bindings = self.ak.list("policies/bindings/")
self.assertEqual(len(bindings), 1)
policy = self.ak.one("policies/expression/", name=self.g["name"])
self.assertIn("'A'", policy["expression"])
self.gate(group="B")
policy = self.ak.one("policies/expression/", name=self.g["name"])
self.assertIn("'B'", policy["expression"])
self.assertEqual([b["pk"] for b in self.ak.list("policies/bindings/")], [bindings[0]["pk"]])
def test_dropping_group_removes_binding_after_nothing_new(self):
self.ak.add("core/groups/", {"name": "A"})
self.gate(group="A")
self.gate(group=None)
self.assertEqual(self.ak.list("policies/bindings/"), [])
self.assertIsNone(self.ak.one("policies/expression/", name=self.g["name"]))
def test_remove_gate_and_ownership_scan(self):
foreign = self.ak.add("providers/proxy/", {"name": "foreign", "assigned_application_slug": "foreign"})
self.ak.add("core/applications/", {"slug": "foreign", "meta_publisher": "", "provider": foreign["pk"]})
self.gate()
owned = list(c.owned_applications(self.ak))
self.assertEqual([a["slug"] for a in owned], [self.g["name"]])
c.remove_gate(self.ak, owned[0])
self.assertIsNone(self.ak.get(f"core/applications/{self.g['name']}/"))
self.assertEqual([p["name"] for p in self.ak.list("providers/proxy/")], ["foreign"])
self.assertEqual([o["pk"] for o in self.ak.list("outposts/instances/")], [self.embedded["pk"]])
def test_adopts_provider_orphaned_by_interrupted_teardown(self):
self.gate()
app = self.ak.get(f"core/applications/{self.g['name']}/")
self.ak.req("DELETE", f"core/applications/{self.g['name']}/")
self.gate()
self.assertEqual(self.ak.get(f"core/applications/{self.g['name']}/")["provider"], app["provider"])
if __name__ == "__main__":
unittest.main()