feat(k8s-dashboard): auto-inject per-user SA token (no token-paste)

nginx token-injector behind the existing forward-auth: maps X-authentik-username
(the user's email, injected by Authentik) -> that user's ServiceAccount token ->
sets Authorization: Bearer -> kong-proxy. Dashboard auto-authenticates; users
never see the token prompt. Mirrors the t3-dispatch pattern. Token map lives in a
Secret (namespace-owners' cluster-read covers configmaps, not secrets). Verified:
gheorghe->vabbit81 pods 200 + kube-system 200 (cluster-read); viktor->nodes 200
(admin); unmapped->401. namespace-owners auto-derived from k8s_users; admins
hardcoded (their Authentik identity != k8s_users email).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-06-04 08:04:23 +00:00
parent 467eb7d7ee
commit d649f4f287
2 changed files with 195 additions and 5 deletions

View file

@ -0,0 +1,186 @@
# Dashboard token-injector: auto-inject each user's ServiceAccount token so they
# never see the dashboard's "paste token" prompt.
#
# Flow: ingress (auth=required Authentik forward-auth injects X-authentik-username
# = the user's email) THIS nginx maps username that user's SA token sets
# `Authorization: Bearer <token>` kong-proxy dashboard auto-authenticates with
# the token per-namespace RBAC applies.
#
# Why this and not OIDC SSO: the apiserver rejects all Authentik OIDC tokens (see
# docs/plans/2026-06-04-k8s-dashboard-sso-design.md §12); SA tokens DO work. Mirrors
# the proven t3-dispatch pattern (X-authentik-username per-user backend).
#
# SECURITY: the usernametoken map lives in a SECRET (not a ConfigMap) the
# namespace-owner cluster-read-only role covers configmaps but NOT secrets, so a
# namespace-owner cannot read other users' tokens. Forward-auth overwrites
# X-authentik-* (anti-spoofing), so a client can't forge another user's identity.
locals {
k8s_users_injector = jsondecode(data.vault_kv_secret_v2.cf_platform.data["k8s_users"])
# namespace-owner email -> their per-namespace dashboard SA token Secret
# (created by stacks/rbac/modules/rbac/dashboard-sa.tf). One namespace per
# owner today; uses the first namespace if several.
dashboard_owners = {
for name, u in local.k8s_users_injector :
u.email => {
namespace = u.namespaces[0]
secret = "dashboard-${name}-token"
}
if u.role == "namespace-owner" && length(try(u.namespaces, [])) > 0
}
# Admins (real Authentik usernames = email) cluster-admin dashboard SA token.
# Hardcoded: admin k8s_users emails (e.g. viktor@viktorbarzin.me) do NOT match
# the actual Authentik login identity, so they're listed explicitly here.
dashboard_admin_emails = ["vbarzin@gmail.com"]
}
# Long-lived token for the cluster-admin `kubernetes-dashboard` SA (for admins).
resource "kubernetes_secret" "dashboard_admin_token" {
metadata {
name = "kubernetes-dashboard-admin-token"
namespace = kubernetes_namespace.k8s-dashboard.metadata[0].name
annotations = {
"kubernetes.io/service-account.name" = kubernetes_service_account.kubernetes-dashboard.metadata[0].name
}
}
type = "kubernetes.io/service-account-token"
wait_for_service_account_token = true
}
# Read each namespace-owner's SA token (created by the rbac stack).
data "kubernetes_secret" "owner_token" {
for_each = nonsensitive(local.dashboard_owners)
metadata {
name = each.value.secret
namespace = each.value.namespace
}
}
locals {
injector_map_lines = concat(
[for email, info in local.dashboard_owners : " \"${email}\" \"${data.kubernetes_secret.owner_token[email].data["token"]}\";"],
[for email in local.dashboard_admin_emails : " \"${email}\" \"${kubernetes_secret.dashboard_admin_token.data["token"]}\";"],
)
injector_nginx_conf = <<-NGINX
map $http_upgrade $connection_upgrade { default upgrade; "" close; }
map $http_x_authentik_username $dash_sa_token {
default "";
${join("\n", local.injector_map_lines)}
}
map $dash_sa_token $dash_auth_hdr {
"" "";
default "Bearer $dash_sa_token";
}
server {
listen 8080;
client_max_body_size 50m;
location / {
proxy_pass https://kubernetes-dashboard-kong-proxy.kubernetes-dashboard.svc.cluster.local:443;
proxy_ssl_server_name on;
proxy_ssl_verify off;
# Inject the authenticated user's SA token; strip any client-supplied one.
proxy_set_header Authorization $dash_auth_hdr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
proxy_buffer_size 16k;
proxy_buffers 8 16k;
}
}
NGINX
}
resource "kubernetes_secret" "dashboard_injector_conf" {
metadata {
name = "dashboard-token-injector-conf"
namespace = kubernetes_namespace.k8s-dashboard.metadata[0].name
}
data = {
"default.conf" = local.injector_nginx_conf
}
}
resource "kubernetes_deployment" "dashboard_injector" {
metadata {
name = "dashboard-token-injector"
namespace = kubernetes_namespace.k8s-dashboard.metadata[0].name
labels = { app = "dashboard-token-injector" }
}
spec {
replicas = 2
selector { match_labels = { app = "dashboard-token-injector" } }
template {
metadata {
labels = { app = "dashboard-token-injector" }
# Roll the pods when the token map changes (hash is non-secret).
annotations = { "conf/sha" = nonsensitive(sha256(local.injector_nginx_conf)) }
}
spec {
container {
name = "nginx"
image = "nginxinc/nginx-unprivileged:1.27-alpine"
port { container_port = 8080 }
volume_mount {
name = "conf"
mount_path = "/etc/nginx/conf.d"
read_only = true
}
resources {
requests = { cpu = "10m", memory = "32Mi" }
limits = { memory = "96Mi" }
}
readiness_probe {
tcp_socket { port = 8080 }
initial_delay_seconds = 3
period_seconds = 10
}
}
volume {
name = "conf"
secret {
secret_name = kubernetes_secret.dashboard_injector_conf.metadata[0].name
}
}
dns_config {
option {
name = "ndots"
value = "2"
}
}
}
}
}
lifecycle {
ignore_changes = [
spec[0].template[0].spec[0].dns_config, # KYVERNO_LIFECYCLE_V1
]
}
}
resource "kubernetes_service" "dashboard_injector" {
metadata {
name = "dashboard-token-injector"
namespace = kubernetes_namespace.k8s-dashboard.metadata[0].name
}
spec {
selector = { app = "dashboard-token-injector" }
port {
port = 80
target_port = 8080
}
}
}

View file

@ -91,15 +91,19 @@ resource "helm_release" "kubernetes-dashboard" {
module "ingress" {
source = "../../modules/kubernetes/ingress_factory"
namespace = kubernetes_namespace.k8s-dashboard.metadata[0].name
name = "kubernetes-dashboard"
service_name = "kubernetes-dashboard-kong-proxy"
namespace = kubernetes_namespace.k8s-dashboard.metadata[0].name
name = "kubernetes-dashboard"
# Route through the token-injector: Authentik forward-auth (auth=required) gates
# access AND injects X-authentik-username; the injector maps that to the user's
# ServiceAccount token and sets Authorization: Bearer so the dashboard skips its
# token-paste login. See dashboard_injector.tf.
service_name = "dashboard-token-injector"
host = "k8s"
dns_type = "proxied"
tls_secret_name = var.tls_secret_name
auth = "required"
backend_protocol = "HTTPS"
port = 443
backend_protocol = "HTTP"
port = 80
extra_annotations = {
"gethomepage.dev/enabled" = "true"
"gethomepage.dev/name" = "Kubernetes Dashboard"