workstation: per-user OIDC kubectl — power-user-readonly RBAC + kubeconfig (Phase 2.2)
All checks were successful
ci/woodpecker/push/default Pipeline was successful
ci/woodpecker/push/build-cli Pipeline was successful

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 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-06-08 17:47:00 +00:00
parent c611ecf84d
commit 173b1fc116
2 changed files with 116 additions and 5 deletions

View file

@ -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" <<EOF
apiVersion: v1
kind: Config
clusters:
- name: homelab
cluster:
server: $server
certificate-authority-data: $ca
contexts:
- name: oidc@homelab
context:
cluster: homelab
user: oidc
current-context: oidc@homelab
users:
- name: oidc
user:
exec:
apiVersion: client.authentication.k8s.io/v1beta1
command: kubectl
args:
- oidc-login
- get-token
- --oidc-issuer-url=$OIDC_ISSUER
- --oidc-client-id=kubernetes
- --oidc-extra-scope=email
- --oidc-extra-scope=profile
- --oidc-extra-scope=groups
interactiveMode: IfAvailable
EOF
chown "$user:$user" "$kc"; chmod 0600 "$kc"
log "wrote OIDC kubeconfig -> $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@

View file

@ -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 {