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:
parent
467eb7d7ee
commit
d649f4f287
2 changed files with 195 additions and 5 deletions
186
stacks/k8s-dashboard/dashboard_injector.tf
Normal file
186
stacks/k8s-dashboard/dashboard_injector.tf
Normal 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 username→token 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue