infra/ingress_factory: add auth = "app" mode for self-authed backends

Adds a fourth auth tier alongside required/public/none. "app" is
functionally identical to "none" — no Authentik middleware attached —
but the distinct name records intent at the call site: this backend
has its own user login (NextAuth, Django, OAuth, bearer-token API,
etc.) and Authentik would only break it.

Why the new tier: with only required/none, every "the app has its
own auth so drop Authentik" decision looked identical at the call
site to "this is an OAuth callback / webhook receiver / native-client
API". Future readers couldn't tell whether a stack was intentionally
unauthenticated or relying on backend auth. Now they can.

Migrates the 8 stacks flipped earlier this session (novelapp, immich,
linkwarden, tandoor, freshrss, affine, actualbudget, ebooks/audiobookshelf)
from "none" to "app". Confirmed no-op: `tg plan` on novelapp showed
"No changes" — same middleware chain, same live state.

The variable description and the .claude/CLAUDE.md Auth section now
spell out the anti-exposure rule: only pick "app" or "none" AFTER
verifying the app has its own user auth ("app") or the endpoint is
intentionally public ("none"). Default stays "required" so accidental
omission fails closed.

[ci skip]
This commit is contained in:
Viktor Barzin 2026-05-11 18:59:11 +00:00
parent 6b9f5e8027
commit eb529d60e4
10 changed files with 73 additions and 37 deletions

View file

@ -28,7 +28,14 @@ Violations cause state drift, which causes future applies to break or silently r
- **Apply**: Authenticate via `vault login -method=oidc`, then use `scripts/tg` (preferred — handles state decrypt/encrypt) or `terragrunt` directly. `scripts/tg` adds `-auto-approve` for `--non-interactive` applies.
- **New services need CI/CD** and **monitoring** (Prometheus/Uptime Kuma)
- **New service**: Use `setup-project` skill for full workflow
- **Ingress**: `ingress_factory` module. **Auth** (`auth` string enum, default `"required"` — fail-closed): `auth = "required"` (Authentik login required — the standard tier; legacy `protected = true` semantics), `auth = "public"` (auto-bind anonymous requests to the `guest` Authentik user via the dedicated `public` outpost; logged-in users keep their real identity in `X-authentik-username`; routed via `traefik-authentik-forward-auth-public` middleware → `ak-outpost-public.authentik.svc:9000`), `auth = "none"` (no Authentik middleware at all — for Anubis-fronted content, native-client APIs like Git/`/v2/`/WebDAV/CalDAV, webhook receivers, OAuth callbacks, and the Authentik outposts themselves). **`auth = "public"` only works for top-level browser navigation** — it requires a 302+cookie dance on first visit to set the guest session cookie, which CORS preflight rejects on XHR/fetch() and which automation scripts can't replay. Use `auth = "none"` for XHR APIs / webhooks / native clients. **Anti-AI**: on by default ONLY when `auth = "none"` (Authentik gating already discourages bots; redundant on `auth = "required"` and `"public"`). **DNS**: `dns_type = "proxied"` (Cloudflare CDN) or `"non-proxied"` (direct A/AAAA). DNS records are auto-created — no need to edit `config.tfvars`. Smoke-test target: `echo.viktorbarzin.me` (auth=public, header-reflecting backend).
- **Ingress**: `ingress_factory` module. **Auth** (`auth` string enum, default `"required"` — fail-closed). Pick by asking "what gates the app?":
- `auth = "required"` — Authentik forward-auth gates every request. Use when the backend has **no built-in user auth** and Authentik is the only thing standing between strangers and the app (prowlarr, qbittorrent, netbox, phpipam, k8s-dashboard, foolery, any admin UI shipped without its own login).
- `auth = "app"` — the backend handles its own user authentication (NextAuth, Django, OAuth, bearer-token API, etc.); Authentik would only break it. No middleware attached; the app's own login is the gate. Examples: immich, linkwarden, tandoor, freshrss, affine, actualbudget, audiobookshelf, novelapp. **Functionally identical to `"none"`** — the distinct name exists to record intent at the call site.
- `auth = "public"` — Authentik anonymous binding via the dedicated `public` outpost (routes via `traefik-authentik-forward-auth-public``ak-outpost-public.authentik.svc:9000`). Strangers auto-bound to `guest`; logged-in users keep their identity in `X-authentik-username`. **Only works for top-level browser navigation** — CORS preflight rejects XHR/fetch and automation can't replay the cookie dance. Audit trail, not a gate.
- `auth = "none"` — no Authentik, no own-auth claim. Use for Anubis-fronted content (Anubis is the gate), native-client APIs (Git, `/v2/`, WebDAV/CalDAV, CardDAV), webhook receivers, OAuth callbacks, and Authentik outposts themselves.
- **Anti-exposure rule** (the reason `"app"` exists): only pick `"app"` or `"none"` AFTER you've verified the app has its own user auth (`"app"`) OR the endpoint is intentionally public (`"none"`). Default is `"required"` so accidental omission fails closed. **Convention**: when using `"app"` or `"none"`, add a comment line above the `auth = "..."` line stating what gates the app or why it's public.
- **Anti-AI**: on by default when `auth = "none"` or `auth = "app"` (no Authentik to discourage bots); redundant on `"required"` and `"public"`.
- **DNS**: `dns_type = "proxied"` (Cloudflare CDN) or `"non-proxied"` (direct A/AAAA). DNS records are auto-created — no need to edit `config.tfvars`. Smoke-test target: `echo.viktorbarzin.me` (auth=public, header-reflecting backend).
- **Anubis PoW challenge** (`modules/kubernetes/anubis_instance/`): per-site reverse proxy that issues a 30-day JWT cookie after a tiny PoW solve. Use for **public, content-bearing sites without app-level auth** (blog, docs, wikis, static landing pages). Pattern: declare `module "anubis" { source = "../../modules/kubernetes/anubis_instance"; name = "X"; namespace = ...; target_url = "http://<backend>.<ns>.svc.cluster.local" }`, then in `ingress_factory` set `service_name = module.anubis.service_name`, `port = module.anubis.service_port`, `anti_ai_scraping = false`. Shared ed25519 key in Vault `secret/viktor` -> `anubis_ed25519_key`; cookie scoped to `viktorbarzin.me` so one solve covers all Anubis-fronted subdomains. **DO NOT put Anubis in front of Git/API/WebDAV/CLI endpoints** — clients without JS can't solve PoW. **Replicas default to 1** because Anubis stores in-flight challenges in process memory; a challenge issued by pod A and solved against pod B errors with `store: key not found` (HTTP 500). Bumping replicas requires wiring a shared Redis store (TODO). For path-level carve-outs (e.g. wrongmove has `/` behind Anubis but `/api` direct), declare a second `ingress_factory` with `ingress_path = ["/api"]` pointing at the bare backend service. Active on: blog, www, kms, travel, f1, cc, json, pb (privatebin), home (homepage), wrongmove (UI only). See `.claude/reference/patterns.md` "Anti-AI Scraping" for full layering.
- **Docker images**: Always build for `linux/amd64`. Use 8-char git SHA tags — `:latest` causes stale pull-through cache.
- **Private registry**: `forgejo.viktorbarzin.me/viktor/<name>` (Forgejo packages, OAuth-style PAT auth). Use `image: forgejo.viktorbarzin.me/viktor/<name>:<tag>` + `imagePullSecrets: [{name: registry-credentials}]`. Kyverno auto-syncs the Secret to all namespaces. Containerd `hosts.toml` on every node redirects to in-cluster Traefik LB `10.0.20.200` to avoid hairpin NAT. Push-side: viktor PAT in Vault `secret/ci/global/forgejo_push_token` (Forgejo container packages are scoped per-user; only the package owner can push, ci-pusher cannot write to viktor/*). Pull-side: cluster-puller PAT in Vault `secret/viktor/forgejo_pull_token`. Retention CronJob (`forgejo-cleanup` in `forgejo` ns, daily 04:00) keeps newest 10 versions + always `:latest`; integrity probed every 15min by `forgejo-integrity-probe` in `monitoring` ns (catalog walk + manifest HEAD on every blob). See `docs/plans/2026-05-07-forgejo-registry-consolidation-{design,plan}.md` for the migration history. Pull-through caches for upstream registries (DockerHub, GHCR, Quay, k8s.gcr, Kyverno) stay on the registry VM at `10.0.20.10` ports 5000/5010/5020/5030/5040 — the old port-5050 R/W private registry was decommissioned 2026-05-07.

View file

@ -35,23 +35,48 @@ variable "auth" {
type = string
default = "required"
description = <<-EOT
Authentik auth posture for this ingress:
* "required" (default): standard Authentik forward-auth login required.
Catches the legacy `protected = true` semantics.
* "public": public-tier auto-bind anonymous requests to the `guest`
Authentik user (no UI prompt), audited but not gated. Logged-in
users keep their real identity in X-authentik-username.
* "none": no Authentik forward-auth middleware at all. Use for
Anubis-fronted content sites, native-client APIs (Git, /v2/, WebDAV),
webhook receivers, and the Authentik outpost itself. Anti-AI
headers are auto-enabled when auth = "none" unless overridden.
Auth posture for this ingress. Pick by asking "what gates the app?":
Defaulting to "required" enforces "every ingress must have an explicit
auth decision recorded by Authentik" — accidental omission fails closed.
* "required" (default, fail-closed): Authentik forward-auth gates every
request. Pick this when the backend has NO built-in user auth and
Authentik is the only thing standing between strangers and the app.
Examples: prowlarr, qbittorrent, netbox, phpipam, k8s-dashboard, any
admin UI shipped without its own login.
* "app": the backend handles its own user authentication (NextAuth,
Django sessions, OAuth, bearer-token API, etc.) and Authentik would
only get in the way. No Authentik middleware is attached; the app's
own login is the gate. Examples: immich, linkwarden, tandoor,
freshrss, affine, actualbudget, audiobookshelf, novelapp.
**Functionally identical to "none"** the distinct name exists to
record intent at the call site so future readers don't have to guess.
* "public": Authentik anonymous binding via the `public` outpost.
Strangers are auto-bound to the `guest` Authentik user; logged-in
users keep their identity in X-authentik-username. Only works for
top-level browser navigation CORS preflight rejects XHR/fetch and
automation can't replay the cookie dance. Audit trail, not a gate.
* "none": no Authentik middleware, no own-auth claim explicitly
public or unauthenticated-by-design. Use for: Anubis-fronted content
sites (where Anubis is the gate), native-client APIs that auth
themselves (Git, /v2/, WebDAV/CalDAV, CardDAV), webhook receivers,
OAuth callbacks, and Authentik outposts themselves.
**Anti-exposure rule** (the reason "app" exists as a distinct mode):
only pick "app" or "none" AFTER you have verified the app has its own
user auth (for "app") OR the endpoint is intentionally public (for
"none"). Picking either of these on a naked admin UI exposes it to the
internet. The default is "required" specifically so accidental omission
fails closed.
**Convention**: when using "app" or "none", add a comment line above
the `auth = "..."` line stating what gates the app or why it's public.
Future-you reads the call site, not the module description.
EOT
validation {
condition = contains(["required", "public", "none"], var.auth)
error_message = "auth must be one of: required, public, none."
condition = contains(["required", "app", "public", "none"], var.auth)
error_message = "auth must be one of: required, app, public, none."
}
}
variable "ingress_path" {
@ -162,13 +187,17 @@ variable "homepage_enabled" {
locals {
effective_host = var.full_host != null ? var.full_host : "${var.host != null ? var.host : var.name}.${var.root_domain}"
# Anti-AI default: ON only when no Authentik auth is in front of the ingress
# (i.e. auth = "none" public Anubis-fronted content sites, etc.). When
# Authentik gates the request (required/public), the auth flow already
# discourages bots, so anti-AI noise is redundant.
effective_anti_ai = var.anti_ai_scraping != null ? var.anti_ai_scraping : (var.auth == "none")
# Anti-AI default: ON when no Authentik auth fronts the ingress (auth =
# "none" or auth = "app" either the app gates users itself or the site
# is intentionally public). When Authentik gates the request
# (required/public), the auth flow already discourages bots.
effective_anti_ai = var.anti_ai_scraping != null ? var.anti_ai_scraping : (var.auth == "none" || var.auth == "app")
# Auth middleware selection. "none" attaches no Authentik middleware at all.
# Auth middleware selection. "app" and "none" both attach no Authentik
# middleware "app" signals "the backend has its own user auth", "none"
# signals "intentionally public / native-client API / webhook". The
# distinction lives at the call site for human readers; the runtime
# effect is identical.
auth_middleware = (
var.auth == "required" ? "traefik-authentik-forward-auth@kubernetescrd" :
var.auth == "public" ? "traefik-authentik-forward-auth-public@kubernetescrd" :

View file

@ -156,10 +156,10 @@ resource "kubernetes_service" "actualbudget" {
module "ingress" {
source = "../../../modules/kubernetes/ingress_factory"
# auth = "none": Actual Budget enforces a server password + per-user login
# auth = "app": Actual Budget enforces a server password + per-user login
# on its own sync API. Authentik forward-auth was 302-ing the mobile/web
# sync clients; Actual's own auth gates users.
auth = "none"
auth = "app"
namespace = "actualbudget"
name = "budget-${var.name}"
tls_secret_name = var.tls_secret_name

View file

@ -359,10 +359,10 @@ resource "kubernetes_service" "affine" {
module "ingress" {
source = "../../modules/kubernetes/ingress_factory"
# auth = "none": AFFiNE has its own workspace auth + bearer-token API
# auth = "app": AFFiNE has its own workspace auth + bearer-token API
# used by desktop/mobile sync clients. Authentik forward-auth was 302-ing
# those API callers; AFFiNE's own auth gates users.
auth = "none"
auth = "app"
dns_type = "non-proxied"
namespace = kubernetes_namespace.affine.metadata[0].name
name = "affine"

View file

@ -662,10 +662,10 @@ resource "kubernetes_service" "audiobookshelf" {
module "audiobookshelf_ingress" {
source = "../../modules/kubernetes/ingress_factory"
# auth = "none": Audiobookshelf has its own user/password login + API
# auth = "app": Audiobookshelf has its own user/password login + API
# tokens used by the iOS/Android Audiobookshelf app. Authentik forward-auth
# was 302-ing the mobile clients; ABS's own auth gates users.
auth = "none"
auth = "app"
dns_type = "non-proxied"
namespace = kubernetes_namespace.ebooks.metadata[0].name
name = "audiobookshelf"

View file

@ -229,10 +229,10 @@ resource "kubernetes_service" "freshrss" {
}
module "ingress" {
source = "../../modules/kubernetes/ingress_factory"
# auth = "none": FreshRSS has built-in user login and exposes Fever +
# auth = "app": FreshRSS has built-in user login and exposes Fever +
# GReader APIs (/api/fever.php, /api/greader.php) used by mobile RSS
# readers like Reeder/FeedMe. Authentik forward-auth was 302-ing those.
auth = "none"
auth = "app"
dns_type = "proxied"
namespace = "freshrss"
name = "rss"

View file

@ -738,10 +738,10 @@ resource "kubernetes_service" "immich-machine-learning" {
module "ingress-immich" {
source = "../../modules/kubernetes/ingress_factory"
# auth = "none": Immich has its own user auth + bearer-token API. Authentik
# auth = "app": Immich has its own user auth + bearer-token API. Authentik
# forward-auth on `/api/*` was 302-ing the iOS/Android Immich app and any
# external API consumer. App-level auth is the gate now.
auth = "none"
auth = "app"
dns_type = "non-proxied"
namespace = kubernetes_namespace.immich.metadata[0].name
name = "immich"

View file

@ -229,10 +229,10 @@ resource "kubernetes_service" "linkwarden" {
module "ingress" {
source = "../../modules/kubernetes/ingress_factory"
# auth = "none": Linkwarden uses NextAuth (NEXTAUTH_SECRET/URL set above)
# auth = "app": Linkwarden uses NextAuth (NEXTAUTH_SECRET/URL set above)
# and exposes /api/* for its mobile clients. Authentik forward-auth would
# 302 those callers; app-level NextAuth gates users.
auth = "none"
auth = "app"
dns_type = "proxied"
namespace = kubernetes_namespace.linkwarden.metadata[0].name
name = "linkwarden"

View file

@ -224,11 +224,11 @@ resource "kubernetes_service" "novelapp" {
module "ingress" {
source = "../../modules/kubernetes/ingress_factory"
# auth = "none": novelapp handles its own auth via NextAuth + Google OAuth
# auth = "app": novelapp handles its own auth via NextAuth + Google OAuth
# (AUTH_URL/AUTH_SECRET/GOOGLE_CLIENT_{ID,SECRET} env vars above). Putting
# Authentik forward-auth in front double-gates the app and breaks iOS/Android
# webview clients that can't complete the Authentik 302/cookie dance.
auth = "none"
auth = "app"
dns_type = "non-proxied"
namespace = kubernetes_namespace.novelapp.metadata[0].name
name = "novelapp"

View file

@ -259,10 +259,10 @@ resource "kubernetes_service" "tandoor" {
module "ingress" {
source = "../../modules/kubernetes/ingress_factory"
# auth = "none": Tandoor uses Django auth (SECRET_KEY set above) and exposes
# auth = "app": Tandoor uses Django auth (SECRET_KEY set above) and exposes
# /api/* with token auth for its mobile clients. Authentik forward-auth was
# 302-ing those callers; Django session/token auth gates users.
auth = "none"
auth = "app"
dns_type = "proxied"
namespace = kubernetes_namespace.tandoor.metadata[0].name
name = "tandoor"