diff --git a/ci/images/authentik-gate-sync/controller.py b/ci/images/authentik-gate-sync/controller.py index feddb0b7..d26d57aa 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. +"""authentik-gate-sync: the Authentik side of the gate Middleware and of OIDC clients. An Ingress is gated when its traefik.ingress.kubernetes.io/router.middlewares annotation names the gate Middleware (authentik/gate, a ForwardAuth against the @@ -18,6 +18,16 @@ 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. @@ -30,6 +40,7 @@ import hashlib import json import logging import os +import re import sys import time import urllib.error @@ -46,6 +57,10 @@ 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 @@ -234,6 +249,74 @@ 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} @@ -264,6 +347,18 @@ 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) @@ -447,42 +542,111 @@ 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 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/") + + +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: 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.""" + """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.""" name, provider = app["slug"], app.get("provider") - remove_dedicated_outposts(ak, name, provider) + path = app.get("_provider_path", "providers/proxy/") + if path == "providers/proxy/": + remove_dedicated_outposts(ak, name, provider) ak.req("DELETE", f"core/applications/{name}/") if provider: - ak.req("DELETE", f"providers/proxy/{provider}/") - policy = ak.one("policies/expression/", name=name) + ak.req("DELETE", f"{path}{provider}/") + policy = ak.one("policies/expression/", name=policy_name(app)) if policy is not None: ak.req("DELETE", f"policies/expression/{policy['pk']}/") - LOG.info("%s: removed gate from Authentik", name) + LOG.info("%s: removed %s from Authentik", name, + "OIDC client" if path == "providers/oauth2/" else "gate") def owned_applications(ak): """Applications this controller owns, found through the (unfiltered) proxy - 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 + and OAuth2 provider lists and confirmed one by one.""" + for path in PROVIDER_PATHS: + for p in ak.list(path): + 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) # --- reconcile -------------------------------------------------------------- -def reconcile(): - k8s = K8s() - with open(AK_TOKEN_FILE) as f: - ak = Authentik(f.read().strip()) +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()) - sources = [i for i in k8s.get("/apis/networking.k8s.io/v1/ingresses").get("items", []) if is_gated(i)] + 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() shared = shared_hosts(sources) flows = {f["slug"]: f["pk"] for f in ak.list("flows/instances/")} ensure_embedded_config(ak) @@ -526,6 +690,44 @@ def reconcile(): 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"]) @@ -536,11 +738,13 @@ def reconcile(): removed += 1 for app in list(owned_applications(ak)): - if app["slug"] not in gates: + keep = keep_clients if app["_provider_path"] == "providers/oauth2/" else gates + if app["slug"] not in keep: remove_gate(ak, app) removed += 1 - LOG.info("reconciled: %d gates, %d failed, %d objects removed", len(sources), failed, removed) + LOG.info("reconciled: %d gates, %d OIDC clients, %d failed, %d objects removed", + len(sources), len(clients), 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 038db4f6..49866088 100644 --- a/ci/images/authentik-gate-sync/test_controller.py +++ b/ci/images/authentik-gate-sync/test_controller.py @@ -176,8 +176,10 @@ 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.db = {"providers/proxy/": {}, "providers/oauth2/": {}, "core/applications/": {}, + "outposts/instances/": {}, "policies/bindings/": {}, "policies/expression/": {}, + "core/groups/": {}, "crypto/certificatekeypairs/": {}, + "propertymappings/provider/scope/": {}} self.calls = [] self.next = 1 @@ -194,6 +196,9 @@ 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) @@ -226,7 +231,7 @@ class FakeAuthentik: 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"] + self._provider(obj["provider"])["assigned_application_slug"] = obj["slug"] return copy.deepcopy(obj) obj = self._find(coll, key) if method == "PATCH": @@ -239,7 +244,7 @@ class FakeAuthentik: if method == "DELETE": del self.db[coll][obj["pk"]] if coll == "core/applications/": - self.db["providers/proxy/"][obj["provider"]]["assigned_application_slug"] = None + self._provider(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 @@ -382,5 +387,234 @@ 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(self.ak.list("providers/oauth2/"), []) + + 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(self.ak.list("providers/oauth2/"), []) + 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(self.ak.list("providers/oauth2/"), []) + + 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(self.ak.list("providers/oauth2/"), []) + 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 684f6b43..e0704a76 100644 --- a/kubernetes/apps/authentik/blueprints-configmap.yaml +++ b/kubernetes/apps/authentik/blueprints-configmap.yaml @@ -1262,6 +1262,12 @@ 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 fedff443..f903b8a4 100644 --- a/kubernetes/apps/authentik/gate-sync.yaml +++ b/kubernetes/apps/authentik/gate-sync.yaml @@ -62,6 +62,12 @@ 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 new file mode 100644 index 00000000..000f58f7 --- /dev/null +++ b/kubernetes/infrastructure/authentik-gate-sync-crds/crd-oidcclients.yaml @@ -0,0 +1,99 @@ +# 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 new file mode 100644 index 00000000..c5373994 --- /dev/null +++ b/kubernetes/infrastructure/authentik-gate-sync-crds/kustomization.yaml @@ -0,0 +1,4 @@ +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 4e0975d2..13fb0975 100644 --- a/kubernetes/infrastructure/kustomization.yaml +++ b/kubernetes/infrastructure/kustomization.yaml @@ -27,6 +27,7 @@ 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