Compare commits

...
Author SHA1 Message Date
hermes-bot f76defb1e1 fix(authentik-gate-sync): review of the OIDC clients
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
- Desired redirect URIs carry redirect_uri_type: authorization, which the
  API returns on every entry (RedirectURISerializer default in 2026.8.4);
  without it every pass would re-PATCH the provider.
- OIDC slugs and gate names are kept apart: a slug that is a gate name is
  refused, an existing application without this controller's OAuth2
  provider is refused, and the sweep keeps proxy and OAuth2 applications
  by their own sets, so an OIDCClient can neither take over nor delete a
  gate.
- Status is written only when it changed.
- Adoption of an unassigned oidc-<slug> provider is documented; removal
  logs say OIDC client.
- reconcile() takes k8s/ak for tests; new loop tests: create then quiet,
  duplicate slug, transient error keeps the client, sweep after the
  resource is gone, slug equal to a gate name.
2026-11-01 09:49:33 +00:00
hermes-bot 3e0557cfe8 feat(authentik-gate-sync): OIDC clients as OIDCClient resources, not Ingress annotations
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
OIDC is not part of the Ingress. The client is now declared by an
OIDCClient (auth.renner.uno/v1alpha1, CRD in
infrastructure/authentik-gate-sync-crds, like kuma-sync-crds) next to the
app: launchURL, redirectURIs, optional slug, group, authorizationFlow.
The controller writes a Ready condition and the issuer to its status.

The auth.renner.uno/oidc-* Ingress annotations are gone; RBAC gains
get/list/watch on oidcclients and patch on their status.
2026-11-01 09:31:43 +00:00
hermes-bot 8739efe026 feat(authentik-gate-sync): OIDC clients from auth.renner.uno/oidc-client
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
An Ingress annotated auth.renner.uno/oidc-client: <slug> gets a
confidential OAuth2/OpenID provider (client ID = slug, secret generated by
Authentik), an application with that slug and, with auth.renner.uno/group,
the same group expression policy as gates. auth.renner.uno/oidc-redirect-uri
pins strict redirect URIs on the Ingress host; default is any path on it.

Same fail-closed and ownership rules as gates: owned clients are found via
the OAuth2 provider list and meta_publisher and swept when their Ingress
goes. The service-account role gains the OAuth2 provider permissions plus
view on scope mappings and certificate key pairs.

So apps that log in through Authentik via OIDC (first: YouTrack, #1534)
need no blueprint entries; the groups stay managed in Authentik.
2026-11-01 09:22:25 +00:00
k.renner e518b46045 Merge pull request 'chore(ci): bump ci-claude image to 2.1.316' (#1533) from ci-image/claude-2.1.316 into main
Reviewed-on: #1533
2026-11-01 09:14:15 +00:00
claude-bot d7a6c2bdc7 chore(ci): bump ci-claude image to 2.1.316
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
2026-11-01 00:01:25 +00:00
k.renner 53d7e716f8 Merge pull request 'fix(loki): enforce retention via the compactor, keep 365 days' (#1532) from worktree-loki-retention-365d into main
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/ci-claude-refresh Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
Reviewed-on: #1532
2026-10-31 06:10:42 +00:00
security-bot 61e4ee2ba8 chore(image-automation): bump ghcr.io/paperless-ngx/paperless-ngx:3.4.1
Updates managed by image-automation-controller.
2026-10-31 05:50:56 +00:00
security-bot b447c57e78 chore(image-automation): bump ghcr.io/linuxserver/lidarr:3.1.6.5079-ls46
Updates managed by image-automation-controller.
2026-10-31 00:23:17 +00:00
security-bot 4f3cf541a4 chore(image-automation): bump docker.gitea.com/gitea-mcp-server:1.12.0
Updates managed by image-automation-controller.
2026-10-30 19:30:16 +00:00
security-bot 6463c38f68 chore(image-automation): bump gitea.renner.uno/konairius/shelly-manager:2026-10-30-1343
Updates managed by image-automation-controller.
2026-10-30 13:51:12 +00:00
security-bot b86aaf3f88 chore(image-automation): bump ghcr.io/music-assistant/server:2.10.9
Updates managed by image-automation-controller.
2026-10-30 13:50:57 +00:00
16 changed files with 597 additions and 43 deletions
+4 -4
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+228 -24
View File
@@ -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
+6
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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