fix: restore tree dropped by 6d224861; land stem95su gdrive-sync (10m) [ci skip]

6d224861 came from a --no-checkout worktree whose empty index made the
commit drop every file except two. This restores 05b50d2b's full tree and
correctly adds stacks/stem95su/gdrive-sync.tf + the service-catalog stem95su
entry. Forward-only (parent=6d224861, no force-push); [ci skip] since the
live infra was never applied from the broken commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-06-09 08:45:33 +00:00
parent 6d224861c4
commit fd0f4a0365
1166 changed files with 358546 additions and 0 deletions

View file

@ -0,0 +1,70 @@
# Catch-all forward-auth restriction: gate the admin-only hostnames to the
# "Home Server Admins" group. Bound to the "Domain wide catch all" application
# (binding stays UI-managed; only the expression is adopted here).
#
# Adopted into Terraform 2026-06-04 to add a carve-out: the Kubernetes Dashboard
# (k8s.viktorbarzin.me) ALSO admits the kubernetes-* RBAC groups, so
# namespace-owners (e.g. gheorghe) can reach the dashboard login page. The
# dashboard itself enforces per-namespace access via the pasted ServiceAccount
# token (stacks/rbac/modules/rbac/dashboard-sa.tf) this policy only controls
# who reaches the page. All other admin-only hosts remain Home-Server-Admins-only.
import {
to = authentik_policy_expression.admin_services_restriction
id = "07a11b85-8f37-4844-aebb-ac9c112ec87c"
}
resource "authentik_policy_expression" "admin_services_restriction" {
name = "admin-services-restriction"
expression = trimspace(<<-EOT
ADMIN_ONLY_HOSTS = {
"terminal.viktorbarzin.me",
"frigate.viktorbarzin.me",
"netbox.viktorbarzin.me",
"trading.viktorbarzin.me",
"speedtest.viktorbarzin.me",
"meshcentral.viktorbarzin.me",
"k8s.viktorbarzin.me",
"dashy.viktorbarzin.me",
"prowlarr.viktorbarzin.me",
"qbittorrent.viktorbarzin.me",
"listenarr.viktorbarzin.me",
"shlink.viktorbarzin.me",
"openclaw.viktorbarzin.me",
"openlobster.viktorbarzin.me",
"wealthfolio.viktorbarzin.me",
}
ADMIN_GROUP = "Home Server Admins"
# The K8s Dashboard additionally admits the Kubernetes RBAC groups. Access
# to the page is not the security boundary the pasted ServiceAccount token
# is (per-namespace admin + cluster read-only). See dashboard-sa.tf.
K8S_DASHBOARD_HOST = "k8s.viktorbarzin.me"
K8S_DASHBOARD_GROUPS = [
"Home Server Admins",
"kubernetes-admins",
"kubernetes-power-users",
"kubernetes-namespace-owners",
]
host = request.context.get("host", "")
# t3 Workstation edge gate: only members of "T3 Users" may reach t3.
# Placed BEFORE the ADMIN_ONLY_HOSTS early-return (t3 is intentionally not in
# that set it must not require Home-Server-Admins, just T3 Users membership).
if host == "t3.viktorbarzin.me":
return ak_is_group_member(request.user, name="T3 Users")
# Not an admin-only host: allow any authenticated user.
if host not in ADMIN_ONLY_HOSTS:
return True
# K8s Dashboard: allow admins OR any Kubernetes RBAC group.
if host == K8S_DASHBOARD_HOST:
return any(ak_is_group_member(request.user, name=g) for g in K8S_DASHBOARD_GROUPS)
# Every other admin-only host: Home Server Admins only.
return ak_is_group_member(request.user, name=ADMIN_GROUP)
EOT
)
}

View file

@ -0,0 +1,200 @@
# goauthentik/authentik Terraform provider.
#
# Adopted 2026-04-18 (Wave 6a of the state-drift consolidation plan) to bring
# the catch-all Proxy Provider previously managed only via the Authentik UI
# under Terraform management. API token lives in Vault
# `secret/authentik/tf_api_token` (token identifier `terraform-infra-stack`,
# intent API, user akadmin, no expiry). Required-providers declaration sits
# in the central terragrunt.hcl so every stack has it available; only this
# stack configures a provider block.
data "vault_kv_secret_v2" "authentik_tf" {
mount = "secret"
name = "authentik"
}
provider "authentik" {
url = "https://authentik.viktorbarzin.me"
token = data.vault_kv_secret_v2.authentik_tf.data["tf_api_token"]
}
data "authentik_flow" "default_authorization_implicit_consent" {
slug = "default-provider-authorization-implicit-consent"
}
data "authentik_flow" "default_provider_invalidation" {
slug = "default-provider-invalidation-flow"
}
# -----------------------------------------------------------------------------
# Catch-all Proxy Provider + Application.
#
# Created via the Authentik UI ~a year ago; adopted into Terraform 2026-04-18
# (Wave 6a). The proxy provider is consumed by the embedded outpost
# (uuid 0eecac07-97c7-443c-8925-05f2f4fe3e47) via an outpost-level binding
# that stays in the UI it's a single toggle with no drift risk.
# -----------------------------------------------------------------------------
resource "authentik_application" "catchall" {
name = "Domain wide catch all"
slug = "domain-wide-catch-all"
protocol_provider = authentik_provider_proxy.catchall.id
lifecycle {
ignore_changes = [meta_description, meta_launch_url, meta_icon, group, backchannel_providers, policy_engine_mode, open_in_new_tab]
}
}
resource "authentik_provider_proxy" "catchall" {
name = "Provider for Domain wide catch all"
mode = "forward_domain"
external_host = "https://authentik.viktorbarzin.me"
cookie_domain = "viktorbarzin.me"
# Flow UUIDs resolved dynamically so a flow re-creation (keeping the slug)
# doesn't require an HCL edit.
authorization_flow = data.authentik_flow.default_authorization_implicit_consent.id
invalidation_flow = data.authentik_flow.default_provider_invalidation.id
# Cookie / proxysession TTL. Drives `Max-Age` on `authentik_proxy_*`
# cookies and the `expires` column in `authentik_providers_proxy_proxysession`.
# See note on the embedded outpost below bumping this requires an outpost
# pod restart for the gorilla session store to rebind.
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]
}
}
# -----------------------------------------------------------------------------
# Embedded outpost record. Adopted into Terraform 2026-05-10 as part of the
# postgres-session-backend fix:
# - `managed` is set server-side to `goauthentik.io/outposts/embedded` so
# the outpost binary's `IsEmbedded()` check returns true it loads the
# PostgreSQL session backend (PR #16628). The Terraform provider does
# NOT expose `managed` in the schema, so the field is preserved across
# applies (TF only writes fields it knows about).
# - kubernetes_json_patches.deployment carries:
# * dshm 2Gi tmpfs (covers the 2026-04-18 ENOSPC class of issues)
# * resources requests/limits
# * `app.kubernetes.io/component=server` pod label so the K8s service
# selector lights up endpoints (works around goauthentik 2026.2.2
# service.py:52 selector mismatch on standalone embedded outposts).
# * AUTHENTIK_POSTGRESQL__{HOST,PORT,USER,PASSWORD,NAME} envFrom the
# shared `goauthentik` Secret so the postgres session backend has
# credentials to connect to the dbaas cluster.
# - kubernetes_json_patches.service replaces the controller-set selector
# (which incorrectly targets `app.kubernetes.io/name=authentik`, i.e.
# the goauthentik-server pods) with the outpost's own labels.
# -----------------------------------------------------------------------------
resource "authentik_outpost" "embedded" {
name = "authentik Embedded Outpost"
type = "proxy"
protocol_providers = [authentik_provider_proxy.catchall.id]
service_connection = "99e227a7-4562-4888-9660-4c27da678c50"
config = jsonencode({
log_level = "trace"
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/volumes"
value = [{ name = "dshm", emptyDir = { medium = "Memory", sizeLimit = "2Gi" } }]
},
{
op = "add"
path = "/spec/template/spec/containers/0/volumeMounts"
value = [{ name = "dshm", mountPath = "/dev/shm" }]
},
{
op = "add"
path = "/spec/template/spec/containers/0/resources"
value = { limits = { memory = "2560Mi" }, requests = { cpu = "100m", memory = "128Mi" } }
},
{
op = "add"
path = "/spec/template/metadata/labels/app.kubernetes.io~1component"
value = "server"
},
{
op = "add"
path = "/spec/template/spec/containers/0/env/-"
value = { name = "AUTHENTIK_POSTGRESQL__HOST", valueFrom = { secretKeyRef = { name = "goauthentik", key = "AUTHENTIK_POSTGRESQL__HOST" } } }
},
{
op = "add"
path = "/spec/template/spec/containers/0/env/-"
value = { name = "AUTHENTIK_POSTGRESQL__PORT", valueFrom = { secretKeyRef = { name = "goauthentik", key = "AUTHENTIK_POSTGRESQL__PORT" } } }
},
{
op = "add"
path = "/spec/template/spec/containers/0/env/-"
value = { name = "AUTHENTIK_POSTGRESQL__USER", valueFrom = { secretKeyRef = { name = "goauthentik", key = "AUTHENTIK_POSTGRESQL__USER" } } }
},
{
op = "add"
path = "/spec/template/spec/containers/0/env/-"
value = { name = "AUTHENTIK_POSTGRESQL__PASSWORD", valueFrom = { secretKeyRef = { name = "goauthentik", key = "AUTHENTIK_POSTGRESQL__PASSWORD" } } }
},
{
op = "add"
path = "/spec/template/spec/containers/0/env/-"
value = { name = "AUTHENTIK_POSTGRESQL__NAME", valueFrom = { secretKeyRef = { name = "goauthentik", key = "AUTHENTIK_POSTGRESQL__NAME" } } }
},
]
service = [
{
op = "replace"
path = "/spec/selector"
value = {
"app.kubernetes.io/managed-by" = "goauthentik.io"
"app.kubernetes.io/name" = "authentik-outpost-proxy"
"goauthentik.io/outpost-name" = "authentik-embedded-outpost"
"goauthentik.io/outpost-type" = "proxy"
"goauthentik.io/outpost-uuid" = "0eecac0797c7443c892505f2f4fe3e47"
}
},
]
}
})
}
# -----------------------------------------------------------------------------
# Default User Login stage bound to default-authentication-flow.
# Adopted into Terraform 2026-05-01 to set session_duration=weeks=4 so users
# stay logged in across browser restarts. There is no Brand.session_duration
# in authentik 2026.2.x UserLoginStage is the correct knob.
# -----------------------------------------------------------------------------
resource "authentik_stage_user_login" "default_login" {
name = "default-authentication-login"
session_duration = "weeks=4"
lifecycle {
# Pin only session_duration; everything else stays UI-managed so the
# plan doesn't churn unrelated knobs (e.g. remember_me_offset toggles).
ignore_changes = [
remember_me_offset,
terminate_other_sessions,
geoip_binding,
network_binding,
]
}
}

217
stacks/authentik/guest.tf Normal file
View file

@ -0,0 +1,217 @@
# =============================================================================
# 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"
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": Public outpost callback path for OAuth flow; protecting with forward-auth creates circular dependency.
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]
}

25
stacks/authentik/main.tf Normal file
View file

@ -0,0 +1,25 @@
# =============================================================================
# Authentik Stack Identity provider (SSO)
# =============================================================================
variable "tls_secret_name" { type = string }
variable "redis_host" { type = string }
data "vault_kv_secret_v2" "secrets" {
mount = "secret"
name = "platform"
}
locals {
homepage_credentials = jsondecode(data.vault_kv_secret_v2.secrets.data["homepage_credentials"])
}
module "authentik" {
source = "./modules/authentik"
tier = local.tiers.cluster
tls_secret_name = var.tls_secret_name
secret_key = data.vault_kv_secret_v2.secrets.data["authentik_secret_key"]
postgres_password = data.vault_kv_secret_v2.secrets.data["authentik_postgres_password"]
redis_host = var.redis_host
homepage_token = try(local.homepage_credentials["authentik"]["token"], "")
}

View file

@ -0,0 +1,113 @@
variable "tls_secret_name" {}
variable "secret_key" {}
variable "postgres_password" {}
variable "tier" { type = string }
variable "redis_host" { type = string }
variable "homepage_token" {
type = string
default = ""
sensitive = true
}
module "tls_secret" {
source = "../../../../modules/kubernetes/setup_tls_secret"
namespace = kubernetes_namespace.authentik.metadata[0].name
tls_secret_name = var.tls_secret_name
}
# The embedded outpost auto-creates an ingress expecting this secret name
module "tls_secret_outpost" {
source = "../../../../modules/kubernetes/setup_tls_secret"
namespace = kubernetes_namespace.authentik.metadata[0].name
tls_secret_name = "authentik-outpost-tls"
}
resource "kubernetes_namespace" "authentik" {
metadata {
name = "authentik"
labels = {
tier = var.tier
"resource-governance/custom-quota" = "true"
"keel.sh/enrolled" = "true"
}
}
lifecycle {
# KYVERNO_LIFECYCLE_V1: goldilocks-vpa-auto-mode ClusterPolicy stamps this label on every namespace
ignore_changes = [metadata[0].labels["goldilocks.fairwinds.com/vpa-update-mode"]]
}
}
resource "kubernetes_resource_quota" "authentik" {
metadata {
name = "authentik-quota"
namespace = kubernetes_namespace.authentik.metadata[0].name
}
spec {
hard = {
"requests.cpu" = "16"
"requests.memory" = "16Gi"
"limits.memory" = "96Gi"
pods = "50"
}
}
}
resource "helm_release" "authentik" {
namespace = kubernetes_namespace.authentik.metadata[0].name
create_namespace = true
name = "goauthentik"
repository = "https://charts.goauthentik.io/"
chart = "authentik"
# version = "2025.10.3"
# version = "2025.12.4"
version = "2026.2.2"
atomic = true
timeout = 6000
values = [templatefile("${path.module}/values.yaml", { postgres_password = var.postgres_password, secret_key = var.secret_key })]
}
module "ingress" {
source = "../../../../modules/kubernetes/ingress_factory"
# Authentik's own UI cannot be gated by Authentik forward-auth that
# creates a chicken-and-egg loop (users can't reach the login page).
# auth = "none": Authentik UI cannot be gated by Authentik forward-auth (chicken-and-egg loop prevents login).
auth = "none"
dns_type = "proxied"
namespace = kubernetes_namespace.authentik.metadata[0].name
name = "authentik"
service_name = "goauthentik-server"
tls_secret_name = var.tls_secret_name
anti_ai_scraping = false
extra_annotations = {
"gethomepage.dev/enabled" = "true"
"gethomepage.dev/name" = "Authentik"
"gethomepage.dev/description" = "Identity provider"
"gethomepage.dev/icon" = "authentik.png"
"gethomepage.dev/group" = "Identity & Security"
"gethomepage.dev/pod-selector" = ""
"gethomepage.dev/widget.type" = "authentik"
"gethomepage.dev/widget.url" = "http://goauthentik-server.authentik.svc.cluster.local"
"gethomepage.dev/widget.key" = var.homepage_token
}
}
module "ingress-outpost" {
source = "../../../../modules/kubernetes/ingress_factory"
# Authentik forward-auth outpost callback path protecting this with
# forward-auth would loop the outpost back onto itself.
# auth = "none": Authentik outpost callback path for forward-auth flow; protecting with forward-auth creates circular dependency.
auth = "none"
namespace = kubernetes_namespace.authentik.metadata[0].name
name = "authentik-outpost"
host = "authentik"
service_name = "ak-outpost-authentik-embedded-outpost"
port = 9000
ingress_path = ["/outpost.goauthentik.io"]
tls_secret_name = var.tls_secret_name
anti_ai_scraping = false
exclude_crowdsec = true
}

