diff --git a/stacks/k8s-dashboard/.terraform.lock.hcl b/stacks/k8s-dashboard/.terraform.lock.hcl index e8910be1..4d09adde 100644 --- a/stacks/k8s-dashboard/.terraform.lock.hcl +++ b/stacks/k8s-dashboard/.terraform.lock.hcl @@ -24,6 +24,22 @@ provider "registry.terraform.io/cloudflare/cloudflare" { ] } +provider "registry.terraform.io/gavinbunney/kubectl" { + version = "1.19.0" + constraints = "~> 1.14" + hashes = [ + "h1:9QkxPjp0x5FZFfJbE+B7hBOoads9gmdfj9aYu5N4Sfc=", + ] +} + +provider "registry.terraform.io/goauthentik/authentik" { + version = "2024.12.1" + constraints = "~> 2024.10" + hashes = [ + "h1:roBMd+gi+TGgikH/bMzEI8JfvJiMAQWt+8FmokCrQIs=", + ] +} + provider "registry.terraform.io/hashicorp/helm" { version = "3.1.1" hashes = [ @@ -91,3 +107,11 @@ provider "registry.terraform.io/hashicorp/vault" { "zh:ff35fb1ab6add288f0f368981e56f780b50405accd1937131cba1137999c8d83", ] } + +provider "registry.terraform.io/telmate/proxmox" { + version = "3.0.2-rc07" + constraints = "3.0.2-rc07" + hashes = [ + "h1:zp5hpQJQ4t4zROSLqdltVpBO+Riy9VugtfFbpyTw1aM=", + ] +} diff --git a/stacks/k8s-dashboard/authentik.tf b/stacks/k8s-dashboard/authentik.tf new file mode 100644 index 00000000..f5558225 --- /dev/null +++ b/stacks/k8s-dashboard/authentik.tf @@ -0,0 +1,108 @@ +# ----------------------------------------------------------------------------- +# Authentik OIDC application for the Kubernetes Dashboard (via oauth2-proxy). +# +# Confidential client `k8s-dashboard`. A custom scope mapping emits +# aud = ["kubernetes","k8s-dashboard"] so BOTH the kube-apiserver +# (--oidc-client-id=kubernetes) and oauth2-proxy (client_id=k8s-dashboard) +# accept the id_token. The existing UI-managed `kubernetes` public client +# used by the kubelogin CLI is untouched. +# +# Provider token: Vault secret/authentik -> tf_api_token (same as +# stacks/authentik/authentik_provider.tf). +# ----------------------------------------------------------------------------- + +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 "vault_kv_secret_v2" "k8s_dashboard" { + mount = "secret" + name = "k8s-dashboard" +} + +data "authentik_flow" "default_authorization_implicit_consent" { + slug = "default-provider-authorization-implicit-consent" +} + +data "authentik_flow" "default_provider_invalidation" { + slug = "default-provider-invalidation-flow" +} + +# Default OIDC scope mappings. `profile` carries the `groups` claim in +# Authentik's default expression, which the apiserver reads via +# --oidc-groups-claim=groups. offline_access enables refresh tokens. +data "authentik_property_mapping_provider_scope" "defaults" { + managed_list = [ + "goauthentik.io/providers/oauth2/scope-openid", + "goauthentik.io/providers/oauth2/scope-email", + "goauthentik.io/providers/oauth2/scope-profile", + "goauthentik.io/providers/oauth2/scope-offline_access", + ] +} + +# Custom scope mapping that overrides the audience. It only fires when the +# client REQUESTS this scope, so oauth2-proxy must include +# `k8s-dashboard-audience` in its --scope (see oauth2_proxy.tf). +resource "authentik_property_mapping_provider_scope" "k8s_dashboard_aud" { + name = "k8s-dashboard audience" + scope_name = "k8s-dashboard-audience" + expression = "return {\"aud\": [\"kubernetes\", \"k8s-dashboard\"]}" +} + +resource "authentik_provider_oauth2" "k8s_dashboard" { + name = "k8s-dashboard" + client_id = data.vault_kv_secret_v2.k8s_dashboard.data["oauth2_proxy_client_id"] + client_secret = data.vault_kv_secret_v2.k8s_dashboard.data["oauth2_proxy_client_secret"] + client_type = "confidential" + + authorization_flow = data.authentik_flow.default_authorization_implicit_consent.id + invalidation_flow = data.authentik_flow.default_provider_invalidation.id + + allowed_redirect_uris = [ + { + matching_mode = "strict" + url = "https://k8s.viktorbarzin.me/oauth2/callback" + }, + ] + + access_token_validity = "hours=1" + refresh_token_validity = "days=30" + include_claims_in_id_token = true + + property_mappings = concat( + data.authentik_property_mapping_provider_scope.defaults.ids, + [authentik_property_mapping_provider_scope.k8s_dashboard_aud.id], + ) +} + +resource "authentik_application" "k8s_dashboard" { + name = "Kubernetes Dashboard" + slug = "k8s-dashboard" + protocol_provider = authentik_provider_oauth2.k8s_dashboard.id + meta_launch_url = "https://k8s.viktorbarzin.me" + policy_engine_mode = "any" +} + +# Restrict who can complete the OIDC flow to the K8s RBAC groups. +resource "authentik_policy_expression" "k8s_dashboard_groups" { + name = "k8s-dashboard-group-access" + expression = <<-EOT + return ( + ak_is_group_member(request.user, name="kubernetes-admins") + or ak_is_group_member(request.user, name="kubernetes-power-users") + or ak_is_group_member(request.user, name="kubernetes-namespace-owners") + ) + EOT +} + +resource "authentik_policy_binding" "k8s_dashboard_groups" { + target = authentik_application.k8s_dashboard.uuid + policy = authentik_policy_expression.k8s_dashboard_groups.id + order = 0 +} diff --git a/stacks/k8s-dashboard/providers.tf b/stacks/k8s-dashboard/providers.tf index b337a2e9..3d0bc2c6 100644 --- a/stacks/k8s-dashboard/providers.tf +++ b/stacks/k8s-dashboard/providers.tf @@ -9,6 +9,21 @@ terraform { source = "cloudflare/cloudflare" version = "~> 4" } + authentik = { + source = "goauthentik/authentik" + version = "~> 2024.10" + } + # kubectl (gavinbunney) — workaround for hashicorp/kubernetes + # `kubernetes_manifest` panics on Kyverno CRDs. See beads code-e2dp. + # Declared for all stacks but only used where opted-in. + kubectl = { + source = "gavinbunney/kubectl" + version = "~> 1.14" + } + proxmox = { + source = "telmate/proxmox" + version = "3.0.2-rc07" + } } } @@ -31,3 +46,8 @@ provider "vault" { address = "https://vault.viktorbarzin.me" skip_child_token = true } + +provider "kubectl" { + config_path = var.kube_config_path + load_config_file = true +}