workstation: per-user OIDC kubectl — power-user-readonly RBAC + kubeconfig (Phase 2.2)
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:
parent
c611ecf84d
commit
173b1fc116
2 changed files with 116 additions and 5 deletions
|
|
@ -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@
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue