Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f76defb1e1 | ||
|
|
3e0557cfe8 | ||
|
|
8739efe026 | ||
|
|
e518b46045 | ||
|
|
d7a6c2bdc7 | ||
|
|
53d7e716f8 | ||
|
|
61e4ee2ba8 | ||
|
|
b447c57e78 | ||
|
|
4f3cf541a4 | ||
|
|
6463c38f68 | ||
|
|
b86aaf3f88 |
@@ -35,25 +35,25 @@ when:
|
||||
steps:
|
||||
- name: model-selection-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.312
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
|
||||
commands:
|
||||
- sh scripts/ci/test-model-selection.sh
|
||||
|
||||
- name: pr-review-mistral-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.312
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
|
||||
commands:
|
||||
- sh scripts/ci/test-pr-review-mistral.sh
|
||||
|
||||
- name: promtool-rules-guard-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.312
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
|
||||
commands:
|
||||
- sh scripts/ci/test-promtool-rules.sh
|
||||
|
||||
- name: yaml-citations-guard-tests
|
||||
depends_on: []
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.312
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
|
||||
commands:
|
||||
- sh scripts/ci/test-yaml-citations.sh
|
||||
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ steps:
|
||||
# fetch-models.sh response mapping. No cluster, no secrets, no network — the
|
||||
# ci-claude image just provides sh + jq. Keeps the flag-injection regression
|
||||
# test from rotting (it exercises the exact injection shapes).
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.312
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
|
||||
commands:
|
||||
- sh scripts/ci/test-model-selection.sh
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ steps:
|
||||
# this step is skipped — otherwise we'd fork-bomb PRs.
|
||||
when:
|
||||
- branch: main
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.312
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -30,7 +30,7 @@ when:
|
||||
|
||||
steps:
|
||||
- name: update-audit-agent
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.312
|
||||
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.316
|
||||
environment:
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
from_secret: claude_code_oauth_token
|
||||
|
||||
@@ -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-<slug> (client ID = slug, secret generated by Authentik) and an
|
||||
application with that slug, so the issuer is <authentik>/application/o/<slug>/;
|
||||
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-<slug> 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():
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,7 +23,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: gitea-mcp
|
||||
image: docker.gitea.com/gitea-mcp-server:1.11.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
|
||||
image: docker.gitea.com/gitea-mcp-server:1.12.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
|
||||
# Image has no ENTRYPOINT (binary is in CMD), so args alone would replace it.
|
||||
command:
|
||||
- /app/gitea-mcp
|
||||
|
||||
@@ -64,7 +64,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: shelly-manager
|
||||
image: gitea.renner.uno/konairius/shelly-manager:2026-10-30-1257 # {"$imagepolicy": "flux-system:shelly-manager"}
|
||||
image: gitea.renner.uno/konairius/shelly-manager:2026-10-30-1343 # {"$imagepolicy": "flux-system:shelly-manager"}
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
|
||||
@@ -54,7 +54,7 @@ spec:
|
||||
# stdlib only; Flux keeps the tag in lockstep via the shared image
|
||||
# policy) — no extra dependency.
|
||||
- name: reconcile-publish-ip
|
||||
image: ghcr.io/music-assistant/server:2.10.8 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
image: ghcr.io/music-assistant/server:2.10.9 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
command: ["/app/venv/bin/python", "/scripts/reconcile.py"]
|
||||
volumeMounts:
|
||||
- name: data
|
||||
@@ -67,7 +67,7 @@ spec:
|
||||
# the stock file is the one it was written against (2.10.7 = 2.10.8);
|
||||
# any other version keeps the stock provider.
|
||||
- name: pocketcasts-overlay
|
||||
image: ghcr.io/music-assistant/server:2.10.8 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
image: ghcr.io/music-assistant/server:2.10.9 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
command:
|
||||
- /bin/sh
|
||||
- -euc
|
||||
@@ -88,7 +88,7 @@ spec:
|
||||
readOnly: true
|
||||
containers:
|
||||
- name: music-assistant
|
||||
image: ghcr.io/music-assistant/server:2.10.8 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
image: ghcr.io/music-assistant/server:2.10.9 # {"$imagepolicy": "flux-system:music-assistant"}
|
||||
env:
|
||||
- name: LOG_LEVEL
|
||||
value: info
|
||||
|
||||
@@ -93,7 +93,7 @@ spec:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: lidarr
|
||||
image: lscr.io/linuxserver/lidarr:3.1.6.5079-ls46 # {"$imagepolicy": "flux-system:lidarr"}
|
||||
image: ghcr.io/linuxserver/lidarr:3.1.6.5079-ls46 # {"$imagepolicy": "flux-system:lidarr"}
|
||||
env:
|
||||
- name: PUID
|
||||
value: "1000"
|
||||
@@ -140,7 +140,7 @@ spec:
|
||||
drop: ["ALL"]
|
||||
add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"]
|
||||
- name: reconcile
|
||||
image: lscr.io/linuxserver/lidarr:3.1.6.5079-ls46 # {"$imagepolicy": "flux-system:lidarr"}
|
||||
image: ghcr.io/linuxserver/lidarr:3.1.6.5079-ls46 # {"$imagepolicy": "flux-system:lidarr"}
|
||||
command: ["sh", "/scripts/reconcile.sh"]
|
||||
volumeMounts:
|
||||
- name: config
|
||||
|
||||
@@ -182,7 +182,7 @@ spec:
|
||||
drop: ["ALL"]
|
||||
containers:
|
||||
- name: paperless
|
||||
image: ghcr.io/paperless-ngx/paperless-ngx:3.4.0 # {"$imagepolicy": "flux-system:paperless-ngx"}
|
||||
image: ghcr.io/paperless-ngx/paperless-ngx:3.4.1 # {"$imagepolicy": "flux-system:paperless-ngx"}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8000
|
||||
|
||||
@@ -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/<slug>/. 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
|
||||
@@ -0,0 +1,4 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- crd-oidcclients.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
|
||||
|
||||
Reference in New Issue
Block a user