A single Flask app that turns Authelia + Traefik + Kubernetes into a managed identity platform.
Discovers your cluster. Generates ACLs from a visual matrix. Ships every change to Git, atomically. Reloads Authelia. Audits everything.
Built solo over weekends. Replaces a stack that costs $500–$1,000/month commercially. Runs on $0.
⚠️ The Problem
Authelia is the SSO/2FA brain. Traefik is the gateway. Kubernetes is the runtime. The glue between them is YAML — and that glue is hand-edited unless something does it for you.
Add the authelia-forwardauth middleware reference to the route's middlewares[] array. Hope you got the namespace right. kubectl apply.
Open access_control.yml. Add a rule. Position it correctly in first-match order. Pick the right policy: bypass, one_factor, two_factor. Forget the subject field on a bypass rule → CrashLoopBackOff.
kubectl rollout restart deploy/authelia. Wait 30s. Test. Did it pick up the change? Did the YAML validate? If something's wrong — read pod logs, redo step 2.
cp /data/acl.yml /repo/authelia/ · git add · git commit -m "add foo.svc" · git push. If you forget this, your live config and your declared config diverge silently.
The git log tells you the file changed. It doesn't tell you which user clicked which button on which group's policy. You need a separate audit trail and you have to build it yourself.
For every service. For every group rotation. Every time a new app needs SSO. Every time someone's role changes. Every time an OIDC client needs to be created. This is the chore.
🏗️ The Architecture
One Flask app. Reads the cluster. Owns a Postgres model of services × groups. Generates ACLs. Writes them three places at once. Reloads Authelia. Surfaces metrics. Every arrow below is real code; every box is a real pod or file on disk.
am2_session · 8h lifetime · SameSite=Strictadmin-ip-allowlist middleware → port 5000get/list/patch on ingressroutes + middlewares · get/list on pods · create on pods/execkubectl get ingressroute -A.bak-{ts} snapshot first · 169 lines · 31 rulesshutil.copy2 · git add/commit/pushauthelia config reload graceful · rollout-restart fallbackhttp://auth-svc/api/authz/forward-authRemote-User · Remote-Groups · Remote-Name · Remote-Email.home.lsn LAN service. One place to grant. One place to revoke. One audit row per click.🔁 The Flows
Cluster discovery, policy generation, and per-route auth toggle — the three motions that account for ~95% of an admin's day. Each one is a single button click in the UI; each one is a precisely-ordered chain of reads, writes, and audit entries underneath.
auto_sync_enabled + auto_sync_interval from the settings table on every tick. Settings UI lets the admin disable scanning entirely or change cadence.scheduler.py:_run_loopkubectl get ingressroute -A -o json. Parses every match for Host(...); one IngressRoute can declare multiple hostnames, all are captured. Classifies auth mode by which middleware name appears: authelia-forwardauth, authelia-nas → forwardauth · ip-allowlist → none-with-flag · everything else → bypass/none.cluster_scanner.scan()(namespace, k8s_service) tuple → "logical apps." A static SERVICE_ID_MAP of ~60 entries assigns nice display names + emojis (immich · grafana · onion · stalwart…). Unmapped services get a slug auto-derived from their primary domain.compare_with_db() produces a four-bucket diff: new services · domain adds · stale domains (in DB but not in cluster) · stale services (deleted apps). UI shows the diff before commit.cluster_scanner.compare_with_db()sync_to_db() applies the diff with INSERT … ON CONFLICT upserts. New services land in the registry as "discovered" — admin still has to assign groups before policy fires.audit_log if anything changed. Silent on no-op cycles._build_rules() walks them in six ordered passes that mirror Authelia's first-match semantics: bypass services → resource-pattern bypasses → resource-pattern protects → catch-all group rules → wildcard policy at the end. Order matters — get it wrong and the wrong rule fires first.acl_generator.py:_build_rules(frozenset(groups), policy) tuples collapse into one rule listing all their domains. A 65-service matrix with 3 groups → 31 actual ACL rules. The dedup keeps the live YAML reviewable.ProperIndentDumper — yaml.SafeDumper subclass with corrected list indentation and ignore_aliases=True. The output is byte-stable: same DB → same bytes, every time. Diffs only show real change.acl_generator.py:ProperIndentDumpercp /data/acl.yml /data/acl.yml.bak-{ts}. Manual rollback path if anything goes wrong./data/acl.yml (hostPath shared with Authelia pod). The file is now live but Authelia hasn't reloaded yet.shutil.copy2 to /git-repo/authelia/acl.yml (hostPath of the K3S-manifests git working tree). git add · commit · push via subprocess; commit author = "Authelia Manager", co-author = the admin user.git_ops.commit_and_pushkubectl exec authelia -- authelia config reload (graceful). Falls back to kubectl rollout restart if the exec fails. ~3-second turnaround vs the ~30-second pod-cycle path.authelia_ops.reload_configaudit_log captures every prior step's result: gen success/failure, reload success/failure, git commit SHA (or "git failed: timeout" warning if push couldn't reach gitea — uptime is preserved over consistency, the .bak file is the rollback). Click → reproducible audit trail..home.lsn Services)lsn-meet.home.lsn. Live route fetched: kubectl get ingressroute <name> -o json. Code walks every entry whose match contains a .home.lsn host.local_auth.py:_toggle_authauthelia-forwardauth Middleware CRD yet → kubectl apply one. Pre-templated: target = http://auth-svc.security-tools.svc.cluster.local:9091/api/authz/forward-auth · trustForwardHeader: true · the four Remote-* response headers._ensure_forwardauth_middlewarekubectl patch ingressroute --type merge. Live cluster updated./git-repo/security-tools/. manifest_scanner walks multi-doc YAMLs looking for the matching kind+name+namespace. Updater closure runs the same mutation. yaml.dump_all back to disk.manifest_scanner.find_resource_file → update_resource_in_filegit_ops.commit_and_push. K3S and git stay coherent: cluster IS the live state, git IS the historical truth, no drift.audit_log entry: local_auth_enable or local_auth_disable, target = the route name, actor = the admin. Showing up next morning to "who put auth on the printer dashboard at 2 AM?" → 1-second answer.📊 The Numbers
Every count below is pulled live from the running PostgreSQL DB, the live cluster (kubectl), and the on-disk source tree, refreshed every 15 minutes. No marketing claims — these are the actual rows, routes, files, and bytes right now.
✨ The Engineering Choices Worth Talking About
The kind of details that don't make the README but separate "it works on my machine" from "it runs in production." Each one is a deliberate response to a real failure mode.
A normal yaml.dump emits aliases for repeated structures and indents list items inconsistently. Two semantically-identical ACL files would produce two different file bytes — and therefore noise on every git diff.
The fix: subclass yaml.SafeDumper, override increase_indent(flow, indentless=False) to force list-block indentation, return True from ignore_aliases. The diff endpoint also re-dumps the current file through the same dumper before computing the unified diff — so format drift never produces a false positive.
class ProperIndentDumper(yaml.SafeDumper):
def increase_indent(self, flow=False, indentless=False):
return super().increase_indent(flow, False)
def ignore_aliases(self, _):
return True
The ACL has to live in three places: the filesystem (Authelia reads it), git (humans review it), the audit log (auditors prove what happened). True atomicity across three storage systems is impossible — but ordering the writes makes any single failure recoverable.
1. cp acl.yml acl.yml.bak-{ts}
2. write /data/acl.yml # live · Authelia reads here
3. copy2 → /git-repo/... # mirror
4. git add · commit · push # may fail — OK
5. authelia config reload # 3s · rollout fallback
6. insert audit_log row # captures all 5 results
If git push fails, the apply still returns success: true with a "git failed:" warning. Uptime over consistency — the bak file is the rollback path.
Early version emitted policy: bypass rules paired with subject: [["group:X"]] — semantically reasonable: "this group bypasses auth on this resource." Authelia treats it as a config error → CrashLoopBackOff. The fix lives at the bottom of _build_rules():
if policy == "bypass":
rule["policy"] = "bypass"
# no 'subject' field — Authelia rejects it
else:
subj = [f"group:{g}" for g in groups]
rule["subject"] = [subj]
A small detail. A real production lesson. The kind of thing a hand-edited ACL would silently get wrong forever. This is exactly why the manager exists.
🛡️ The Threat Model
An access manager is a high-value target. If it gets popped, every service downstream gets popped. Hardening was a 12-finding internal audit (SECURITY.md); every finding has a control below.
runAsUser: 1000 · runAsGroup: 1000 · allowPrivilegeEscalation: false · capabilities.drop: [ALL]. Dockerfile creates appuser and USER 1000 before the entrypoint.
kube-system pods labelled app.kubernetes.io/name: traefik + monitoring namespace (for Prometheus scrape). Even the Authelia pod next door cannot reach the manager API directly.
kube-system/admin-ip-allowlist. Web UI is unreachable except from admin source IPs — Authelia itself doesn't gate its own manager (that would be circular).
information_schema.role_table_grants: 4 tables SELECT-only · 6 tables SELECT+DELETE · zero INSERT/UPDATE/CREATE/DROP. The manager can read 2FA / auth state and reset 2FA — nothing else. Attacker compromise still can't grant themselves access.
@admin_required gates every write endpoint — read-only users can view but not change.
SESSION_COOKIE_SAMESITE = "Strict" · HTTPONLY = True · custom name am2_session · 8h lifetime. Global error handler regex-strips DB internals (violates|constraint|relation|detail:|row contains) so a constraint error never leaks a partial bcrypt hash to the client.
access_grant · access_revoke · acl_apply · local_auth_enable · local_auth_disable · OIDC CRUD · login · logout · login_failed — gets a row. Live count: 238 entries · 87 logins · 17 failed · 52 grants.
pods get/list · pods/exec create · ingressroutes get/list/patch/update · middlewares get/list/create/patch/update · services get/list · deployments get/patch. No cluster-admin. No secret access. No node access.
The closest commercial equivalent is Auth0 + a custom Kubernetes operator + ArgoCD — roughly $500–1,000/month plus months of engineering. This was built solo on weekends, runs on $0, and does things none of them can: it knows every IngressRoute in your cluster, generates a byte-stable YAML, and proves what changed in the same audit row that proves who changed it.
— the design brief, written backwards from what was needed
🧱 The Stack
No frameworks-of-the-month, no build pipelines, no containers wrapped in containers. Boring tools that compose well, picked because each one is the simplest thing that works at this scale.