View file

@ -0,0 +1,14 @@
[databases]
authentik = host=postgresql.dbaas port=5432 dbname=authentik user=authentik password=${password}
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = session
max_client_conn = 200
default_pool_size = 20
reserve_pool_size = 5
reserve_pool_timeout = 5
ignore_startup_parameters = extra_float_digits

View file

@ -0,0 +1,207 @@
resource "kubernetes_config_map" "pgbouncer_config" {
metadata {
name = "pgbouncer-config"
namespace = "authentik"
}
data = {
"pgbouncer.ini" = templatefile("${path.module}/pgbouncer.ini", { password = var.postgres_password })
}
}
# --- 2 Secret for user credentials ---
resource "kubernetes_secret" "pgbouncer_auth" {
metadata {
name = "pgbouncer-auth"
namespace = "authentik"
}
data = {
"userlist.txt" = templatefile("${path.module}/userlist.txt", { password = var.postgres_password })
}
type = "Opaque"
}
# --- 3 Deployment ---
resource "kubernetes_deployment" "pgbouncer" {
metadata {
name = "pgbouncer"
namespace = "authentik"
labels = {
app = "pgbouncer"
tier = var.tier
}
}
spec {
replicas = 3
selector {
match_labels = {
app = "pgbouncer"
}
}
template {
metadata {
labels = {
app = "pgbouncer"
}
}
spec {
affinity {
pod_anti_affinity {
required_during_scheduling_ignored_during_execution {
label_selector {
match_expressions {
key = "component"
operator = "In"
values = ["server"]
}
}
topology_key = "kubernetes.io/hostname"
}
}
}
container {
name = "pgbouncer"
image = "edoburu/pgbouncer:latest"
# `:latest` tag keep `Always` so pod restarts pick up upstream
# updates. The previous `IfNotPresent` value was declared at module
# creation but the live cluster has reconciled to `Always` (likely
# via a Helm/operator default). Match reality to drop the drift.
image_pull_policy = "Always"
port {
container_port = 6432
}
resources {
requests = {
cpu = "50m"
memory = "128Mi"
}
limits = {
memory = "512Mi"
}
}
readiness_probe {
tcp_socket {
port = 6432
}
initial_delay_seconds = 5
period_seconds = 10
timeout_seconds = 3
failure_threshold = 3
}
liveness_probe {
tcp_socket {
port = 6432
}
initial_delay_seconds = 30
period_seconds = 30
timeout_seconds = 5
failure_threshold = 3
}
volume_mount {
name = "config"
mount_path = "/etc/pgbouncer/pgbouncer.ini"
sub_path = "pgbouncer.ini"
}
volume_mount {
name = "auth"
mount_path = "/etc/pgbouncer/userlist.txt"
sub_path = "userlist.txt"
}
env {
name = "DATABASES_AUTHENTIK"
value = "host=postgres port=5432 dbname=authentik user=authentik password=${var.postgres_password}"
}
}
volume {
name = "config"
config_map {
name = kubernetes_config_map.pgbouncer_config.metadata[0].name
}
}
volume {
name = "auth"
secret {
secret_name = kubernetes_secret.pgbouncer_auth.metadata[0].name
}
}
dns_config {
option {
name = "ndots"
value = "2"
}
}
}
}
}
depends_on = [kubernetes_secret.pgbouncer_auth]
lifecycle {
# KYVERNO_LIFECYCLE_V1: Kyverno admission webhook mutates dns_config with ndots=2
ignore_changes = [
spec[0].template[0].spec[0].dns_config, # KYVERNO_LIFECYCLE_V1
metadata[0].annotations["keel.sh/policy"],
metadata[0].annotations["keel.sh/trigger"],
metadata[0].annotations["keel.sh/pollSchedule"], # KYVERNO_LIFECYCLE_V2
metadata[0].annotations["keel.sh/match-tag"],
spec[0].template[0].spec[0].container[0].image, # KEEL_IGNORE_IMAGE Keel manages tag updates
metadata[0].annotations["kubernetes.io/change-cause"],
metadata[0].annotations["deployment.kubernetes.io/revision"],
spec[0].template[0].metadata[0].annotations["keel.sh/update-time"], # KEEL_LIFECYCLE_V1
]
}
}
# --- 3b PodDisruptionBudget ---
# Protects auth against simultaneous node drains. With 3 replicas and
# minAvailable=2, a single drain rolls cleanly; a simultaneous two-node
# outage is correctly blocked.
resource "kubernetes_pod_disruption_budget_v1" "pgbouncer" {
metadata {
name = "pgbouncer"
namespace = "authentik"
}
spec {
min_available = 2
selector {
match_labels = {
app = "pgbouncer"
}
}
}
}
# --- 4 Service ---
resource "kubernetes_service" "pgbouncer" {
metadata {
name = "pgbouncer"
namespace = "authentik"
}
spec {
selector = {
app = "pgbouncer"
}
port {
port = 6432
target_port = 6432
protocol = "TCP"
}
type = "ClusterIP"
}
}

