infra/stacks/authentik/guest.tf
Viktor Barzin ff5538a667 ingress_factory: replace protected bool with auth enum + audit pass across 100 stacks
Phase 3+4 of default-deny ingress plan. Replaces the `protected = bool` (default
false → unprotected) variable in `modules/kubernetes/ingress_factory` with
`auth = string` enum (default "required" → fail-closed). Touches every
ingress_factory caller so the audit decision is recorded explicitly in code.

ingress_factory (Phase 3):
- `auth = "required"`: standard Authentik forward-auth (the legacy
  `protected = true` semantic).
- `auth = "public"`: forward-auth via the new `authentik-forward-auth-public`
  middleware → dedicated public outpost → guest auto-bind. Logged-in users
  keep their real identity.
- `auth = "none"`: no Authentik middleware. For Anubis-fronted content, native
  client APIs (Git, /v2/, WebDAV), webhook receivers, the Authentik outpost
  itself.
- `effective_anti_ai` default flips ON only when `auth = "none"` (auth-gated
  ingresses don't need anti-AI noise; the auth flow already discourages bots).

Audit pass (Phase 4) across 96 ingress_factory call sites:
- 49 explicit `protected = true`     → `auth = "required"`
- 8 explicit `protected = false`     → `auth = "none"` (5) or `auth = "public"` (3)
- 64 previously-default (no protected line) → `auth = "required"` ADDED, then
  reviewed individually:
  * 9 Anubis-fronted (blog, www, kms, travel, f1, cyberchef, jsoncrack,
    homepage, wrongmove UI, privatebin) → `auth = "none"`
  * 22 native-client / programmatic surfaces (Forgejo Git+/v2/, webhook
    handler, claude-memory MCP, Nextcloud WebDAV, Matrix, Vault CLI/OIDC,
    xray VPN, ntfy, woodpecker webhooks, n8n triggers, ntfy push, dawarich
    location ingestion, immich frame kiosk, headscale CP, send anonymous
    drops, rybbit beacon, vaultwarden API, Authentik UI itself + outposts) →
    `auth = "none"`
  * Remaining ~33 → `auth = "required"` confirmed (admin tools, internal
    UIs, services without app-level auth)
- Smoke-test promotions to `auth = "public"`: fire-planner public UI,
  k8s-portal API, insta2spotify callback.

Three call sites in wrapper modules (`stacks/freedify/factory/`,
`stacks/reverse-proxy/modules/reverse_proxy/`) keep their internal `protected`
bool — they translate to `auth` internally, out of scope for this rename.

Behavior change: previously-default ingresses now fail closed (require
Authentik login) unless explicitly flipped to `auth = "none"` or
`auth = "public"`. This is the audit goal — no more accidentally-unprotected
surfaces. Sites that were intentionally public (Anubis content, native APIs,
webhooks) are now explicitly recorded as `auth = "none"`.

Drive-by: `modules/create-vm/main.tf` picked up cosmetic alignment via
`terraform fmt -recursive` during the audit. Behavior-neutral.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 14:16:42 +00:00

217 lines
9.7 KiB
HCL

# =============================================================================
# Public Guest user + auto-login flow + public proxy provider + dedicated
# outpost.
#
# Backs the `auth = "public"` tier of the ingress_factory module. Architecture:
#
# * `guest` user (in `Public Guests` group, NOT `Allow Login Users`).
# * `public-auto-login` flow: anonymous user enters → expression policy sets
# `pending_user = guest` → user_login stage logs them in. No UI shown.
# * `Provider for Public` proxy provider (forward_domain, cookie_domain
# `viktorbarzin.me`) with `authentication_flow = public-auto-login`.
# * Dedicated `Public Outpost` Deployment+Service (managed by Authentik's
# K8s controller). Bound to the public provider only — there is no other
# provider claiming `viktorbarzin.me` on this outpost, so every request
# it sees runs the public flow regardless of host.
# * `public-auth.viktorbarzin.me` ingress exposes the public outpost's
# `/outpost.goauthentik.io/*` path so OAuth callbacks land on it (the
# embedded outpost doesn't know about the public provider, so callbacks
# can't go to authentik.viktorbarzin.me).
#
# Traffic flow for a stranger hitting an `auth = "public"` ingress:
# 1. Traefik's `authentik-forward-auth-public` middleware → public outpost.
# 2. No session cookie → 302 to `https://authentik.viktorbarzin.me/...`
# with redirect_uri = `https://public-auth.viktorbarzin.me/.../callback`.
# 3. Authentik runs `public-auto-login` (no UI), issues session.
# 4. 302 → public-auth.viktorbarzin.me callback → public outpost validates
# state and sets `authentik_proxy_<public-hash>` cookie on `viktorbarzin.me`.
# 5. 302 → original URL → Traefik retries forward_auth → public outpost
# validates cookie → 200 with `X-authentik-username: guest`.
#
# A user already logged into anything else on viktorbarzin.me (the catchall)
# still gets recognised here — Authentik prefers an existing session and the
# public provider's authorization_flow auto-approves anyone, so their real
# username shows up in `X-authentik-username`. Strangers get `guest`.
# =============================================================================
resource "authentik_user" "guest" {
username = "guest"
name = "Guest"
path = "users/system"
is_active = true
type = "internal"
# No password set: the user_login stage in `public_auto_login` logs the
# request in via pending_user pre-set by an expression policy. There is no
# UI path for `guest` to authenticate via password — the user is also kept
# out of `Allow Login Users`, so even a leaked password cannot be used to
# complete the standard login flow.
lifecycle {
ignore_changes = [attributes, email]
}
}
resource "authentik_group" "public_guests" {
name = "Public Guests"
users = [authentik_user.guest.id]
# NOT a child of "Allow Login Users" — keeps a hypothetical leaked password
# from promoting `guest` to a real user via the standard login flow.
}
# Pre-stage policy: sets pending_user = guest before user_login stage runs.
# Mutates `request.context["flow_plan"].context["pending_user"]` — the
# canonical pattern (the user_login stage reads pending_user from
# `flow_plan.context`). Direct `request.context["pending_user"]` mutations
# don't propagate, since policy request.context is not the same dict as
# flow_plan.context.
resource "authentik_policy_expression" "set_guest_user" {
name = "set-public-guest-user"
execution_logging = true
expression = trimspace(<<-EOT
request.context["flow_plan"].context["pending_user"] = ak_user_by(username="guest")
return True
EOT
)
}
# Dedicated user_login stage for the public flow. 4-week session matches the
# default authentication stage; means a stranger only goes through the auto-
# bind once per ~month per device.
resource "authentik_stage_user_login" "public_guest_login" {
name = "public-guest-login"
session_duration = "weeks=4"
}
# `authentication = "none"` lets anonymous requests run the flow.
# `designation = "authentication"` because the flow's outcome is "request is
# now authenticated as guest"; the public proxy provider's authorization_flow
# then runs implicit consent.
resource "authentik_flow" "public_auto_login" {
name = "Public Auto Login"
slug = "public-auto-login"
title = "Public Guest Login"
designation = "authentication"
authentication = "none"
}
resource "authentik_flow_stage_binding" "public_login" {
target = authentik_flow.public_auto_login.uuid
stage = authentik_stage_user_login.public_guest_login.id
order = 10
# Re-evaluate at stage runtime: at plan time, flow_plan may not yet be in
# request.context, so the expression policy's mutation would no-op. With
# evaluate_on_plan=false + re_evaluate_policies=true, the policy fires
# right before the stage runs, when flow_plan is fully populated.
evaluate_on_plan = false
re_evaluate_policies = true
}
resource "authentik_policy_binding" "set_guest_before_login" {
target = authentik_flow_stage_binding.public_login.id
policy = authentik_policy_expression.set_guest_user.id
order = 0
}
# -----------------------------------------------------------------------------
# Public proxy provider — forward_domain so it claims any host on
# viktorbarzin.me. Used only on the dedicated `public` outpost (where it is
# the sole bound provider), so there's no dispatch ambiguity with the
# catchall (which lives on the embedded outpost).
# -----------------------------------------------------------------------------
resource "authentik_provider_proxy" "public" {
name = "Provider for Public"
mode = "forward_domain"
external_host = "https://public-auth.viktorbarzin.me"
cookie_domain = "viktorbarzin.me"
# When a request hits with NO Authentik session, this flow runs first and
# auto-binds the request to the `guest` user (no UI prompt).
authentication_flow = authentik_flow.public_auto_login.uuid
# Once authenticated (or already authenticated), implicit-consent auto-approves.
authorization_flow = data.authentik_flow.default_authorization_implicit_consent.id
invalidation_flow = data.authentik_flow.default_provider_invalidation.id
access_token_validity = "weeks=4"
lifecycle {
ignore_changes = [property_mappings, jwt_federation_sources, skip_path_regex, internal_host, basic_auth_enabled, basic_auth_password_attribute, basic_auth_username_attribute, intercept_header_auth]
}
}
resource "authentik_application" "public" {
name = "Public"
slug = "public"
protocol_provider = authentik_provider_proxy.public.id
# No bound policies. policy_engine_mode = "any" + zero bindings = everyone
# passes (the auto-login flow has already established `guest` as the user).
policy_engine_mode = "any"
lifecycle {
ignore_changes = [meta_description, meta_launch_url, meta_icon, group, backchannel_providers, open_in_new_tab]
}
}
# Dedicated outpost so the public provider can claim viktorbarzin.me without
# colliding with the catchall (which already claims viktorbarzin.me on the
# embedded outpost). Authentik's K8s controller deploys this as
# `ak-outpost-public` (Deployment + Service in the `authentik` namespace).
resource "authentik_outpost" "public" {
name = "public"
type = "proxy"
protocol_providers = [authentik_provider_proxy.public.id]
service_connection = "99e227a7-4562-4888-9660-4c27da678c50"
config = jsonencode({
log_level = "info"
docker_labels = null
authentik_host = "https://authentik.viktorbarzin.me/"
docker_network = null
container_image = null
docker_map_ports = true
refresh_interval = "minutes=5"
kubernetes_replicas = 1
kubernetes_namespace = "authentik"
authentik_host_browser = ""
object_naming_template = "ak-outpost-%(name)s"
authentik_host_insecure = false
kubernetes_service_type = "ClusterIP"
kubernetes_ingress_path_type = null
kubernetes_image_pull_secrets = []
kubernetes_ingress_class_name = null
kubernetes_disabled_components = []
kubernetes_ingress_annotations = {}
kubernetes_ingress_secret_name = "authentik-outpost-tls"
kubernetes_httproute_annotations = {}
kubernetes_httproute_parent_refs = []
kubernetes_json_patches = {
deployment = [
{
op = "add"
path = "/spec/template/spec/containers/0/resources"
value = { limits = { memory = "256Mi" }, requests = { cpu = "10m", memory = "64Mi" } }
},
]
}
})
}
# Ingress for `public-auth.viktorbarzin.me` — exposes the public outpost's
# /outpost.goauthentik.io/* path so OAuth callbacks land on it. The
# `Provider for Public` external_host points here, so all redirect_uris in
# the OAuth flow resolve to this hostname.
module "ingress_public_outpost" {
source = "../../modules/kubernetes/ingress_factory"
# Public-tier outpost callback — the OAuth flow's redirect_uris all resolve
# here; gating it with forward-auth would loop the public outpost onto itself.
auth = "none"
namespace = "authentik"
name = "public-outpost"
host = "public-auth"
service_name = "ak-outpost-public"
port = 9000
ingress_path = ["/outpost.goauthentik.io"]
tls_secret_name = var.tls_secret_name
dns_type = "proxied"
anti_ai_scraping = false
exclude_crowdsec = true
homepage_enabled = false
depends_on = [authentik_outpost.public]
}