Compare commits

..
Author SHA1 Message Date
hermes-bot 14dc5247b7 fix(youtrack): OIDC client as an OIDCClient resource, not Ingress annotations
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
Uses the OIDCClient CRD from #1535; the Ingress carries only routing and
DNS again. The Flux Kustomization treats OIDCClient as current on apply,
since its Ready reflects Authentik state (the group), as with KumaMonitor.
2026-11-01 09:31:45 +00:00
hermes-bot 68d1a174db fix(youtrack): OIDC client from authentik-gate-sync, no blueprint
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The provider, application and group policy are now created by
authentik-gate-sync from the Ingress annotations auth.renner.uno/oidc-client
and auth.renner.uno/group (#1535); the youtrack.yaml blueprint is gone.
The group YouTrack Users stays managed in Authentik.
2026-11-01 09:22:40 +00:00
hermes-bot 7efc62e216 fix(youtrack): take the YouTrack Users group out of the blueprint
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
Groups are managed in Authentik itself. The blueprint now only binds the
application to the group by name (!Find).

The YouTrack entries move to their own blueprint (youtrack.yaml). Authentik
imports a blueprint atomically (importer.py apply() runs _apply_models in
one transaction), so while the group does not exist the whole YouTrack
blueprint rolls back: no provider, no application, login closed. In the
shared OAuth2 blueprint the same miss would also have blocked every other
provider.
2026-11-01 09:16:22 +00:00
hermes-bot f50b33cadf fix(youtrack): manage YouTrack Users members in Authentik, not the blueprint
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline failed
Same as Wiki Readers: the blueprint only ensures the group exists, because
the application's policy binding refers to it; membership is edited in
Authentik.
2026-11-01 08:45:40 +00:00
hermes-bot f96737c576 feat(youtrack): YouTrack Server, LAN only, login through Authentik
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
Namespace youtrack: jetbrains/youtrack 2026.2.19728 (digest-pinned), one
replica with Recreate, PVC youtrack (data/conf/logs/backups as subPaths),
Velero youtrack-daily plus restore-youtrack like the other apps.

The Ingress carries only dns.renner.uno/internal, so the UDM gets a record
and netbird-service-sync publishes nothing: the host is not reachable from
the internet until login goes through Authentik and a later PR adds the
public label.

Authentik blueprint: group YouTrack Users, confidential OIDC provider
(client_id youtrack; YouTrack's OpenID Connect auth module requires a
client secret), application slug youtrack, bound to the group.

YouTrack Server has no documented unattended first install (2026.2 docs:
the Configuration Wizard is mandatory, the skip flag exists for upgrades
only), so the wizard and the auth module are a one-time owner step,
accepted by the owner.
2026-11-01 08:42:43 +00:00
23 changed files with 383 additions and 586 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.316
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.312
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.316
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.312
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.316
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.312
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.316
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.312
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.316
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.312
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.316
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.312
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.316
image: gitea.renner.uno/gitea_admin/ci-claude:2.1.312
environment:
CLAUDE_CODE_OAUTH_TOKEN:
from_secret: claude_code_oauth_token
+24 -228
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""authentik-gate-sync: the Authentik side of the gate Middleware and of OIDC clients.
"""authentik-gate-sync: the Authentik side of the gate Middleware.
An Ingress is gated when its traefik.ingress.kubernetes.io/router.middlewares
annotation names the gate Middleware (authentik/gate, a ForwardAuth against the
@@ -18,16 +18,6 @@ Annotations (all optional):
auth.renner.uno/authorization-flow authorization flow slug (default below)
auth.renner.uno/skip-path provider skip_path_regex
OIDC clients: each OIDCClient (auth.renner.uno/v1alpha1, CRD in
infrastructure/authentik-gate-sync-crds) gets a confidential OAuth2/OpenID
provider oidc-<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.
@@ -40,7 +30,6 @@ import hashlib
import json
import logging
import os
import re
import sys
import time
import urllib.error
@@ -57,10 +46,6 @@ DEFAULT_AUTHZ_FLOW = os.environ.get("DEFAULT_AUTHORIZATION_FLOW", "default-provi
INVALIDATION_FLOW = os.environ.get("INVALIDATION_FLOW", "default-provider-invalidation-flow")
COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", "renner.uno")
AK_BROWSER_URL = os.environ.get("AUTHENTIK_HOST_BROWSER", "https://auth.renner.uno/")
SIGNING_KEY = os.environ.get("OIDC_SIGNING_KEY", "authentik Self-signed Certificate")
OIDC_SCOPES = tuple(f"goauthentik.io/providers/oauth2/scope-{s}" for s in ("openid", "profile", "email"))
OIDC_GRANTS = ["authorization_code", "refresh_token"]
SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,48}[a-z0-9])?$")
INTERVAL = int(os.environ.get("INTERVAL_SECONDS", "60"))
HTTP_TIMEOUT = 20
@@ -249,74 +234,6 @@ def provider_body(gate, flows):
}
OIDC_API = "/apis/auth.renner.uno/v1alpha1"
def oidc_slug(cr):
return ((cr.get("spec") or {}).get("slug") or cr["metadata"]["name"]).strip()
def oidc_spec(cr):
"""Translate an OIDCClient into the client it asks for. Raises GateError."""
meta, spec = cr["metadata"], cr.get("spec") or {}
slug = oidc_slug(cr)
if not SLUG_RE.match(slug):
raise GateError(f"slug {slug!r} is not a slug (a-z, 0-9, -; max 50)")
launch = spec.get("launchURL", "")
if not launch.startswith(("https://", "http://")):
raise GateError(f"launchURL {launch!r} is not an http(s) URL")
redirects = [{"matching_mode": r.get("matchingMode", "strict"), "url": r.get("url", ""),
"redirect_uri_type": "authorization"}
for r in spec.get("redirectURIs") or []]
if not redirects or any(not r["url"] or r["matching_mode"] not in ("strict", "regex") for r in redirects):
raise GateError("redirectURIs needs at least one entry with url and matchingMode strict|regex")
for r in redirects:
if r["matching_mode"] == "regex":
try:
re.compile(r["url"])
except re.error as e:
raise GateError(f"redirect regex {r['url']!r}: {e}") from None
return {
# name: provider and policy name; slug: application slug and client ID.
"name": f"oidc-{slug}",
"slug": slug,
"namespace": meta["namespace"],
"resource": meta["name"],
"launch_url": launch,
"group": spec.get("group") or None,
"authorization_flow": spec.get("authorizationFlow") or DEFAULT_AUTHZ_FLOW,
"redirect_uris": redirects,
}
def oidc_status(cr, ok, message):
cond = {"type": "Ready", "status": "True" if ok else "False",
"reason": "Reconciled" if ok else "Failed", "message": message[:1000],
"observedGeneration": cr["metadata"].get("generation", 0)}
old = next((c for c in (cr.get("status") or {}).get("conditions") or [] if c.get("type") == "Ready"), {})
same = all(old.get(k) == cond[k] for k in ("status", "reason", "message", "observedGeneration"))
cond["lastTransitionTime"] = old.get("lastTransitionTime") if same and old.get("lastTransitionTime") \
else time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
status = {"conditions": [cond], "observedGeneration": cond["observedGeneration"]}
if ok:
status["issuer"] = f"{AK_BROWSER_URL}application/o/{oidc_slug(cr)}/"
return status
def oauth2_body(spec, flows, refs):
return {
"name": spec["name"],
"authorization_flow": flows[spec["authorization_flow"]],
"invalidation_flow": flows[INVALIDATION_FLOW],
"client_type": "confidential",
"client_id": spec["slug"],
"grant_types": OIDC_GRANTS,
"signing_key": refs["signing_key"],
"property_mappings": sorted(refs["scopes"]),
"redirect_uris": spec["redirect_uris"],
}
def drift(current, desired):
return {k: v for k, v in desired.items() if current.get(k) != v}
@@ -347,18 +264,6 @@ class K8s:
return http("PATCH", K8S_API + path, obj, headers=self._h(), cafile=K8S_CA_FILE,
content_type="application/apply-patch+yaml")
def patch_status(self, path, status):
return http("PATCH", K8S_API + path + "/status", {"status": status}, headers=self._h(),
cafile=K8S_CA_FILE, content_type="application/merge-patch+json")
def oidc_clients(self):
try:
return self.get(f"{OIDC_API}/oidcclients").get("items", [])
except ApiError as e:
if e.code == 404: # CRD not installed: no clients, gates unaffected
return []
raise
def delete(self, obj):
try:
http("DELETE", K8S_API + object_path(obj), headers=self._h(), cafile=K8S_CA_FILE)
@@ -542,111 +447,42 @@ def ensure_access(ak, gate, target):
ak.req("DELETE", f"policies/expression/{stale['pk']}/")
def oidc_refs(ak):
"""Signing key and scope mappings every OIDC client uses; matched here, not
by server-side filters, so an ignored filter cannot pick another object."""
keys = [k["pk"] for k in ak.list("crypto/certificatekeypairs/") if k.get("name") == SIGNING_KEY]
if len(keys) != 1:
raise ApiError(f"expected one certificate {SIGNING_KEY!r}, found {len(keys)}")
scopes = {m.get("managed"): m["pk"] for m in ak.list("propertymappings/provider/scope/")
if m.get("managed") in OIDC_SCOPES}
missing = [s for s in OIDC_SCOPES if s not in scopes]
if missing:
raise ApiError(f"scope mappings not found: {missing}")
return {"signing_key": keys[0], "scopes": list(scopes.values())}
def ensure_oidc(ak, spec, flows, refs):
name, slug = spec["name"], spec["slug"]
if spec["group"] and ak.one("core/groups/", name=spec["group"]) is None:
raise GateError(f"group {spec['group']!r} not found in Authentik")
app = get_application(ak, slug)
if app is not None and app.get("meta_publisher") != MANAGER:
raise GateError(f"application {slug} exists and is not managed by {MANAGER}")
want = oauth2_body(spec, flows, refs)
provider = next((p for p in ak.list("providers/oauth2/") if 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 or OIDC client: the application (bindings cascade;
its provider is PROTECTed while referenced), then the provider (a proxy
provider leaves the embedded outpost with it), the policy. Ownership is
found through the application, so until it is gone a failed step is
retried on the next pass."""
"""Delete an owned gate: the application (bindings cascade; its provider is
PROTECTed while referenced), then the provider (which leaves the embedded
outpost with it), the policy. Ownership is found through the application,
so until it is gone a failed step is retried on the next pass."""
name, provider = app["slug"], app.get("provider")
path = app.get("_provider_path", "providers/proxy/")
if path == "providers/proxy/":
remove_dedicated_outposts(ak, name, provider)
remove_dedicated_outposts(ak, name, provider)
ak.req("DELETE", f"core/applications/{name}/")
if provider:
ak.req("DELETE", f"{path}{provider}/")
policy = ak.one("policies/expression/", name=policy_name(app))
ak.req("DELETE", f"providers/proxy/{provider}/")
policy = ak.one("policies/expression/", name=name)
if policy is not None:
ak.req("DELETE", f"policies/expression/{policy['pk']}/")
LOG.info("%s: removed %s from Authentik", name,
"OIDC client" if path == "providers/oauth2/" else "gate")
LOG.info("%s: removed gate from Authentik", name)
def owned_applications(ak):
"""Applications this controller owns, found through the (unfiltered) proxy
and OAuth2 provider lists and confirmed one by one."""
for path in PROVIDER_PATHS:
for p in ak.list(path):
slug = p.get("assigned_application_slug")
if not slug:
continue
app = get_application(ak, slug)
if app and app.get("meta_publisher") == MANAGER and app.get("provider") == p["pk"]:
yield dict(app, _provider_path=path)
provider list and confirmed one by one."""
for p in ak.list("providers/proxy/"):
slug = p.get("assigned_application_slug")
if not slug:
continue
app = get_application(ak, slug)
if app and app.get("meta_publisher") == MANAGER and app.get("provider") == p["pk"]:
yield app
# --- reconcile --------------------------------------------------------------
def reconcile(k8s=None, ak=None):
k8s = k8s or K8s()
if ak is None:
with open(AK_TOKEN_FILE) as f:
ak = Authentik(f.read().strip())
def reconcile():
k8s = K8s()
with open(AK_TOKEN_FILE) as f:
ak = Authentik(f.read().strip())
ingresses = k8s.get("/apis/networking.k8s.io/v1/ingresses").get("items", [])
sources = [i for i in ingresses if is_gated(i)]
clients = k8s.oidc_clients()
sources = [i for i in k8s.get("/apis/networking.k8s.io/v1/ingresses").get("items", []) if is_gated(i)]
shared = shared_hosts(sources)
flows = {f["slug"]: f["pk"] for f in ak.list("flows/instances/")}
ensure_embedded_config(ak)
@@ -690,44 +526,6 @@ def reconcile(k8s=None, ak=None):
held.add(gate_name(ing))
failed += 1
gate_names = {gate_name(i) for i in sources}
keep_clients = set() # OIDC slugs whose Authentik side should exist
slugs = [oidc_slug(i) for i in clients]
refs = None
for cr in sorted(clients, key=oidc_slug):
ref, slug = f'{cr["metadata"]["namespace"]}/{cr["metadata"]["name"]}', oidc_slug(cr)
ok, message = False, ""
try:
spec = oidc_spec(cr)
if slugs.count(slug) > 1:
raise GateError(f"slug {slug} is claimed by more than one OIDCClient")
if slug in gate_names:
raise GateError(f"slug {slug} is the name of a gate")
for flow in (spec["authorization_flow"], INVALIDATION_FLOW):
if flow not in flows:
raise GateError(f"flow {flow!r} not found in Authentik")
keep_clients.add(slug)
refs = refs or oidc_refs(ak)
ensure_oidc(ak, spec, flows, refs)
ok, message = True, f"provider {spec['name']}, application {slug}"
except GateError as e:
LOG.error("OIDCClient %s: %s", ref, e)
failed += 1
keep_clients.discard(slug)
message = str(e)
except Exception as e:
LOG.error("OIDCClient %s: %s", ref, e)
keep_clients.add(slug)
failed += 1
message = f"transient: {e}"
try:
path = f'{OIDC_API}/namespaces/{cr["metadata"]["namespace"]}/oidcclients/{cr["metadata"]["name"]}'
status = oidc_status(cr, ok, message)
if status != cr.get("status"):
k8s.patch_status(path, status)
except ApiError as e:
LOG.error("OIDCClient %s: status: %s", ref, e)
removed = 0
for obj in managed_objects(k8s):
key = (obj["kind"], obj["metadata"]["namespace"], obj["metadata"]["name"])
@@ -738,13 +536,11 @@ def reconcile(k8s=None, ak=None):
removed += 1
for app in list(owned_applications(ak)):
keep = keep_clients if app["_provider_path"] == "providers/oauth2/" else gates
if app["slug"] not in keep:
if app["slug"] not in gates:
remove_gate(ak, app)
removed += 1
LOG.info("reconciled: %d gates, %d OIDC clients, %d failed, %d objects removed",
len(sources), len(clients), failed, removed)
LOG.info("reconciled: %d gates, %d failed, %d objects removed", len(sources), failed, removed)
def main():
@@ -176,10 +176,8 @@ class FakeAuthentik:
"""In-memory stand-in for the endpoints the controller uses; records calls."""
def __init__(self):
self.db = {"providers/proxy/": {}, "providers/oauth2/": {}, "core/applications/": {},
"outposts/instances/": {}, "policies/bindings/": {}, "policies/expression/": {},
"core/groups/": {}, "crypto/certificatekeypairs/": {},
"propertymappings/provider/scope/": {}}
self.db = {"providers/proxy/": {}, "core/applications/": {}, "outposts/instances/": {},
"policies/bindings/": {}, "policies/expression/": {}, "core/groups/": {}}
self.calls = []
self.next = 1
@@ -196,9 +194,6 @@ class FakeAuthentik:
return coll, rest or None
raise AssertionError(path)
def _provider(self, pk):
return self.db["providers/proxy/"].get(pk) or self.db["providers/oauth2/"][pk]
def _find(self, coll, key):
if coll == "core/applications/":
return next((a for a in self.db[coll].values() if a["slug"] == key), None)
@@ -231,7 +226,7 @@ class FakeAuthentik:
obj = self.add(coll, dict(body))
if coll == "core/applications/":
obj["pbm_uuid"] = f"pbm-{obj['pk']}"
self._provider(obj["provider"])["assigned_application_slug"] = obj["slug"]
self.db["providers/proxy/"][obj["provider"]]["assigned_application_slug"] = obj["slug"]
return copy.deepcopy(obj)
obj = self._find(coll, key)
if method == "PATCH":
@@ -244,7 +239,7 @@ class FakeAuthentik:
if method == "DELETE":
del self.db[coll][obj["pk"]]
if coll == "core/applications/":
self._provider(obj["provider"])["assigned_application_slug"] = None
self.db["providers/proxy/"][obj["provider"]]["assigned_application_slug"] = None
for b in [b for b in self.db["policies/bindings/"].values() if b["target"] == obj["pbm_uuid"]]:
del self.db["policies/bindings/"][b["pk"]]
return None
@@ -387,234 +382,5 @@ class Reconcile(unittest.TestCase):
self.assertEqual(self.ak.get(f"core/applications/{self.g['name']}/")["provider"], app["provider"])
YOUTRACK = {
"metadata": {"name": "youtrack", "namespace": "youtrack", "generation": 3},
"spec": {
"launchURL": "https://youtrack.renner.uno",
"group": "YouTrack Users",
"redirectURIs": [{"matchingMode": "regex", "url": r"^https://youtrack\.renner\.uno/.*$"}],
},
}
class OidcSpec(unittest.TestCase):
def test_youtrack(self):
s = c.oidc_spec(YOUTRACK)
self.assertEqual((s["name"], s["slug"], s["launch_url"], s["group"]),
("oidc-youtrack", "youtrack", "https://youtrack.renner.uno", "YouTrack Users"))
self.assertEqual(s["redirect_uris"],
[{"matching_mode": "regex", "url": r"^https://youtrack\.renner\.uno/.*$",
"redirect_uri_type": "authorization"}])
def test_slug_overrides_name(self):
cr = copy.deepcopy(YOUTRACK)
cr["spec"]["slug"] = "yt"
self.assertEqual(c.oidc_spec(cr)["slug"], "yt")
def test_strict_is_default(self):
cr = copy.deepcopy(YOUTRACK)
cr["spec"]["redirectURIs"] = [{"url": "https://youtrack.renner.uno/cb"}]
self.assertEqual(c.oidc_spec(cr)["redirect_uris"][0]["matching_mode"], "strict")
def test_rejects_bad_slug(self):
cr = copy.deepcopy(YOUTRACK)
cr["spec"]["slug"] = "You Track"
with self.assertRaises(c.GateError):
c.oidc_spec(cr)
def test_rejects_missing_redirects(self):
cr = copy.deepcopy(YOUTRACK)
cr["spec"]["redirectURIs"] = []
with self.assertRaises(c.GateError):
c.oidc_spec(cr)
def test_rejects_bad_regex(self):
cr = copy.deepcopy(YOUTRACK)
cr["spec"]["redirectURIs"] = [{"matchingMode": "regex", "url": "("}]
with self.assertRaises(c.GateError):
c.oidc_spec(cr)
def test_status(self):
st = c.oidc_status(YOUTRACK, True, "ok")
self.assertEqual(st["issuer"], c.AK_BROWSER_URL + "application/o/youtrack/")
self.assertEqual((st["conditions"][0]["status"], st["observedGeneration"]), ("True", 3))
cr = dict(YOUTRACK, status=st)
self.assertEqual(c.oidc_status(cr, True, "ok")["conditions"][0]["lastTransitionTime"],
st["conditions"][0]["lastTransitionTime"])
self.assertNotIn("issuer", c.oidc_status(cr, False, "group missing"))
class ReconcileOidc(unittest.TestCase):
def setUp(self):
self.ak = FakeAuthentik()
self.ak.add("crypto/certificatekeypairs/", {"name": c.SIGNING_KEY, "pk": "key-1"})
self.ak.add("crypto/certificatekeypairs/", {"name": "other", "pk": "key-2"})
for i, scope in enumerate(c.OIDC_SCOPES):
self.ak.add("propertymappings/provider/scope/", {"pk": f"m{i}", "managed": scope})
self.ak.add("propertymappings/provider/scope/", {"pk": "m9", "managed": "goauthentik.io/x"})
self.ak.add("core/groups/", {"name": "YouTrack Users"})
self.s = c.oidc_spec(YOUTRACK)
def run_once(self):
c.ensure_oidc(self.ak, self.s, FLOWS, c.oidc_refs(self.ak))
def test_creates_owned_client(self):
self.run_once()
app = self.ak.get("core/applications/youtrack/")
self.assertEqual(app["meta_publisher"], c.MANAGER)
p = self.ak.db["providers/oauth2/"][app["provider"]]
self.assertEqual((p["name"], p["client_id"], p["client_type"], p["signing_key"]),
("oidc-youtrack", "youtrack", "confidential", "key-1"))
self.assertNotIn("client_secret", p)
self.assertEqual(p["grant_types"], ["authorization_code", "refresh_token"])
self.assertEqual(p["property_mappings"], ["m0", "m1", "m2"])
policy = self.ak.one("policies/expression/", name="oidc-youtrack")
self.assertIn("'YouTrack Users'", policy["expression"])
self.assertEqual([b["target"] for b in self.ak.list("policies/bindings/")], [app["pbm_uuid"]])
def test_second_pass_is_a_noop(self):
self.run_once()
before = len(self.ak.calls)
self.run_once()
self.assertEqual(self.ak.calls[before:], [])
def test_missing_group_is_a_gate_error(self):
self.ak.db["core/groups/"].clear()
with self.assertRaises(c.GateError):
self.run_once()
self.assertEqual(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,12 +1262,6 @@ data:
- authentik_policies_expression.add_expressionpolicy
- authentik_policies_expression.change_expressionpolicy
- authentik_policies_expression.delete_expressionpolicy
- authentik_providers_oauth2.view_oauth2provider
- authentik_providers_oauth2.add_oauth2provider
- authentik_providers_oauth2.change_oauth2provider
- authentik_providers_oauth2.delete_oauth2provider
- authentik_providers_oauth2.view_scopemapping
- authentik_crypto.view_certificatekeypair
- authentik_core.view_group
- authentik_flows.view_flow
- authentik_outposts.view_outpostserviceconnection
-6
View File
@@ -62,12 +62,6 @@ rules:
- apiGroups: ["cilium.io"]
resources: ["ciliumnetworkpolicies"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["auth.renner.uno"]
resources: ["oidcclients"]
verbs: ["get", "list", "watch"]
- apiGroups: ["auth.renner.uno"]
resources: ["oidcclients/status"]
verbs: ["get", "patch", "update"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
+90
View File
@@ -0,0 +1,90 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: youtrack
namespace: youtrack
labels:
app.kubernetes.io/name: youtrack
spec:
replicas: 1
# One process owns the database directory.
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: youtrack
template:
metadata:
labels:
app.kubernetes.io/name: youtrack
spec:
automountServiceAccountToken: false
enableServiceLinks: false
securityContext:
runAsNonRoot: true
# The image's service account.
runAsUser: 13001
runAsGroup: 13001
fsGroup: 13001
seccompProfile:
type: RuntimeDefault
containers:
- name: youtrack
image: jetbrains/youtrack:2026.2.19728@sha256:c5e9cedc6fb1d7bcc9b3b56fc3f63e563623e1cd39396161eb70957991583950
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
ports:
- name: http
containerPort: 8080
resources:
requests:
cpu: 250m
memory: 2Gi
limits:
memory: 4Gi
startupProbe:
tcpSocket:
port: http
periodSeconds: 10
failureThreshold: 60
readinessProbe:
tcpSocket:
port: http
periodSeconds: 10
livenessProbe:
tcpSocket:
port: http
periodSeconds: 30
failureThreshold: 4
volumeMounts:
- name: youtrack
mountPath: /opt/youtrack/data
subPath: data
- name: youtrack
mountPath: /opt/youtrack/conf
subPath: conf
- name: youtrack
mountPath: /opt/youtrack/logs
subPath: logs
- name: youtrack
mountPath: /opt/youtrack/backups
subPath: backups
volumes:
- name: youtrack
persistentVolumeClaim:
claimName: youtrack
---
apiVersion: v1
kind: Service
metadata:
name: youtrack
namespace: youtrack
spec:
selector:
app.kubernetes.io/name: youtrack
ports:
- name: http
port: 8080
targetPort: http
+27
View File
@@ -0,0 +1,27 @@
# LAN-only (UDM record via dns.renner.uno/internal); no public label until login goes through Authentik.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: youtrack
namespace: youtrack
labels:
dns.renner.uno/internal: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
ingressClassName: traefik
tls:
- hosts:
- youtrack.renner.uno
secretName: youtrack-tls
rules:
- host: youtrack.renner.uno
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: youtrack
port:
number: 8080
@@ -0,0 +1,7 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- pvc.yaml
- deployment.yaml
- ingress.yaml
- oidcclient.yaml
+13
View File
@@ -0,0 +1,13 @@
# Authentik OIDC client, created by authentik-gate-sync; issuer https://auth.renner.uno/application/o/youtrack/.
apiVersion: auth.renner.uno/v1alpha1
kind: OIDCClient
metadata:
name: youtrack
namespace: youtrack
spec:
launchURL: https://youtrack.renner.uno
group: YouTrack Users
redirectURIs:
# YouTrack generates the callback when the auth module is created; pin it strict once known.
- matchingMode: regex
url: ^https://youtrack\.renner\.uno/.*$
+17
View File
@@ -0,0 +1,17 @@
# YouTrack data, conf, logs and backups (subPaths); Velero youtrack-daily.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: youtrack
namespace: youtrack
labels:
app.kubernetes.io/name: youtrack
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled
spec:
accessModes:
- ReadWriteOnce
storageClassName: ceph-block
resources:
requests:
storage: 20Gi
+28
View File
@@ -394,3 +394,31 @@ spec:
status.validationErrors.exists(e, e.startsWith('No backups found for schedule')))))
current: >-
has(status.phase) && (status.phase == 'Completed' || status.phase == 'FailedValidation')
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: restore-youtrack
namespace: flux-system
spec:
interval: 10m
timeout: 1h
sourceRef:
kind: GitRepository
name: flux-system
path: ./kubernetes/restore/youtrack
prune: true
wait: true
dependsOn:
- name: restore-gitea
- name: velero-backups-synced
- name: infrastructure-ceph-cluster
healthCheckExprs:
- apiVersion: velero.io/v1
kind: Restore
failed: >-
has(status.phase) && (status.phase in ['Failed', 'PartiallyFailed'] ||
(status.phase == 'FailedValidation' && !(has(status.validationErrors) &&
status.validationErrors.exists(e, e.startsWith('No backups found for schedule')))))
current: >-
has(status.phase) && (status.phase == 'Completed' || status.phase == 'FailedValidation')
+25
View File
@@ -0,0 +1,25 @@
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: youtrack
namespace: flux-system
spec:
interval: 10m
retryInterval: 1m
timeout: 15m
sourceRef:
kind: GitRepository
name: flux-system
path: ./kubernetes/apps/youtrack
prune: true
wait: true
# OIDCClient Ready tracks Authentik (e.g. the group exists), not this apply.
healthCheckExprs:
- apiVersion: auth.renner.uno/v1alpha1
kind: OIDCClient
current: "true"
dependsOn:
- name: restore-youtrack
- name: infrastructure
- name: infrastructure-crds
- name: infrastructure-ceph-cluster
@@ -1,99 +0,0 @@
# OIDCClient: an Authentik OAuth2/OpenID client, reconciled by authentik-gate-sync (ci/images/authentik-gate-sync).
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: oidcclients.auth.renner.uno
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled
spec:
group: auth.renner.uno
names:
kind: OIDCClient
listKind: OIDCClientList
plural: oidcclients
singular: oidcclient
scope: Namespaced
versions:
- name: v1alpha1
served: true
storage: true
subresources:
status: {}
additionalPrinterColumns:
- jsonPath: .status.issuer
name: Issuer
type: string
- jsonPath: .status.conditions[?(@.type=="Ready")].status
name: Ready
type: string
schema:
openAPIV3Schema:
type: object
required: [spec]
properties:
apiVersion:
type: string
kind:
type: string
metadata:
type: object
spec:
type: object
required: [launchURL, redirectURIs]
properties:
slug:
description: Application slug and client ID; issuer is /application/o/<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
@@ -27,7 +27,6 @@ resources:
- admission-policies/
- kuma-sync-crds/
- cloudflare-lb-sync-crds/
- authentik-gate-sync-crds/
# The OpenBao ClusterSecretStore lives in post-crds/ (needs ESO's CRDs and
# an initialized OpenBao; see kubernetes/MIGRATION-sops-openbao.md).
# cnpg-barman-plugin/ lives in its own Flux Kustomization
@@ -83,6 +83,11 @@ metadata:
---
apiVersion: v1
kind: Namespace
metadata:
name: youtrack
---
apiVersion: v1
kind: Namespace
metadata:
name: immich
---
@@ -32,6 +32,7 @@ resources:
- wordpress-ns.yaml
- ai-ns.yaml
- navidrome-ns.yaml
- youtrack-ns.yaml
- uptime-monitor-ns.yaml
- kuma-sync-ns.yaml
- cloudflare-lb-sync-ns.yaml
@@ -0,0 +1,107 @@
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: default-deny-ingress
namespace: youtrack
spec:
endpointSelector: {}
ingress:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": youtrack
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ingress
app.kubernetes.io/name: traefik
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": monitoring
- fromEntities:
- host
- remote-node
# ICMP — all standard ICMPv4 + ICMPv6 from any cluster pod. See
# home-assistant-ns.yaml for the rationale (CT-RELATED path not
# passing related ICMP empirically; per-namespace shape, mirroring
# commit 3c517a0's verified-safe pattern with the 66dbe9f type set).
- fromEndpoints:
- matchExpressions:
- key: io.kubernetes.pod.namespace
operator: Exists
icmps:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 143, family: IPv6 }
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": youtrack
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ingress
app.kubernetes.io/name: traefik
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
# host/remote-node: kubelet probes + node-local reach (Ceph mons on host net)
- toEntities:
- host
- remote-node
# ICMP — egress mirror; see ingress block above.
- toEndpoints:
- matchExpressions:
- key: io.kubernetes.pod.namespace
operator: Exists
icmps:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 143, family: IPv6 }
---
# Outbound HTTPS: OIDC discovery/token/JWKS at auth.renner.uno (Traefik VIP, matched as traefik
# pods above) and JetBrains services (license, update check), mail-free.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: youtrack-egress-https
namespace: youtrack
spec:
endpointSelector: {}
egress:
- toEntities:
- world
toPorts:
- ports:
- port: "443"
protocol: TCP
@@ -369,3 +369,23 @@ spec:
labelSelector:
matchLabels:
app.kubernetes.io/name: synapse
---
apiVersion: velero.io/v1
kind: Schedule
metadata:
name: youtrack-daily
namespace: velero
labels:
app.kubernetes.io/part-of: homelab-backup
spec:
# Daily at 10:15 local, next free stagger slot after matrix-daily at 09:45.
schedule: "15 10 * * *"
useOwnerReferencesInBackup: false
template:
includedNamespaces:
- youtrack
includedResources:
- persistentvolumeclaims
storageLocation: default
ttl: 720h0m0s
snapshotMoveData: true
@@ -1,4 +1,4 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- crd-oidcclients.yaml
- restore.yaml
+7
View File
@@ -0,0 +1,7 @@
apiVersion: velero.io/v1
kind: Restore
metadata:
name: youtrack
namespace: velero
spec:
scheduleName: youtrack-daily