View file

@ -0,0 +1 @@
"authentik" "${password}"

View file

@ -0,0 +1,113 @@
authentik:
log_level: warning
# log_level: trace
secret_key: ""
existingSecret:
secretName: "goauthentik"
# This sends anonymous usage-data, stack traces on errors and
# performance data to authentik.error-reporting.a7k.io, and is fully opt-in
error_reporting:
enabled: false
postgresql:
# host: postgresql.dbaas
host: pgbouncer.authentik
port: 6432
user: authentik
password: ""
# Persistent client-side connections (safe with PgBouncer session mode;
# must be < pgbouncer server_idle_timeout=600s). Cuts Django connection
# setup overhead off the ~70 sequential ORM ops per flow stage.
conn_max_age: 60
conn_health_checks: true
cache:
# Cache flow plans for 30m and policy evaluations for 15m. Authentik 2026.2
# moved cache storage from Redis to Postgres, so a TTL hit is still a
# SELECT — but a single indexed lookup beats re-evaluating PolicyBindings.
timeout_flows: 1800
timeout_policies: 900
web:
# Gunicorn: 3 workers × 4 threads per server pod (default 2×4).
# Pairs with the server memory bump to 2Gi (each worker preloads Django ~500Mi).
workers: 3
threads: 4
worker:
# Celery-equivalent worker threads per pod (default 2, renamed from
# AUTHENTIK_WORKER__CONCURRENCY in 2025.8).
threads: 4
server:
replicas: 3
# Anonymous Django sessions (no completed login: bots, healthcheckers,
# partial flows) expire in 2h. Default is days=1. Once login completes,
# UserLoginStage.session_duration takes over via request.session.set_expiry.
# Injected via server.env (not authentik.sessions.*) because we use
# authentik.existingSecret.secretName, which makes the chart skip
# rendering the AUTHENTIK_* secret — so the values block doesn't reach env.
env:
- name: AUTHENTIK_SESSIONS__UNAUTHENTICATED_AGE
value: "hours=2"
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 0
maxUnavailable: 1
resources:
requests:
cpu: 100m
memory: 1.5Gi
limits:
memory: 2Gi
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/component: server
ingress:
enabled: false
# hosts:
# - authentik.viktorbarzin.me
podAnnotations:
diun.enable: true
diun.include_tags: "^202[0-9].[0-9]+.*$" # no need to annotate the worker as it uses the same image
pdb:
enabled: true
minAvailable: 2
global:
addPrometheusAnnotations: true
worker:
# 2 replicas: workers handle background tasks (LDAP sync, email,
# certificate renewal) — no user-facing traffic, so 2-of-3 isn't
# needed for availability. Drop saves ~100m sustained CPU.
replicas: 2
# Same unauthenticated_age cap as server — both the server (Django session
# middleware) and worker (cleanup tasks) need to see the value.
env:
- name: AUTHENTIK_SESSIONS__UNAUTHENTICATED_AGE
value: "hours=2"
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 0
maxUnavailable: 1
resources:
requests:
cpu: 100m
memory: 1.5Gi
limits:
memory: 2Gi
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/component: worker
pdb:
enabled: true
maxUnavailable: 1
postgresql:
enabled: false

