From 173b1fc116753ff5e5ef70c06cba0740f25d39b6 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Mon, 8 Jun 2026 17:47:00 +0000 Subject: [PATCH] =?UTF-8?q?workstation:=20per-user=20OIDC=20kubectl=20?= =?UTF-8?q?=E2=80=94=20power-user-readonly=20RBAC=20+=20kubeconfig=20(Phas?= =?UTF-8?q?e=202.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New oidc-power-user-readonly ClusterRole (cluster-wide get/list/watch, NO secrets/exec/write); the power-user binding re-pointed to it (the existing read+write+secrets oidc-power-user role is retained but UNBOUND per ADR-0005). Applied to the rbac stack (2 add, 1 change, 0 destroy). emo added to Vault k8s_users (secret/platform) as power-user, email emil.barzin@gmail.com — the OIDC email IS the Authentik username (verified live). Verified via impersonation: emo gets cluster-wide read, NO secrets/write/exec/delete; anca unchanged. Provisioner: install_user_kubeconfig writes a per-user OIDC kubeconfig (kubelogin/PKCE — the kubernetes Authentik client is public, no secret; server+CA copied from the admin kubeconfig) if-absent. Written for emo + ancamilea (0600). End-to-end login is interactive (browser OIDC); verified config validity + RBAC, not the live browser flow. Co-Authored-By: Claude Opus 4.8 --- scripts/t3-provision-users.sh | 58 ++++++++++++++++++++++++++++- stacks/rbac/modules/rbac/main.tf | 63 ++++++++++++++++++++++++++++++-- 2 files changed, 116 insertions(+), 5 deletions(-) diff --git a/scripts/t3-provision-users.sh b/scripts/t3-provision-users.sh index d51c1063..33edf3fd 100644 --- a/scripts/t3-provision-users.sh +++ b/scripts/t3-provision-users.sh @@ -20,6 +20,9 @@ MAP=/etc/ttyd-user-map DRY_RUN="${DRY_RUN:-0}" # Public infra repo for the locked clone (no auth; the monorepo has no remote). INFRA_REMOTE="${INFRA_REMOTE:-https://github.com/ViktorBarzin/infra.git}" +# Per-user OIDC kubeconfig (kubelogin/PKCE; cluster server+CA copied from the admin kubeconfig). +OIDC_ISSUER="${OIDC_ISSUER:-https://authentik.viktorbarzin.me/application/o/kubernetes/}" +ADMIN_KUBECONFIG="${ADMIN_KUBECONFIG:-/home/wizard/.kube/config}" log() { echo "[t3-provision] $*"; } run() { if [[ "$DRY_RUN" == 1 ]]; then echo "[dry-run] $*"; else "$@"; fi; } @@ -42,6 +45,56 @@ install_locked_clone() { runuser -u "$user" -- git -C "$home/code" checkout --quiet master } +# Per-user OIDC kubeconfig (kubelogin/PKCE — the `kubernetes` Authentik client is +# public, no secret). Identical for all users: identity comes from each user's own +# interactive OIDC login, which the apiserver maps (email claim) to their RBAC. +# Cluster server + CA are copied from the admin kubeconfig. If-absent, never clobber. +install_user_kubeconfig() { + local user="$1" home kc server ca + home="$(getent passwd "$user" | cut -d: -f6)" + [[ -z "$home" ]] && return 0 + kc="$home/.kube/config" + [[ -f "$kc" ]] && return 0 + [[ -r "$ADMIN_KUBECONFIG" ]] || { log "WARN: $ADMIN_KUBECONFIG unreadable -> skip kubeconfig for $user"; return 0; } + if [[ "$DRY_RUN" == 1 ]]; then echo "[dry-run] OIDC kubeconfig -> $user:$kc"; return 0; fi + server="$(KUBECONFIG="$ADMIN_KUBECONFIG" kubectl config view --raw --minify -o jsonpath='{.clusters[0].cluster.server}')" + ca="$(KUBECONFIG="$ADMIN_KUBECONFIG" kubectl config view --raw --minify -o jsonpath='{.clusters[0].cluster.certificate-authority-data}')" + [[ -n "$server" && -n "$ca" ]] || { log "WARN: could not read cluster server/CA -> skip kubeconfig for $user"; return 0; } + install -d -o "$user" -g "$user" -m 0700 "$home/.kube" + cat > "$kc" < $user:~/.kube/config" +} + [[ $EUID -eq 0 ]] || { echo "t3-provision-users: must run as root" >&2; exit 1; } for bin in python3 jq; do command -v "$bin" >/dev/null || { echo "missing $bin" >&2; exit 1; }; done [[ -f "$ROSTER" && -f "$ENGINE" ]] || { echo "roster/engine not under $WORKSTATION_DIR" >&2; exit 1; } @@ -91,7 +144,10 @@ while IFS=$'\t' read -r os_user tier shell groups_csv; do log "add $os_user -> group $g"; run gpasswd -a "$os_user" "$g" >/dev/null done fi - [[ "$tier" != admin ]] && install_locked_clone "$os_user" # non-admins: writable locked ~/code + if [[ "$tier" != admin ]]; then # non-admins: locked ~/code clone + OIDC kubeconfig + install_locked_clone "$os_user" + install_user_kubeconfig "$os_user" + fi done < <(jq -r '.accounts[] | [.os_user, .tier, .shell, (.groups|join(","))] | @tsv' "$desired_file") # 5) per-user .env (sticky port) + enable t3-serve@ diff --git a/stacks/rbac/modules/rbac/main.tf b/stacks/rbac/modules/rbac/main.tf index 203be47b..e0ab29ec 100644 --- a/stacks/rbac/modules/rbac/main.tf +++ b/stacks/rbac/modules/rbac/main.tf @@ -40,8 +40,10 @@ resource "kubernetes_cluster_role_binding" "admin_users" { } } -# --- Power-user role --- -# Can manage workloads cluster-wide but cannot modify RBAC, nodes, or persistent volumes +# --- Power-user role (read+write+secrets) — RETAINED BUT UNBOUND --- +# Superseded by oidc-power-user-readonly (below) per ADR-0005: power-users are bound +# to the read-only role, NOT this one. Kept defined for reference/rollback; do NOT +# bind it without a deliberate decision (it grants cluster-wide write + secrets). resource "kubernetes_cluster_role" "power_user" { metadata { @@ -109,17 +111,70 @@ resource "kubernetes_cluster_role" "power_user" { } } +# --- Power-user READ-ONLY role (ADR-0005) --- +# Cluster-wide get/list/watch, explicitly NO secrets and NO pods/exec. This is the +# role power-users are actually bound to (workstation tier: "cluster-wide read, +# no Secrets"). Mirrors power_user's resource breadth minus writes/secrets/exec. +resource "kubernetes_cluster_role" "power_user_readonly" { + metadata { + name = "oidc-power-user-readonly" + } + + rule { + api_groups = [""] + resources = ["pods", "pods/log", "services", "endpoints", "configmaps", "persistentvolumeclaims", "events", "namespaces", "nodes"] + verbs = ["get", "list", "watch"] + } + + rule { + api_groups = ["apps"] + resources = ["deployments", "statefulsets", "daemonsets", "replicasets"] + verbs = ["get", "list", "watch"] + } + + rule { + api_groups = ["batch"] + resources = ["jobs", "cronjobs"] + verbs = ["get", "list", "watch"] + } + + rule { + api_groups = ["networking.k8s.io"] + resources = ["ingresses", "networkpolicies"] + verbs = ["get", "list", "watch"] + } + + rule { + api_groups = ["autoscaling"] + resources = ["horizontalpodautoscalers"] + verbs = ["get", "list", "watch"] + } + + rule { + api_groups = ["storage.k8s.io"] + resources = ["storageclasses"] + verbs = ["get", "list", "watch"] + } + + rule { + api_groups = ["rbac.authorization.k8s.io"] + resources = ["clusterroles", "clusterrolebindings", "roles", "rolebindings"] + verbs = ["get", "list", "watch"] + } +} + +# Power-users are bound to the READ-ONLY role above (NOT the read+write+secrets one). resource "kubernetes_cluster_role_binding" "power_users" { for_each = nonsensitive({ for name, user in var.k8s_users : name => user if user.role == "power-user" }) metadata { - name = "oidc-power-user-${each.key}" + name = "oidc-power-user-readonly-${each.key}" } role_ref { api_group = "rbac.authorization.k8s.io" kind = "ClusterRole" - name = kubernetes_cluster_role.power_user.metadata[0].name + name = kubernetes_cluster_role.power_user_readonly.metadata[0].name } subject {