authentik: add public guest auto-login flow + dedicated outpost + traefik public middleware
Phase 1+2 of default-deny ingress plan. Adds the infrastructure for an `auth = "public"` ingress tier that auto-binds anonymous requests to a `guest` Authentik user (no UI prompt), so public sites are still recorded as authenticated by Authentik for audit purposes — but as `guest`, not by leaking the standard catchall flow. - guest user in `Public Guests` group (NOT `Allow Login Users`). - `public-auto-login` flow: stage_binding policy sets `pending_user = guest`, `evaluate_on_plan = false` + `re_evaluate_policies = true` so flow_plan is populated when the policy mutates it; `authentication = none` lets anonymous requests enter. - `Provider for Public` proxy provider (forward_domain, cookie_domain viktorbarzin.me) with `authentication_flow = public-auto-login`. - Dedicated `public` outpost: only the public provider bound, deployed as `ak-outpost-public` Deployment+Service in the `authentik` namespace by Authentik's K8s controller. - `public-auth.viktorbarzin.me` ingress exposes the public outpost's `/outpost.goauthentik.io/*` so OAuth callbacks land on it (the embedded outpost doesn't know about the public provider, so `authentik.viktorbarzin.me` callbacks would fail). - `authentik-forward-auth-public` traefik middleware points at the public outpost service (not via the auth-proxy nginx fallback). The plan's `?app=public` dispatch idea was tested and rejected — the embedded outpost dispatches purely by Host header, so a dedicated outpost was the only way to isolate the public flow without conflicts. No ingresses use the new middleware yet — Phase 3+4 (the ingress_factory `auth` variable refactor + audit pass) wires it up. This commit is additive and behaviour-neutral. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
1e4eac5386
commit
0e837b57b8
2 changed files with 256 additions and 1 deletions
214
stacks/authentik/guest.tf
Normal file
214
stacks/authentik/guest.tf
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
# =============================================================================
|
||||
# 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"
|
||||
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]
|
||||
}
|
||||
|
|
@ -26,7 +26,8 @@ resource "kubernetes_manifest" "middleware_rate_limit" {
|
|||
depends_on = [helm_release.traefik]
|
||||
}
|
||||
|
||||
# Authentik forward auth middleware
|
||||
# Authentik forward auth middleware (default — login required).
|
||||
# Used by ingress_factory `auth = "required"`.
|
||||
resource "kubernetes_manifest" "middleware_authentik_forward_auth" {
|
||||
manifest = {
|
||||
apiVersion = "traefik.io/v1alpha1"
|
||||
|
|
@ -54,6 +55,46 @@ resource "kubernetes_manifest" "middleware_authentik_forward_auth" {
|
|||
depends_on = [helm_release.traefik]
|
||||
}
|
||||
|
||||
# Authentik forward auth — public tier. Calls the dedicated public outpost
|
||||
# (`ak-outpost-public.authentik.svc`) where the `Public` proxy provider is the
|
||||
# only bound provider, so every request runs the `public-auto-login` flow and
|
||||
# auto-binds anonymous users to the `guest` user. Users with an existing
|
||||
# Authentik session keep their real identity in `X-authentik-username`.
|
||||
# Used by ingress_factory `auth = "public"`.
|
||||
#
|
||||
# This is intentionally a different upstream from the standard middleware
|
||||
# (which targets the embedded outpost via the auth-proxy nginx fallback). The
|
||||
# `?app=` query param is NOT a working dispatch knob in current Authentik —
|
||||
# the embedded outpost dispatches by Host header alone, and the catchall's
|
||||
# forward_domain mode already claims viktorbarzin.me, so the only way to
|
||||
# isolate the public flow is via a dedicated outpost.
|
||||
resource "kubernetes_manifest" "middleware_authentik_forward_auth_public" {
|
||||
manifest = {
|
||||
apiVersion = "traefik.io/v1alpha1"
|
||||
kind = "Middleware"
|
||||
metadata = {
|
||||
name = "authentik-forward-auth-public"
|
||||
namespace = kubernetes_namespace.traefik.metadata[0].name
|
||||
}
|
||||
spec = {
|
||||
forwardAuth = {
|
||||
address = "http://ak-outpost-public.authentik.svc.cluster.local:9000/outpost.goauthentik.io/auth/traefik"
|
||||
trustForwardHeader = true
|
||||
authResponseHeaders = [
|
||||
"X-authentik-username",
|
||||
"X-authentik-uid",
|
||||
"X-authentik-email",
|
||||
"X-authentik-name",
|
||||
"X-authentik-groups",
|
||||
"Set-Cookie",
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
depends_on = [helm_release.traefik]
|
||||
}
|
||||
|
||||
# IP allowlist for local-only access
|
||||
resource "kubernetes_manifest" "middleware_local_only" {
|
||||
manifest = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue