From 317989f9d5afc20aafc91596beb4253d4d2aa711 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Thu, 4 Jun 2026 07:33:14 +0000 Subject: [PATCH] feat(rbac): per-namespace-owner dashboard SA + long-lived token Pragmatic dashboard access while OIDC SSO is blocked: each namespace-owner (from k8s_users) gets a ServiceAccount scoped to admin on their namespace(s) + cluster read-only, plus a long-lived token to paste into the dashboard 'Token' login. Real per-namespace isolation, no apiserver-OIDC dependency. Verified: vabbit81 SA = admin in vabbit81, read-only elsewhere, no cross-ns write. Co-Authored-By: Claude Opus 4.8 --- stacks/rbac/modules/rbac/dashboard-sa.tf | 81 ++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 stacks/rbac/modules/rbac/dashboard-sa.tf diff --git a/stacks/rbac/modules/rbac/dashboard-sa.tf b/stacks/rbac/modules/rbac/dashboard-sa.tf new file mode 100644 index 00000000..5d6e9f8b --- /dev/null +++ b/stacks/rbac/modules/rbac/dashboard-sa.tf @@ -0,0 +1,81 @@ +# Per-namespace-owner ServiceAccount + long-lived token for Kubernetes Dashboard +# token-paste access. +# +# WHY: seamless OIDC SSO into the dashboard is blocked — the apiserver currently +# rejects all Authentik OIDC tokens (see docs/plans/2026-06-04-k8s-dashboard-sso-design.md +# §12). Until that's solved, each namespace-owner gets a ServiceAccount scoped to +# `admin` on their namespace(s) + cluster read-only, and a long-lived token they +# paste into the dashboard "Token" login. Real per-namespace isolation, no OIDC +# dependency. Rotate a token by deleting+recreating its `dashboard--token` +# Secret. Retrieve with: +# kubectl -n get secret dashboard--token -o jsonpath='{.data.token}' | base64 -d +# +# Driven by the same `local.namespace_owner_pairs` as the OIDC bindings, so every +# namespace-owner in k8s_users automatically gets one. + +resource "kubernetes_service_account" "dashboard_owner" { + for_each = nonsensitive({ for pair in local.namespace_owner_pairs : "${pair.user_key}-${pair.namespace}" => pair }) + + metadata { + name = "dashboard-${each.value.user_key}" + namespace = each.value.namespace + } +} + +# Full admin within the owner's namespace (same scope as their OIDC RoleBinding). +resource "kubernetes_role_binding" "dashboard_owner_admin" { + for_each = nonsensitive({ for pair in local.namespace_owner_pairs : "${pair.user_key}-${pair.namespace}" => pair }) + + metadata { + name = "dashboard-owner-${each.value.user_key}" + namespace = each.value.namespace + } + + role_ref { + api_group = "rbac.authorization.k8s.io" + kind = "ClusterRole" + name = "admin" + } + + subject { + kind = "ServiceAccount" + name = kubernetes_service_account.dashboard_owner[each.key].metadata[0].name + namespace = each.value.namespace + } +} + +# Cluster-wide read-only so the dashboard nav (namespaces, nodes, etc.) renders. +resource "kubernetes_cluster_role_binding" "dashboard_owner_readonly" { + for_each = nonsensitive({ for pair in local.namespace_owner_pairs : "${pair.user_key}-${pair.namespace}" => pair }) + + metadata { + name = "dashboard-readonly-${each.value.user_key}-${each.value.namespace}" + } + + role_ref { + api_group = "rbac.authorization.k8s.io" + kind = "ClusterRole" + name = kubernetes_cluster_role.namespace_owner_readonly.metadata[0].name + } + + subject { + kind = "ServiceAccount" + name = kubernetes_service_account.dashboard_owner[each.key].metadata[0].name + namespace = each.value.namespace + } +} + +# Long-lived (non-expiring) token the user pastes into the dashboard login. +resource "kubernetes_secret" "dashboard_owner_token" { + for_each = nonsensitive({ for pair in local.namespace_owner_pairs : "${pair.user_key}-${pair.namespace}" => pair }) + + metadata { + name = "dashboard-${each.value.user_key}-token" + namespace = each.value.namespace + annotations = { + "kubernetes.io/service-account.name" = kubernetes_service_account.dashboard_owner[each.key].metadata[0].name + } + } + type = "kubernetes.io/service-account-token" + wait_for_service_account_token = true +}