1
stacks/authentik/secrets Symbolic link
View file

@ -0,0 +1 @@
../../secrets

View file

@ -0,0 +1,33 @@
# "T3 Users" group gates the devvm Claude Code Workstation (t3.viktorbarzin.me)
# at the Authentik edge (the branch in admin-services-restriction.tf). The group
# is created WITH its members atomically so enabling the gate can never lock
# everyone (incl. wizard) out.
#
# emo / ancamilea / wizard are NOT Terraform-managed authentik_user resources in
# this stack, so they're looked up by username which in this Authentik instance
# IS the user's email (verified live 2026-06-08): vbarzin@gmail.com, etc.
#
# Membership is in HCL for now (matches the roster's 3 users). FUTURE: when the
# devvm provisioner reconciles T3 Users membership from roster.yaml via the
# Authentik API, drop the `users` arg here so TF owns only the group's existence.
data "authentik_user" "wizard" {
username = "vbarzin@gmail.com"
}
data "authentik_user" "emo" {
username = "emil.barzin@gmail.com"
}
data "authentik_user" "ancamilea" {
username = "ancaelena98@gmail.com"
}
resource "authentik_group" "t3_users" {
name = "T3 Users"
users = [
data.authentik_user.wizard.id,
data.authentik_user.emo.id,
data.authentik_user.ancamilea.id,
]
}

View file

@ -0,0 +1,8 @@
include "root" {
path = find_in_parent_folders()
}
dependency "infra" {
config_path = "../infra"
skip_outputs = true
}