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:
parent
6d224861c4
commit
fd0f4a0365
1166 changed files with 358546 additions and 0 deletions
70
stacks/authentik/admin-services-restriction.tf
Normal file
70
stacks/authentik/admin-services-restriction.tf
Normal 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
|
||||
)
|
||||
}
|
||||
200
stacks/authentik/authentik_provider.tf
Normal file
200
stacks/authentik/authentik_provider.tf
Normal 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
217
stacks/authentik/guest.tf
Normal 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
25
stacks/authentik/main.tf
Normal 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"], "")
|
||||
}
|
||||
113
stacks/authentik/modules/authentik/main.tf
Normal file
113
stacks/authentik/modules/authentik/main.tf
Normal 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
|
||||
}
|
||||
14
stacks/authentik/modules/authentik/pgbouncer.ini
Normal file
14
stacks/authentik/modules/authentik/pgbouncer.ini
Normal 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
|
||||
207
stacks/authentik/modules/authentik/pgbouncer.tf
Normal file
207
stacks/authentik/modules/authentik/pgbouncer.tf
Normal 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"
|
||||
}
|
||||
}
|
||||
1
stacks/authentik/modules/authentik/userlist.txt
Normal file
1
stacks/authentik/modules/authentik/userlist.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
"authentik" "${password}"
|
||||
113
stacks/authentik/modules/authentik/values.yaml
Normal file
113
stacks/authentik/modules/authentik/values.yaml
Normal 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
1
stacks/authentik/secrets
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../secrets
|
||||
33
stacks/authentik/t3-users.tf
Normal file
33
stacks/authentik/t3-users.tf
Normal 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,
|
||||
]
|
||||
}
|
||||
8
stacks/authentik/terragrunt.hcl
Normal file
8
stacks/authentik/terragrunt.hcl
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
include "root" {
|
||||
path = find_in_parent_folders()
|
||||
}
|
||||
|
||||
dependency "infra" {
|
||||
config_path = "../infra"
|
||||
skip_outputs = true
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue