Live · auto-refresh every 15 min

The Access Engine

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.

65
Services
116
IngressRoutes
31
Auto-gen ACL rules
3
Three-way atomic write
7
OIDC clients
238
Audited events

What Adding "One More Service" Looks Like Without This

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.

01

Edit the IngressRoute

Add the authelia-forwardauth middleware reference to the route's middlewares[] array. Hope you got the namespace right. kubectl apply.

02

Edit the access policy

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.

03

Reload Authelia

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.

04

Mirror to Git

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.

05

Track who changed what

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.

06

Repeat 65 times

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.

Cluster-Aware IAM Loop

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.

👤
Admin browser
Source IP allowlist · am2_session · 8h lifetime · SameSite=Strict
HTTPS / 443
🌐
Traefik (kube-system)
IngressRoute · admin-ip-allowlist middleware → port 5000
NetworkPolicy: Traefik-only ingress + monitoring scrape
NetworkPolicy gated
⚙️
Authelia Manager v2 (Flask · uid 1000 · zero caps)
25 modules · 4,924 lines Python · waitress · 8 threads
RBAC: get/list/patch on ingressroutes + middlewares · get/list on pods · create on pods/exec
reads ↗  ·  writes ↘
📡
cluster_scanner
Every 5min: kubectl get ingressroute -A
Classifies auth mode by middleware name.
Reconciles → DB.
🐘
PostgreSQL · 8 tables
services · service_domains · groups · group_service_access · service_bypass_rules · settings · app_users · audit_log
🔒
authelia_mgr (read-only)
Restricted PG user · 4 tables SELECT · 6 tables SELECT+DELETE · zero INSERT/UPDATE/DDL on Authelia's own DB
ACL apply (3-way write)
📝
/data/acl.yml (live)
hostPath into Authelia · .bak-{ts} snapshot first · 169 lines · 31 rules
📦
/git-repo/authelia/acl.yml
shutil.copy2 · git add/commit/push
Co-authored as "Authelia Manager"
🧾
audit_log row
timestamp · actor · action · target · gen-result · reload-result · git-sha
config reload
🔐
Authelia pod
authelia config reload graceful · rollout-restart fallback
Telemetry on :9959 → ServiceMonitor → Grafana
🛡️
Traefik forwardAuth
http://auth-svc/api/authz/forward-auth
Remote-User · Remote-Groups · Remote-Name · Remote-Email
protects
🍯
All your services — Immich · Grafana · Gitea · Vault · Onion · Stalwart · Headlamp · OpenWebUI · 57 more
Every protected route, every domain, every .home.lsn LAN service. One place to grant. One place to revoke. One audit row per click.
read path
write path
least-privilege boundary
the manager itself

Three Things It Does, Step By Step

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.

1

Cluster Scan → DB Sync → UI

services/cluster_scanner.py · services/scheduler.py · 5-minute daemon
R1
Daemon thread reads 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_loop
R2
kubectl 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()
R3
Groups discovered routes by (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.
W1
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()
W2
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.
A
Per-tick result logged to audit_log if anything changed. Silent on no-op cycles.
2

ACL Apply — Three-Way Atomic Write

routes/access.py:apply_acl · services/acl_generator.py · services/git_ops.py
R1
Read entire DB matrix: 65 services × 3 groups × per-domain bypass + protect rules. _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
R2
Group-rule deduplication: rules with identical (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.
W1
YAML emitted through a 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:ProperIndentDumper
W2
Snapshot existing live file: cp /data/acl.yml /data/acl.yml.bak-{ts}. Manual rollback path if anything goes wrong.
W3
Write the new file to /data/acl.yml (hostPath shared with Authelia pod). The file is now live but Authelia hasn't reloaded yet.
W4
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_push
W5
Trigger Authelia reload: kubectl 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_config
A
One row. audit_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.
3

Per-Route Auth Toggle (Local .home.lsn Services)

routes/local_auth.py · services/manifest_scanner.py
R1
Admin flips the switch on, say, 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_auth
W1
If enabling and the namespace has no authelia-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_middleware
W2
Mutated routes list patched back: kubectl patch ingressroute --type merge. Live cluster updated.
W3
Find the corresponding YAML file under /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_file
W4
git_ops.commit_and_push. K3S and git stay coherent: cluster IS the live state, git IS the historical truth, no drift.
A
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.

Live View of a Running Production System

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.

116
IngressRoute objects
across all namespaces
130
Routing rules
match clauses
129
Hostnames
unique FQDNs
72
Middlewares
Traefik CRDs
65
Services registered
groups: 3 · matrix entries: 46
104
Service domains
in service_domains
31
Generated ACL rules
post-dedup · 169 YAML lines
7
OIDC clients
gitea · grafana · immich · openwebui · onion · stalwart · headlamp
24
Authelia forwardauth
routes gated by SSO/2FA
18
IP allowlist only
LAN / admin networks
88
Public / bypass
landing, marketing, docs
1
OIDC-protected
first-class OIDC client
238
Total audit entries
all-time
87
Successful logins
to the manager itself
17
Failed login attempts
rate-limit triggered
52
Access grants/revokes
tracked per cell-click
4,924
Lines of Python
across 25 modules
1,806
Lines of vanilla JS
no framework, no build step
8
PostgreSQL tables
single source of truth
7
RBAC verbs total
surgical kube permissions

Three Non-Obvious Decisions

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.

Byte-Stable YAML

ProperIndentDumper

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
Atomic-ish Apply

Three-Way Write + Bak File

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.

Production Lesson

Bypass Without Subject

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.

Defense In Depth, By Layer

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.

📦
Container hardening. runAsUser: 1000 · runAsGroup: 1000 · allowPrivilegeEscalation: false · capabilities.drop: [ALL]. Dockerfile creates appuser and USER 1000 before the entrypoint.
🌐
NetworkPolicy. Ingress only from 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.
🚪
Edge-level allowlist. The single IngressRoute attaches 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).
🐘
Restricted PostgreSQL user for Authelia DB. Verified live with 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.
🔑
Login hardening. PBKDF2-SHA256, 200,000 iterations. Rate limit: 5 attempts / 60 s per IP via in-memory sliding window. @admin_required gates every write endpoint — read-only users can view but not change.
🍪
Session hardening. 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.
📋
Comprehensive audit log. Every write — 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.
🔐
Surgical kube RBAC. 7 verbs total: 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

Everything Listed, Nothing Hidden

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.

🐍Python 3 · Flask · waitress 🐘PostgreSQL · 8 tables ⚙️Kubernetes · client-go via kubectl 🛡️Traefik · IngressRoute + Middleware CRDs 🔐Authelia · forwardAuth + OIDC 🌳Gitea · self-hosted · GitOps target 📈Prometheus · ServiceMonitor · :9959 📊Grafana · 15-panel dashboard 🎨Vanilla JS · no build step · no SPA framework 📜YAML via custom SafeDumper 🪞NetworkPolicy + RBAC + non-root container 🧪52-test adversarial security audit