diff --git a/ci/images/authentik-gate-sync/controller.py b/ci/images/authentik-gate-sync/controller.py index 6c1c55a8..feddb0b7 100644 --- a/ci/images/authentik-gate-sync/controller.py +++ b/ci/images/authentik-gate-sync/controller.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""authentik-gate-sync: the Authentik side of the gate Middleware and of OIDC clients. +"""authentik-gate-sync: the Authentik side of the gate Middleware. An Ingress is gated when its traefik.ingress.kubernetes.io/router.middlewares annotation names the gate Middleware (authentik/gate, a ForwardAuth against the @@ -18,16 +18,6 @@ Annotations (all optional): auth.renner.uno/authorization-flow authorization flow slug (default below) auth.renner.uno/skip-path provider skip_path_regex -OIDC clients: each OIDCClient (auth.renner.uno/v1alpha1, CRD in -infrastructure/authentik-gate-sync-crds) gets a confidential OAuth2/OpenID -provider oidc- (client ID = slug, secret generated by Authentik) and an -application with that slug, so the issuer is /application/o//; -with spec.group the same group policy as gates. Its status carries Ready and -the issuer. OIDC slugs and gate names are kept apart: a slug that is a gate's -name, or an existing application without this controller's OAuth2 provider, -is refused. An unassigned provider named oidc- is adopted (the remains -of an interrupted teardown, which deletes the application first). - Fail closed: until the provider exists, or after it is removed (e.g. the named group is gone), the embedded outpost has no provider for the host and rejects the request; Traefik answers 500 when the outpost is unreachable. @@ -40,7 +30,6 @@ import hashlib import json import logging import os -import re import sys import time import urllib.error @@ -57,10 +46,6 @@ DEFAULT_AUTHZ_FLOW = os.environ.get("DEFAULT_AUTHORIZATION_FLOW", "default-provi INVALIDATION_FLOW = os.environ.get("INVALIDATION_FLOW", "default-provider-invalidation-flow") COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", "renner.uno") AK_BROWSER_URL = os.environ.get("AUTHENTIK_HOST_BROWSER", "https://auth.renner.uno/") -SIGNING_KEY = os.environ.get("OIDC_SIGNING_KEY", "authentik Self-signed Certificate") -OIDC_SCOPES = tuple(f"goauthentik.io/providers/oauth2/scope-{s}" for s in ("openid", "profile", "email")) -OIDC_GRANTS = ["authorization_code", "refresh_token"] -SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,48}[a-z0-9])?$") INTERVAL = int(os.environ.get("INTERVAL_SECONDS", "60")) HTTP_TIMEOUT = 20 @@ -249,74 +234,6 @@ def provider_body(gate, flows): } -OIDC_API = "/apis/auth.renner.uno/v1alpha1" - - -def oidc_slug(cr): - return ((cr.get("spec") or {}).get("slug") or cr["metadata"]["name"]).strip() - - -def oidc_spec(cr): - """Translate an OIDCClient into the client it asks for. Raises GateError.""" - meta, spec = cr["metadata"], cr.get("spec") or {} - slug = oidc_slug(cr) - if not SLUG_RE.match(slug): - raise GateError(f"slug {slug!r} is not a slug (a-z, 0-9, -; max 50)") - launch = spec.get("launchURL", "") - if not launch.startswith(("https://", "http://")): - raise GateError(f"launchURL {launch!r} is not an http(s) URL") - redirects = [{"matching_mode": r.get("matchingMode", "strict"), "url": r.get("url", ""), - "redirect_uri_type": "authorization"} - for r in spec.get("redirectURIs") or []] - if not redirects or any(not r["url"] or r["matching_mode"] not in ("strict", "regex") for r in redirects): - raise GateError("redirectURIs needs at least one entry with url and matchingMode strict|regex") - for r in redirects: - if r["matching_mode"] == "regex": - try: - re.compile(r["url"]) - except re.error as e: - raise GateError(f"redirect regex {r['url']!r}: {e}") from None - return { - # name: provider and policy name; slug: application slug and client ID. - "name": f"oidc-{slug}", - "slug": slug, - "namespace": meta["namespace"], - "resource": meta["name"], - "launch_url": launch, - "group": spec.get("group") or None, - "authorization_flow": spec.get("authorizationFlow") or DEFAULT_AUTHZ_FLOW, - "redirect_uris": redirects, - } - - -def oidc_status(cr, ok, message): - cond = {"type": "Ready", "status": "True" if ok else "False", - "reason": "Reconciled" if ok else "Failed", "message": message[:1000], - "observedGeneration": cr["metadata"].get("generation", 0)} - old = next((c for c in (cr.get("status") or {}).get("conditions") or [] if c.get("type") == "Ready"), {}) - same = all(old.get(k) == cond[k] for k in ("status", "reason", "message", "observedGeneration")) - cond["lastTransitionTime"] = old.get("lastTransitionTime") if same and old.get("lastTransitionTime") \ - else time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - status = {"conditions": [cond], "observedGeneration": cond["observedGeneration"]} - if ok: - status["issuer"] = f"{AK_BROWSER_URL}application/o/{oidc_slug(cr)}/" - return status - - -def oauth2_body(spec, flows, refs): - return { - "name": spec["name"], - "authorization_flow": flows[spec["authorization_flow"]], - "invalidation_flow": flows[INVALIDATION_FLOW], - "client_type": "confidential", - "client_id": spec["slug"], - "grant_types": OIDC_GRANTS, - "signing_key": refs["signing_key"], - "property_mappings": sorted(refs["scopes"]), - "redirect_uris": spec["redirect_uris"], - } - - def drift(current, desired): return {k: v for k, v in desired.items() if current.get(k) != v} @@ -347,18 +264,6 @@ class K8s: return http("PATCH", K8S_API + path, obj, headers=self._h(), cafile=K8S_CA_FILE, content_type="application/apply-patch+yaml") - def patch_status(self, path, status): - return http("PATCH", K8S_API + path + "/status", {"status": status}, headers=self._h(), - cafile=K8S_CA_FILE, content_type="application/merge-patch+json") - - def oidc_clients(self): - try: - return self.get(f"{OIDC_API}/oidcclients").get("items", []) - except ApiError as e: - if e.code == 404: # CRD not installed: no clients, gates unaffected - return [] - raise - def delete(self, obj): try: http("DELETE", K8S_API + object_path(obj), headers=self._h(), cafile=K8S_CA_FILE) @@ -542,120 +447,42 @@ def ensure_access(ak, gate, target): ak.req("DELETE", f"policies/expression/{stale['pk']}/") -def oidc_refs(ak): - """Signing key and scope mappings every OIDC client uses; matched here, not - by server-side filters, so an ignored filter cannot pick another object.""" - keys = [k["pk"] for k in ak.list("crypto/certificatekeypairs/") if k.get("name") == SIGNING_KEY] - if len(keys) != 1: - raise ApiError(f"expected one certificate {SIGNING_KEY!r}, found {len(keys)}") - scopes = {m.get("managed"): m["pk"] for m in ak.list("propertymappings/provider/scope/") - if m.get("managed") in OIDC_SCOPES} - missing = [s for s in OIDC_SCOPES if s not in scopes] - if missing: - raise ApiError(f"scope mappings not found: {missing}") - return {"signing_key": keys[0], "scopes": list(scopes.values())} - - -def ensure_oidc(ak, spec, flows, refs): - name, slug = spec["name"], spec["slug"] - if spec["group"] and ak.one("core/groups/", name=spec["group"]) is None: - raise GateError(f"group {spec['group']!r} not found in Authentik") - - app = get_application(ak, slug) - if app is not None and app.get("meta_publisher") != MANAGER: - raise GateError(f"application {slug} exists and is not managed by {MANAGER}") - - want = oauth2_body(spec, flows, refs) - provider = next((p for p in ak.list("providers/oauth2/") - if is_plain_oauth2(p) and p.get("name", "").lower() == name), None) - if app is not None and (provider is None or app.get("provider") != provider["pk"]): - # E.g. a gate's application: never re-point it at an OAuth2 provider. - raise GateError(f"application {slug} exists without OAuth2 provider {name}") - if provider is None: - # No client_secret: Authentik generates it (generate_client_secret). - provider = ak.req("POST", "providers/oauth2/", want) - LOG.info("%s: created OAuth2 provider %s", name, provider["pk"]) - elif provider.get("assigned_application_slug") not in (None, slug) or \ - (app is not None and app.get("provider") != provider["pk"]): - raise GateError(f"OAuth2 provider {name} belongs to another application") - else: - current = dict(provider, property_mappings=sorted(provider.get("property_mappings") or [])) - if drift(current, want): - ak.req("PATCH", f"providers/oauth2/{provider['pk']}/", drift(current, want)) - LOG.info("%s: updated OAuth2 provider %s", name, sorted(drift(current, want))) - - want_app = {"name": f"{slug} (oidc)", "slug": slug, "provider": provider["pk"], - "meta_launch_url": spec["launch_url"], "meta_publisher": MANAGER, - "meta_description": f"OIDCClient {spec['namespace']}/{spec['resource']}"} - if app is None: - app = ak.req("POST", "core/applications/", want_app) - LOG.info("%s: created application %s", name, slug) - elif drift(app, want_app): - ak.req("PATCH", f"core/applications/{slug}/", drift(app, want_app)) - LOG.info("%s: updated application %s", name, sorted(drift(app, want_app))) - - ensure_access(ak, spec, app["pbm_uuid"]) - - -PROVIDER_PATHS = ("providers/proxy/", "providers/oauth2/") -OAUTH2_MODEL = "authentik_providers_oauth2.oauth2provider" - - -def is_plain_oauth2(p): - # ProxyProvider subclasses OAuth2Provider, so providers/oauth2/ lists proxy providers too. - return p.get("meta_model_name") == OAUTH2_MODEL - - -def policy_name(app): - return f"oidc-{app['slug']}" if app.get("_provider_path") == "providers/oauth2/" else app["slug"] - - def remove_gate(ak, app): - """Delete an owned gate or OIDC client: the application (bindings cascade; - its provider is PROTECTed while referenced), then the provider (a proxy - provider leaves the embedded outpost with it), the policy. Ownership is - found through the application, so until it is gone a failed step is - retried on the next pass.""" + """Delete an owned gate: the application (bindings cascade; its provider is + PROTECTed while referenced), then the provider (which leaves the embedded + outpost with it), the policy. Ownership is found through the application, + so until it is gone a failed step is retried on the next pass.""" name, provider = app["slug"], app.get("provider") - path = app.get("_provider_path", "providers/proxy/") - if path == "providers/proxy/": - remove_dedicated_outposts(ak, name, provider) + remove_dedicated_outposts(ak, name, provider) ak.req("DELETE", f"core/applications/{name}/") if provider: - ak.req("DELETE", f"{path}{provider}/") - policy = ak.one("policies/expression/", name=policy_name(app)) + ak.req("DELETE", f"providers/proxy/{provider}/") + policy = ak.one("policies/expression/", name=name) if policy is not None: ak.req("DELETE", f"policies/expression/{policy['pk']}/") - LOG.info("%s: removed %s from Authentik", name, - "OIDC client" if path == "providers/oauth2/" else "gate") + LOG.info("%s: removed gate from Authentik", name) def owned_applications(ak): """Applications this controller owns, found through the (unfiltered) proxy - and OAuth2 provider lists and confirmed one by one.""" - for path in PROVIDER_PATHS: - for p in ak.list(path): - if path == "providers/oauth2/" and not is_plain_oauth2(p): - continue - slug = p.get("assigned_application_slug") - if not slug: - continue - app = get_application(ak, slug) - if app and app.get("meta_publisher") == MANAGER and app.get("provider") == p["pk"]: - yield dict(app, _provider_path=path) + provider list and confirmed one by one.""" + for p in ak.list("providers/proxy/"): + slug = p.get("assigned_application_slug") + if not slug: + continue + app = get_application(ak, slug) + if app and app.get("meta_publisher") == MANAGER and app.get("provider") == p["pk"]: + yield app # --- reconcile -------------------------------------------------------------- -def reconcile(k8s=None, ak=None): - k8s = k8s or K8s() - if ak is None: - with open(AK_TOKEN_FILE) as f: - ak = Authentik(f.read().strip()) +def reconcile(): + k8s = K8s() + with open(AK_TOKEN_FILE) as f: + ak = Authentik(f.read().strip()) - ingresses = k8s.get("/apis/networking.k8s.io/v1/ingresses").get("items", []) - sources = [i for i in ingresses if is_gated(i)] - clients = k8s.oidc_clients() + sources = [i for i in k8s.get("/apis/networking.k8s.io/v1/ingresses").get("items", []) if is_gated(i)] shared = shared_hosts(sources) flows = {f["slug"]: f["pk"] for f in ak.list("flows/instances/")} ensure_embedded_config(ak) @@ -699,44 +526,6 @@ def reconcile(k8s=None, ak=None): held.add(gate_name(ing)) failed += 1 - gate_names = {gate_name(i) for i in sources} - keep_clients = set() # OIDC slugs whose Authentik side should exist - slugs = [oidc_slug(i) for i in clients] - refs = None - for cr in sorted(clients, key=oidc_slug): - ref, slug = f'{cr["metadata"]["namespace"]}/{cr["metadata"]["name"]}', oidc_slug(cr) - ok, message = False, "" - try: - spec = oidc_spec(cr) - if slugs.count(slug) > 1: - raise GateError(f"slug {slug} is claimed by more than one OIDCClient") - if slug in gate_names: - raise GateError(f"slug {slug} is the name of a gate") - for flow in (spec["authorization_flow"], INVALIDATION_FLOW): - if flow not in flows: - raise GateError(f"flow {flow!r} not found in Authentik") - keep_clients.add(slug) - refs = refs or oidc_refs(ak) - ensure_oidc(ak, spec, flows, refs) - ok, message = True, f"provider {spec['name']}, application {slug}" - except GateError as e: - LOG.error("OIDCClient %s: %s", ref, e) - failed += 1 - keep_clients.discard(slug) - message = str(e) - except Exception as e: - LOG.error("OIDCClient %s: %s", ref, e) - keep_clients.add(slug) - failed += 1 - message = f"transient: {e}" - try: - path = f'{OIDC_API}/namespaces/{cr["metadata"]["namespace"]}/oidcclients/{cr["metadata"]["name"]}' - status = oidc_status(cr, ok, message) - if status != cr.get("status"): - k8s.patch_status(path, status) - except ApiError as e: - LOG.error("OIDCClient %s: status: %s", ref, e) - removed = 0 for obj in managed_objects(k8s): key = (obj["kind"], obj["metadata"]["namespace"], obj["metadata"]["name"]) @@ -747,13 +536,11 @@ def reconcile(k8s=None, ak=None): removed += 1 for app in list(owned_applications(ak)): - keep = keep_clients if app["_provider_path"] == "providers/oauth2/" else gates - if app["slug"] not in keep: + if app["slug"] not in gates: remove_gate(ak, app) removed += 1 - LOG.info("reconciled: %d gates, %d OIDC clients, %d failed, %d objects removed", - len(sources), len(clients), failed, removed) + LOG.info("reconciled: %d gates, %d failed, %d objects removed", len(sources), failed, removed) def main(): diff --git a/ci/images/authentik-gate-sync/test_controller.py b/ci/images/authentik-gate-sync/test_controller.py index 8825d3b3..038db4f6 100644 --- a/ci/images/authentik-gate-sync/test_controller.py +++ b/ci/images/authentik-gate-sync/test_controller.py @@ -176,10 +176,8 @@ class FakeAuthentik: """In-memory stand-in for the endpoints the controller uses; records calls.""" def __init__(self): - self.db = {"providers/proxy/": {}, "providers/oauth2/": {}, "core/applications/": {}, - "outposts/instances/": {}, "policies/bindings/": {}, "policies/expression/": {}, - "core/groups/": {}, "crypto/certificatekeypairs/": {}, - "propertymappings/provider/scope/": {}} + self.db = {"providers/proxy/": {}, "core/applications/": {}, "outposts/instances/": {}, + "policies/bindings/": {}, "policies/expression/": {}, "core/groups/": {}} self.calls = [] self.next = 1 @@ -196,9 +194,6 @@ class FakeAuthentik: return coll, rest or None raise AssertionError(path) - def _provider(self, pk): - return self.db["providers/proxy/"].get(pk) or self.db["providers/oauth2/"][pk] - def _find(self, coll, key): if coll == "core/applications/": return next((a for a in self.db[coll].values() if a["slug"] == key), None) @@ -206,13 +201,7 @@ class FakeAuthentik: def list(self, path, **q): out = [] - objs = list(self.db[path].values()) - if path == "providers/oauth2/": - # ProxyProvider subclasses OAuth2Provider: the real list endpoint returns both. - objs = [dict(o, meta_model_name=c.OAUTH2_MODEL) for o in objs] + \ - [dict(o, meta_model_name="authentik_providers_proxy.proxyprovider") - for o in self.db["providers/proxy/"].values()] - for o in objs: + for o in self.db[path].values(): ok = True for k, v in q.items(): field = k.replace("__iexact", "") @@ -237,7 +226,7 @@ class FakeAuthentik: obj = self.add(coll, dict(body)) if coll == "core/applications/": obj["pbm_uuid"] = f"pbm-{obj['pk']}" - self._provider(obj["provider"])["assigned_application_slug"] = obj["slug"] + self.db["providers/proxy/"][obj["provider"]]["assigned_application_slug"] = obj["slug"] return copy.deepcopy(obj) obj = self._find(coll, key) if method == "PATCH": @@ -250,7 +239,7 @@ class FakeAuthentik: if method == "DELETE": del self.db[coll][obj["pk"]] if coll == "core/applications/": - self._provider(obj["provider"])["assigned_application_slug"] = None + 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 @@ -393,253 +382,5 @@ class Reconcile(unittest.TestCase): self.assertEqual(self.ak.get(f"core/applications/{self.g['name']}/")["provider"], app["provider"]) -YOUTRACK = { - "metadata": {"name": "youtrack", "namespace": "youtrack", "generation": 3}, - "spec": { - "launchURL": "https://youtrack.renner.uno", - "group": "YouTrack Users", - "redirectURIs": [{"matchingMode": "regex", "url": r"^https://youtrack\.renner\.uno/.*$"}], - }, -} - - -class OidcSpec(unittest.TestCase): - def test_youtrack(self): - s = c.oidc_spec(YOUTRACK) - self.assertEqual((s["name"], s["slug"], s["launch_url"], s["group"]), - ("oidc-youtrack", "youtrack", "https://youtrack.renner.uno", "YouTrack Users")) - self.assertEqual(s["redirect_uris"], - [{"matching_mode": "regex", "url": r"^https://youtrack\.renner\.uno/.*$", - "redirect_uri_type": "authorization"}]) - - def test_slug_overrides_name(self): - cr = copy.deepcopy(YOUTRACK) - cr["spec"]["slug"] = "yt" - self.assertEqual(c.oidc_spec(cr)["slug"], "yt") - - def test_strict_is_default(self): - cr = copy.deepcopy(YOUTRACK) - cr["spec"]["redirectURIs"] = [{"url": "https://youtrack.renner.uno/cb"}] - self.assertEqual(c.oidc_spec(cr)["redirect_uris"][0]["matching_mode"], "strict") - - def test_rejects_bad_slug(self): - cr = copy.deepcopy(YOUTRACK) - cr["spec"]["slug"] = "You Track" - with self.assertRaises(c.GateError): - c.oidc_spec(cr) - - def test_rejects_missing_redirects(self): - cr = copy.deepcopy(YOUTRACK) - cr["spec"]["redirectURIs"] = [] - with self.assertRaises(c.GateError): - c.oidc_spec(cr) - - def test_rejects_bad_regex(self): - cr = copy.deepcopy(YOUTRACK) - cr["spec"]["redirectURIs"] = [{"matchingMode": "regex", "url": "("}] - with self.assertRaises(c.GateError): - c.oidc_spec(cr) - - def test_status(self): - st = c.oidc_status(YOUTRACK, True, "ok") - self.assertEqual(st["issuer"], c.AK_BROWSER_URL + "application/o/youtrack/") - self.assertEqual((st["conditions"][0]["status"], st["observedGeneration"]), ("True", 3)) - cr = dict(YOUTRACK, status=st) - self.assertEqual(c.oidc_status(cr, True, "ok")["conditions"][0]["lastTransitionTime"], - st["conditions"][0]["lastTransitionTime"]) - self.assertNotIn("issuer", c.oidc_status(cr, False, "group missing")) - - -class ReconcileOidc(unittest.TestCase): - def setUp(self): - self.ak = FakeAuthentik() - self.ak.add("crypto/certificatekeypairs/", {"name": c.SIGNING_KEY, "pk": "key-1"}) - self.ak.add("crypto/certificatekeypairs/", {"name": "other", "pk": "key-2"}) - for i, scope in enumerate(c.OIDC_SCOPES): - self.ak.add("propertymappings/provider/scope/", {"pk": f"m{i}", "managed": scope}) - self.ak.add("propertymappings/provider/scope/", {"pk": "m9", "managed": "goauthentik.io/x"}) - self.ak.add("core/groups/", {"name": "YouTrack Users"}) - self.s = c.oidc_spec(YOUTRACK) - - def run_once(self): - c.ensure_oidc(self.ak, self.s, FLOWS, c.oidc_refs(self.ak)) - - def test_creates_owned_client(self): - self.run_once() - app = self.ak.get("core/applications/youtrack/") - self.assertEqual(app["meta_publisher"], c.MANAGER) - p = self.ak.db["providers/oauth2/"][app["provider"]] - self.assertEqual((p["name"], p["client_id"], p["client_type"], p["signing_key"]), - ("oidc-youtrack", "youtrack", "confidential", "key-1")) - self.assertNotIn("client_secret", p) - self.assertEqual(p["grant_types"], ["authorization_code", "refresh_token"]) - self.assertEqual(p["property_mappings"], ["m0", "m1", "m2"]) - policy = self.ak.one("policies/expression/", name="oidc-youtrack") - self.assertIn("'YouTrack Users'", policy["expression"]) - self.assertEqual([b["target"] for b in self.ak.list("policies/bindings/")], [app["pbm_uuid"]]) - - def test_second_pass_is_a_noop(self): - self.run_once() - before = len(self.ak.calls) - self.run_once() - self.assertEqual(self.ak.calls[before:], []) - - def test_missing_group_is_a_gate_error(self): - self.ak.db["core/groups/"].clear() - with self.assertRaises(c.GateError): - self.run_once() - self.assertEqual([p for p in self.ak.list("providers/oauth2/") if c.is_plain_oauth2(p)], []) - - def test_refuses_unowned_application(self): - self.ak.add("core/applications/", {"slug": "youtrack", "meta_publisher": "", "provider": None}) - with self.assertRaises(c.GateError): - self.run_once() - - def test_redirect_change_patches_provider(self): - self.run_once() - self.s = dict(self.s, redirect_uris=[{"matching_mode": "strict", "url": "https://youtrack.renner.uno/cb", - "redirect_uri_type": "authorization"}]) - self.run_once() - p = next(iter(self.ak.db["providers/oauth2/"].values())) - self.assertEqual(p["redirect_uris"], self.s["redirect_uris"]) - - def test_missing_signing_key_is_transient(self): - self.ak.db["crypto/certificatekeypairs/"].clear() - with self.assertRaises(c.ApiError): - c.oidc_refs(self.ak) - - def test_ownership_scan_and_removal(self): - foreign = self.ak.add("providers/oauth2/", {"name": "gitea", "assigned_application_slug": "gitea"}) - self.ak.add("core/applications/", {"slug": "gitea", "meta_publisher": "", "provider": foreign["pk"]}) - self.run_once() - owned = list(c.owned_applications(self.ak)) - self.assertEqual([a["slug"] for a in owned], ["youtrack"]) - c.remove_gate(self.ak, owned[0]) - self.assertIsNone(self.ak.get("core/applications/youtrack/")) - self.assertEqual([p["name"] for p in self.ak.list("providers/oauth2/")], ["gitea"]) - self.assertIsNone(self.ak.one("policies/expression/", name="oidc-youtrack")) - - - def test_refuses_application_without_its_provider(self): - gate_provider = self.ak.add("providers/proxy/", {"name": "g", "assigned_application_slug": "youtrack"}) - self.ak.add("core/applications/", {"slug": "youtrack", "meta_publisher": c.MANAGER, - "provider": gate_provider["pk"], "pbm_uuid": "pbm-x"}) - with self.assertRaises(c.GateError): - self.run_once() - self.assertEqual([p for p in self.ak.list("providers/oauth2/") if c.is_plain_oauth2(p)], []) - self.assertEqual(self.ak.get("core/applications/youtrack/")["provider"], gate_provider["pk"]) - - -class FakeK8s: - def __init__(self, ingresses=(), clients=(), services=None): - self.ingresses, self.clients = list(ingresses), list(clients) - self.services = services or {} - self.status, self.applied = {}, [] - - def get(self, path): - if path == "/apis/networking.k8s.io/v1/ingresses": - return {"items": copy.deepcopy(self.ingresses)} - if "labelSelector=" in path: - return {"items": []} - if path.startswith("/api/v1/namespaces/") and "/services/" in path: - return copy.deepcopy(self.services[path.rsplit("/", 1)[1]]) - raise AssertionError(path) - - def oidc_clients(self): - return copy.deepcopy(self.clients) - - def apply(self, obj): - self.applied.append(obj) - - def patch_status(self, path, status): - self.status[path.rsplit("/", 1)[1]] = status - for cr in self.clients: - if cr["metadata"]["name"] == path.rsplit("/", 1)[1]: - cr["status"] = status - - -class ReconcileLoop(unittest.TestCase): - def setUp(self): - self.ak = FakeAuthentik() - self.ak.db["flows/instances/"] = {} - for slug, pk in FLOWS.items(): - self.ak.add("flows/instances/", {"slug": slug, "pk": pk}) - self.ak.add("outposts/instances/", {"name": "authentik Embedded Outpost", "managed": c.EMBEDDED_OUTPOST, - "providers": [], "config": {}}) - self.ak.add("crypto/certificatekeypairs/", {"name": c.SIGNING_KEY, "pk": "key-1"}) - for i, scope in enumerate(c.OIDC_SCOPES): - self.ak.add("propertymappings/provider/scope/", {"pk": f"m{i}", "managed": scope}) - self.ak.add("core/groups/", {"name": "YouTrack Users"}) - - def client(self, name="youtrack", **spec): - cr = copy.deepcopy(YOUTRACK) - cr["metadata"]["name"] = name - cr["spec"].update(spec) - return cr - - def test_creates_then_quiet(self): - k8s = FakeK8s(clients=[self.client()]) - c.reconcile(k8s, self.ak) - self.assertEqual(k8s.status["youtrack"]["conditions"][0]["status"], "True") - self.assertIsNotNone(self.ak.get("core/applications/youtrack/")) - before, k8s.status = len(self.ak.calls), {} - c.reconcile(k8s, self.ak) - self.assertEqual(self.ak.calls[before:], []) - self.assertEqual(k8s.status, {}) # unchanged status is not re-written - - def test_duplicate_slug_withholds_both(self): - k8s = FakeK8s(clients=[self.client(), self.client("other", slug="youtrack")]) - c.reconcile(k8s, self.ak) - self.assertIsNone(self.ak.get("core/applications/youtrack/")) - self.assertEqual({k: v["conditions"][0]["status"] for k, v in k8s.status.items()}, - {"youtrack": "False", "other": "False"}) - - def test_transient_error_keeps_client(self): - k8s = FakeK8s(clients=[self.client()]) - c.reconcile(k8s, self.ak) - self.ak.db["crypto/certificatekeypairs/"].clear() - c.reconcile(k8s, self.ak) - self.assertIsNotNone(self.ak.get("core/applications/youtrack/")) - self.assertIn("transient", k8s.status["youtrack"]["conditions"][0]["message"]) - - def test_sweeps_client_whose_resource_is_gone(self): - k8s = FakeK8s(clients=[self.client()]) - c.reconcile(k8s, self.ak) - k8s.clients = [] - c.reconcile(k8s, self.ak) - self.assertIsNone(self.ak.get("core/applications/youtrack/")) - self.assertEqual([p for p in self.ak.list("providers/oauth2/") if c.is_plain_oauth2(p)], []) - - def test_gates_survive_alongside_oidc(self): - k8s = FakeK8s(ingresses=[LLAMA], services={"llama-server": SERVICE}, clients=[self.client()]) - c.reconcile(k8s, self.ak) - gate = c.gate_name(LLAMA) - app = self.ak.get(f"core/applications/{gate}/") - before = len(self.ak.calls) - c.reconcile(k8s, self.ak) - self.assertEqual(self.ak.calls[before:], []) - self.assertEqual(self.ak.get(f"core/applications/{gate}/")["provider"], app["provider"]) - self.assertIsNotNone(self.ak.get("core/applications/youtrack/")) - - def test_gate_only_is_stable(self): - k8s = FakeK8s(ingresses=[LLAMA], services={"llama-server": SERVICE}) - c.reconcile(k8s, self.ak) - before = len(self.ak.calls) - c.reconcile(k8s, self.ak) - self.assertEqual([m for m, _ in self.ak.calls[before:] if m != "GET"], []) - self.assertIsNotNone(self.ak.get(f"core/applications/{c.gate_name(LLAMA)}/")) - - def test_slug_equal_to_gate_name_leaves_gate_alone(self): - k8s = FakeK8s(ingresses=[LLAMA], services={"llama-server": SERVICE}) - c.reconcile(k8s, self.ak) - gate = c.gate_name(LLAMA) - app = self.ak.get(f"core/applications/{gate}/") - k8s.clients = [self.client("evil", slug=gate)] - c.reconcile(k8s, self.ak) - self.assertEqual(self.ak.get(f"core/applications/{gate}/")["provider"], app["provider"]) - self.assertEqual([p for p in self.ak.list("providers/oauth2/") if c.is_plain_oauth2(p)], []) - self.assertEqual(k8s.status["evil"]["conditions"][0]["status"], "False") - - if __name__ == "__main__": unittest.main() diff --git a/kubernetes/apps/authentik/blueprints-configmap.yaml b/kubernetes/apps/authentik/blueprints-configmap.yaml index e0704a76..684f6b43 100644 --- a/kubernetes/apps/authentik/blueprints-configmap.yaml +++ b/kubernetes/apps/authentik/blueprints-configmap.yaml @@ -1262,12 +1262,6 @@ data: - authentik_policies_expression.add_expressionpolicy - authentik_policies_expression.change_expressionpolicy - authentik_policies_expression.delete_expressionpolicy - - authentik_providers_oauth2.view_oauth2provider - - authentik_providers_oauth2.add_oauth2provider - - authentik_providers_oauth2.change_oauth2provider - - authentik_providers_oauth2.delete_oauth2provider - - authentik_providers_oauth2.view_scopemapping - - authentik_crypto.view_certificatekeypair - authentik_core.view_group - authentik_flows.view_flow - authentik_outposts.view_outpostserviceconnection diff --git a/kubernetes/apps/authentik/gate-sync.yaml b/kubernetes/apps/authentik/gate-sync.yaml index 3acce9d8..4fc7c954 100644 --- a/kubernetes/apps/authentik/gate-sync.yaml +++ b/kubernetes/apps/authentik/gate-sync.yaml @@ -62,12 +62,6 @@ rules: - apiGroups: ["cilium.io"] resources: ["ciliumnetworkpolicies"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - - apiGroups: ["auth.renner.uno"] - resources: ["oidcclients"] - verbs: ["get", "list", "watch"] - - apiGroups: ["auth.renner.uno"] - resources: ["oidcclients/status"] - verbs: ["get", "patch", "update"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding diff --git a/kubernetes/infrastructure/authentik-gate-sync-crds/crd-oidcclients.yaml b/kubernetes/infrastructure/authentik-gate-sync-crds/crd-oidcclients.yaml deleted file mode 100644 index 000f58f7..00000000 --- a/kubernetes/infrastructure/authentik-gate-sync-crds/crd-oidcclients.yaml +++ /dev/null @@ -1,99 +0,0 @@ -# OIDCClient: an Authentik OAuth2/OpenID client, reconciled by authentik-gate-sync (ci/images/authentik-gate-sync). -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - name: oidcclients.auth.renner.uno - annotations: - kustomize.toolkit.fluxcd.io/prune: disabled -spec: - group: auth.renner.uno - names: - kind: OIDCClient - listKind: OIDCClientList - plural: oidcclients - singular: oidcclient - scope: Namespaced - versions: - - name: v1alpha1 - served: true - storage: true - subresources: - status: {} - additionalPrinterColumns: - - jsonPath: .status.issuer - name: Issuer - type: string - - jsonPath: .status.conditions[?(@.type=="Ready")].status - name: Ready - type: string - schema: - openAPIV3Schema: - type: object - required: [spec] - properties: - apiVersion: - type: string - kind: - type: string - metadata: - type: object - spec: - type: object - required: [launchURL, redirectURIs] - properties: - slug: - description: Application slug and client ID; issuer is /application/o//. Default metadata.name (then checked by the controller only). - type: string - maxLength: 50 - pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ - launchURL: - description: The app's URL, shown in the Authentik library. - type: string - pattern: ^https?:// - redirectURIs: - type: array - minItems: 1 - items: - type: object - required: [url] - properties: - url: - type: string - matchingMode: - type: string - enum: [strict, regex] - default: strict - group: - description: Authentik group whose members may sign in (managed in Authentik); none = any user. - type: string - authorizationFlow: - description: Authorization flow slug; default default-provider-authorization-implicit-consent. - type: string - status: - type: object - properties: - observedGeneration: - type: integer - format: int64 - issuer: - type: string - conditions: - type: array - items: - type: object - required: [type, status] - properties: - type: - type: string - status: - type: string - reason: - type: string - message: - type: string - observedGeneration: - type: integer - format: int64 - lastTransitionTime: - type: string - format: date-time diff --git a/kubernetes/infrastructure/authentik-gate-sync-crds/kustomization.yaml b/kubernetes/infrastructure/authentik-gate-sync-crds/kustomization.yaml deleted file mode 100644 index c5373994..00000000 --- a/kubernetes/infrastructure/authentik-gate-sync-crds/kustomization.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -resources: - - crd-oidcclients.yaml diff --git a/kubernetes/infrastructure/kustomization.yaml b/kubernetes/infrastructure/kustomization.yaml index 13fb0975..4e0975d2 100644 --- a/kubernetes/infrastructure/kustomization.yaml +++ b/kubernetes/infrastructure/kustomization.yaml @@ -27,7 +27,6 @@ resources: - admission-policies/ - kuma-sync-crds/ - cloudflare-lb-sync-crds/ - - authentik-gate-sync-crds/ # The OpenBao ClusterSecretStore lives in post-crds/ (needs ESO's CRDs and # an initialized OpenBao; see kubernetes/MIGRATION-sops-openbao.md). # cnpg-barman-plugin/ lives in its own Flux Kustomization