diff --git a/hack/kubernetes-oidc-bindings.bats b/hack/kubernetes-oidc-bindings.bats new file mode 100644 index 000000000..f37273802 --- /dev/null +++ b/hack/kubernetes-oidc-bindings.bats @@ -0,0 +1,187 @@ +#!/usr/bin/env bats +# ----------------------------------------------------------------------------- +# packages/apps/kubernetes: the OIDC bootstrap Job must not carry the users[] +# list inside its container command. +# +# The Job's script reaches the container as one `sh -c` argument, and Linux caps +# one exec argument at 128 KiB (MAX_ARG_STRLEN). With a ClusterRoleBinding per +# user written into that script, about 240 users take it past the cap: the +# container cannot start, the post-install/post-upgrade hook fails, and the +# tenant cluster's HelmRelease stays unready. The bindings reach the Job through +# a mounted ConfigMap instead, so the script is the same size for any list. +# +# The last three tests execute the rendered script against a fake kubectl, since +# no assertion on the script's text says what the prune step does with the list: +# a condition forced either way passes every helm-unittest case. +# +# Requires: helm, yq (mikefarah v4+), jq, like hack/admin-kubeconfig-invariant.bats. +# ----------------------------------------------------------------------------- + +load test_helper + +USERS=300 + +# render_users renders the chart in System mode with +# generated users and leaves one JSON document per object in /rendered.json. +render_users() { + jq -n --argjson n "$2" ' + {oidc: {users: [range($n) | {email: "user\(.)@example.org", role: (if . % 7 == 0 then "admin" else "view" end)}]}}' \ + > "$1/users.yaml" + helm template bindings packages/apps/kubernetes \ + --namespace tenant-test \ + --values packages/apps/kubernetes/tests/values/oidc-system.yaml \ + --values "$1/users.yaml" \ + > "$1/rendered.yaml" + yq --output-format=json eval-all '.' "$1/rendered.yaml" > "$1/rendered.json" +} + +# render_job renders the chart in CustomConfig mode and unpacks +# what the bootstrap Job runs and mounts into : script.sh is its `sh -c` +# script, bindings/ holds the two files of the mounted ConfigMap. -j keeps an +# empty value an empty file, as the kubelet writes it. +render_job() { + jq -n --argjson users "$2" ' + {_cluster: {"oidc-enabled": "false"}, + oidc: {mode: "CustomConfig", + customConfig: {config: "apiVersion: apiserver.config.k8s.io/v1beta1\nkind: AuthenticationConfiguration\njwt: []\n"}, + users: $users}}' > "$1/values.yaml" + helm template bindings packages/apps/kubernetes \ + --namespace tenant-test \ + --values packages/apps/kubernetes/tests/values/oidc-system.yaml \ + --values "$1/values.yaml" \ + > "$1/rendered.yaml" + yq --output-format=json eval-all '.' "$1/rendered.yaml" > "$1/rendered.json" + jq -s -j '.[] | select(.kind == "Job" and .metadata.name == "bindings-oidc-bootstrap") + | .spec.template.spec.containers[0].command[2]' "$1/rendered.json" > "$1/script.sh" + mkdir -p "$1/bindings" + jq -s -j '.[] | select(.kind == "ConfigMap" and .metadata.name == "bindings-oidc-bindings") + | .data["crbs.yaml"]' "$1/rendered.json" > "$1/bindings/crbs.yaml" + jq -s -j '.[] | select(.kind == "ConfigMap" and .metadata.name == "bindings-oidc-bindings") + | .data["desired.txt"]' "$1/rendered.json" > "$1/bindings/desired.txt" +} + +# fake_kubectl puts a kubectl first on PATH that logs every call and +# answers the script's list of existing bindings with . +fake_kubectl() { + mkdir -p "$1/bin" + printf '%s' "$2" > "$1/existing" + cat > "$1/bin/kubectl" <<'KEOF' +#!/bin/sh +echo "$*" >> "$KLOG" +case "$*" in + *"get clusterrolebindings"*) cat "$EXISTING" ;; +esac +KEOF + chmod +x "$1/bin/kubectl" +} + +# run_job runs the unpacked script as the container does and leaves its +# status in $rc and its output in /out. Run by bash, which has the pipefail +# the script sets and which is what the unit lane runs under; the container's own +# shell is not exercised. +run_job() { + : > "$1/kubectl.log" + echo kubeconfig > "$1/kubeconfig" + rc=0 + env PATH="$1/bin:$PATH" KLOG="$1/kubectl.log" EXISTING="$1/existing" HOME="$1" \ + KUBECONFIG_TENANT="$1/kubeconfig" RELEASE=bindings RELEASE_NAMESPACE=tenant-test \ + OIDC_MODE=CustomConfig OIDC_BINDINGS_DIR="$1/bindings" \ + bash "$1/script.sh" > "$1/out" 2>&1 || rc=$? +} + +@test "the OIDC bootstrap Job script stays under the exec argument cap and carries no user" { + dir=$(mktemp -d) + render_users "$dir" "$USERS" + jq -s -r '.[] | select(.kind == "Job" and .metadata.name == "bindings-oidc-bootstrap") + | .spec.template.spec.containers[0].command[2]' "$dir/rendered.json" > "$dir/script" + size=$(wc -c < "$dir/script") + [ "$size" -gt 0 ] + [ "$size" -lt 131072 ] + if grep -q -E 'user[0-9]+@example\.org' "$dir/script"; then + echo "the Job script still carries users[] entries" >&2 + false + fi + rm -rf "$dir" +} + +@test "every user keeps one ClusterRoleBinding with its old name and role in the bindings ConfigMap" { + dir=$(mktemp -d) + render_users "$dir" "$USERS" + jq -s -r '.[] | select(.kind == "ConfigMap" and .metadata.name == "bindings-oidc-bindings") + | .data["crbs.yaml"]' "$dir/rendered.json" > "$dir/crbs.yaml" + jq -s -r '.[] | select(.kind == "ConfigMap" and .metadata.name == "bindings-oidc-bindings") + | .data["desired.txt"]' "$dir/rendered.json" > "$dir/desired.txt" + [ -s "$dir/crbs.yaml" ] + yq --output-format=json eval-all '.' "$dir/crbs.yaml" | jq -s '.' > "$dir/crbs.json" + + [ "$(jq 'length' "$dir/crbs.json")" -eq "$USERS" ] + [ "$(jq -r '[.[].subjects[0].name] | unique | length' "$dir/crbs.json")" -eq "$USERS" ] + [ "$(jq -r '[.[] | select(.roleRef.name == "cluster-admin")] | length' "$dir/crbs.json")" -eq $(( (USERS + 6) / 7 )) ] + + # The name is what the prune step and every binding already in a tenant cluster + # go by, so it must not move: cozystack-oidc- plus the first 16 hex digits of + # sha256("-"). + for i in 0 1 6 7; do + email="user$i@example.org" + name="cozystack-oidc-$(printf '%s' "bindings-$email" | sha256sum | cut -c1-16)" + [ "$(jq -r --arg e "$email" '.[] | select(.subjects[0].name == $e) | .metadata.name' "$dir/crbs.json")" = "$name" ] + grep -q -x -F "$name" "$dir/desired.txt" + done + + # The list the prune step compares against is exactly the applied names. + [ "$(jq -r '.[].metadata.name' "$dir/crbs.json" | sort)" = "$(grep . "$dir/desired.txt" | sort)" ] + rm -rf "$dir" +} + +@test "the Job applies the mounted bindings and deletes only the bindings no user has" { + dir=$(mktemp -d) + render_job "$dir" '[{"email":"alice@example.org","role":"admin"},{"email":"bob@example.org","role":"view"}]' + alice="cozystack-oidc-$(printf '%s' 'bindings-alice@example.org' | sha256sum | cut -c1-16)" + bob="cozystack-oidc-$(printf '%s' 'bindings-bob@example.org' | sha256sum | cut -c1-16)" + fake_kubectl "$dir" "$alice cozystack-oidc-000000000000dead $bob cozystack-oidc-000000000000beef" + run_job "$dir" + if [ "$rc" -ne 0 ]; then + cat "$dir/out" + false + fi + grep -q -F -- "apply --filename $dir/bindings/crbs.yaml" "$dir/kubectl.log" + grep -q -F -- "delete clusterrolebinding cozystack-oidc-000000000000dead --ignore-not-found" "$dir/kubectl.log" + grep -q -F -- "delete clusterrolebinding cozystack-oidc-000000000000beef --ignore-not-found" "$dir/kubectl.log" + if grep -F -e "delete clusterrolebinding $alice" -e "delete clusterrolebinding $bob" "$dir/kubectl.log"; then + echo "the Job deleted a binding a user still has" >&2 + false + fi + [ "$(grep -c 'delete clusterrolebinding' "$dir/kubectl.log")" -eq 2 ] + rm -rf "$dir" +} + +@test "a release with no users applies nothing and deletes every binding it left behind" { + dir=$(mktemp -d) + render_job "$dir" '[]' + fake_kubectl "$dir" "cozystack-oidc-000000000000dead cozystack-oidc-000000000000beef" + run_job "$dir" + if [ "$rc" -ne 0 ]; then + cat "$dir/out" + false + fi + if grep -q -F -- ' apply ' "$dir/kubectl.log"; then + echo "the Job ran kubectl apply with no bindings to apply" >&2 + false + fi + [ "$(grep -c 'delete clusterrolebinding' "$dir/kubectl.log")" -eq 2 ] + rm -rf "$dir" +} + +@test "the Job stops before deleting anything when the desired list is not mounted" { + dir=$(mktemp -d) + render_job "$dir" '[{"email":"alice@example.org","role":"admin"}]' + fake_kubectl "$dir" "cozystack-oidc-000000000000dead cozystack-oidc-000000000000beef" + rm "$dir/bindings/desired.txt" + run_job "$dir" + [ "$rc" -ne 0 ] + if grep -q -E 'delete clusterrolebinding| apply ' "$dir/kubectl.log"; then + cat "$dir/kubectl.log" + false + fi + rm -rf "$dir" +} diff --git a/packages/apps/kubernetes/templates/oidc-rbac-bindings.yaml b/packages/apps/kubernetes/templates/oidc-rbac-bindings.yaml new file mode 100644 index 000000000..a8517da4f --- /dev/null +++ b/packages/apps/kubernetes/templates/oidc-rbac-bindings.yaml @@ -0,0 +1,42 @@ +{{- /* + The ClusterRoleBindings the OIDC bootstrap Job (oidc-rbac-job.yaml) applies + inside the tenant cluster, one per `oidc.users` entry, and the list of their + names it prunes against. They travel in a ConfigMap the Job mounts and not as + text inside its script: the script reaches the container as a single `sh -c` + argument, Linux caps one argument at 128 KiB, and a binding per user crosses + that cap at about 240 users, after which the container cannot start. + + A release-owned object, not a hook resource, so it exists before the + post-install and post-upgrade hooks run. It is rendered only where users are + bound; at `mode: None` the Job takes its early branch and the volume is + optional. +*/}} +{{- if ne .Values.oidc.mode "None" }} +{{- $desired := "" }} +{{- $bindings := "" }} +{{- range .Values.oidc.users }} +{{- $name := printf "cozystack-oidc-%s" (printf "%s-%s" $.Release.Name .email | sha256sum | trunc 16) }} +{{- $role := "view" }} +{{- if eq .role "admin" }} +{{- $role = "cluster-admin" }} +{{- end }} +{{- $desired = printf "%s%s\n" $desired $name }} +{{- $binding := dict + "apiVersion" "rbac.authorization.k8s.io/v1" + "kind" "ClusterRoleBinding" + "metadata" (dict "name" $name "labels" (dict "app.kubernetes.io/managed-by" "cozystack-oidc" "app.kubernetes.io/instance" $.Release.Name)) + "roleRef" (dict "apiGroup" "rbac.authorization.k8s.io" "kind" "ClusterRole" "name" $role) + "subjects" (list (dict "kind" "User" "apiGroup" "rbac.authorization.k8s.io" "name" .email)) }} +{{- $bindings = printf "%s---\n%s\n" $bindings (toYaml $binding) }} +{{- end }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Release.Name }}-oidc-bindings + namespace: {{ .Release.Namespace }} + labels: + {{- include "kubernetes.labels" . | nindent 4 }} +data: + {{- dict "crbs.yaml" $bindings "desired.txt" $desired | toYaml | nindent 2 }} +{{- end }} diff --git a/packages/apps/kubernetes/templates/oidc-rbac-job.yaml b/packages/apps/kubernetes/templates/oidc-rbac-job.yaml index 8672ce124..8556d092b 100644 --- a/packages/apps/kubernetes/templates/oidc-rbac-job.yaml +++ b/packages/apps/kubernetes/templates/oidc-rbac-job.yaml @@ -25,7 +25,9 @@ - `mode: System | CustomConfig`: - Apply one ClusterRoleBinding per `users[]` entry inside the tenant cluster (`admin` → `cluster-admin`, `view` → `view`, subject = the - literal email matched against the `email` claim). + literal email matched against the `email` claim). The bindings are + rendered by oidc-rbac-bindings.yaml into a ConfigMap this Job mounts, + not into the script below. - Prune stale CRBs labelled by this release. - `mode: System`: patch the pre-rendered `-oidc-kubeconfig` Secret with a real kubeconfig wired to `kubectl oidc-login` @@ -168,6 +170,8 @@ spec: value: {{ $issuer | quote }} - name: OIDC_SERVER_URL value: {{ printf "https://%s:443" $externalHost | quote }} + - name: OIDC_BINDINGS_DIR + value: /etc/oidc-bindings resources: requests: cpu: 10m @@ -254,46 +258,32 @@ spec: sleep 5 done + # A missing list must stop the Job here: grep on a file that is not + # there exits 2, `! grep` is then true, and the prune below would + # delete every binding of the release. + BINDINGS=$OIDC_BINDINGS_DIR + if [ ! -f "$BINDINGS/desired.txt" ]; then + echo "$BINDINGS/desired.txt is not mounted (ConfigMap $RELEASE-oidc-bindings); refusing to prune against a list that is not there" >&2 + exit 1 + fi + echo "Applying ${OIDC_MODE} ClusterRoleBindings to the tenant cluster..." - DESIRED_FILE=$(mktemp) - {{- range .Values.oidc.users }} - {{- $hash := printf "%s-%s" $.Release.Name .email | sha256sum | trunc 16 }} - {{- $crbName := printf "cozystack-oidc-%s" $hash }} - {{- $role := "view" }} - {{- if eq .role "admin" }} - {{- $role = "cluster-admin" }} - {{- end }} - echo "{{ $crbName }}" >> "$DESIRED_FILE" - cat <<'EOF' | kubectl --kubeconfig "$KUBECONFIG_TENANT" apply --filename - - apiVersion: rbac.authorization.k8s.io/v1 - kind: ClusterRoleBinding - metadata: - name: {{ $crbName }} - labels: - app.kubernetes.io/managed-by: cozystack-oidc - app.kubernetes.io/instance: {{ $.Release.Name }} - roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: {{ $role }} - subjects: - - kind: User - apiGroup: rbac.authorization.k8s.io - name: {{ .email | quote }} - EOF - {{- end }} + # kubectl apply fails on a file with no objects, and a release with + # no users has none. + if [ -s "$BINDINGS/crbs.yaml" ]; then + kubectl --kubeconfig "$KUBECONFIG_TENANT" apply --filename "$BINDINGS/crbs.yaml" + fi echo "Pruning stale ClusterRoleBindings labelled by this release..." EXISTING=$(kubectl --kubeconfig "$KUBECONFIG_TENANT" get clusterrolebindings \ --selector=app.kubernetes.io/managed-by=cozystack-oidc,app.kubernetes.io/instance=$RELEASE \ --output=jsonpath='{.items[*].metadata.name}') for crb in $EXISTING; do - if ! grep -x -q -F "$crb" "$DESIRED_FILE"; then + if ! grep -x -q -F "$crb" "$BINDINGS/desired.txt"; then echo " removing $crb" kubectl --kubeconfig "$KUBECONFIG_TENANT" delete clusterrolebinding "$crb" --ignore-not-found fi done - rm -f "$DESIRED_FILE" if [ "$OIDC_MODE" = "System" ]; then echo "Patching the OIDC kubeconfig Secret on the management cluster..." @@ -349,6 +339,9 @@ spec: - name: kubeconfig mountPath: /etc/kubernetes/kubeconfig readOnly: true + - name: oidc-bindings + mountPath: /etc/oidc-bindings + readOnly: true - name: tmp mountPath: /tmp volumes: @@ -356,6 +349,10 @@ spec: secret: secretName: {{ .Release.Name }}-admin-kubeconfig optional: true + - name: oidc-bindings + configMap: + name: {{ .Release.Name }}-oidc-bindings + optional: true - name: tmp emptyDir: {} {{- /* diff --git a/packages/apps/kubernetes/tests/oidc_test.yaml b/packages/apps/kubernetes/tests/oidc_test.yaml index 0623063e7..8511e392c 100644 --- a/packages/apps/kubernetes/tests/oidc_test.yaml +++ b/packages/apps/kubernetes/tests/oidc_test.yaml @@ -2561,7 +2561,73 @@ tests: value: "true" documentIndex: 3 - - it: bootstrap Job command builds a CRB for each users[] entry + - it: the bindings ConfigMap holds a CRB for each users[] entry + release: + name: kubernetes-app + namespace: tenant-foo + template: templates/oidc-rbac-bindings.yaml + set: + _cluster.oidc-enabled: "true" + _cluster.root-host: example.org + oidc.mode: System + oidc.users: + - email: alice@example.org + role: admin + - email: bob@example.org + role: view + asserts: + - isKind: + of: ConfigMap + - equal: + path: metadata.name + value: kubernetes-app-oidc-bindings + - matchRegex: + path: data["crbs.yaml"] + pattern: 'name: alice@example.org' + - matchRegex: + path: data["crbs.yaml"] + pattern: 'name: bob@example.org' + - matchRegex: + path: data["crbs.yaml"] + pattern: 'app.kubernetes.io/managed-by: cozystack-oidc' + - matchRegex: + path: data["crbs.yaml"] + pattern: 'app.kubernetes.io/instance: kubernetes-app' + + - it: the binding names and roles stay what the Job script used to render + # The prune step and every binding already in a tenant cluster go by these + # names: cozystack-oidc- plus the first 16 hex digits of + # sha256("-"). A different name leaves the old binding + # behind next to the new one. + release: + name: kubernetes-app + namespace: tenant-foo + template: templates/oidc-rbac-bindings.yaml + set: + _cluster.oidc-enabled: "true" + _cluster.root-host: example.org + oidc.mode: System + oidc.users: + - email: alice@example.org + role: admin + - email: bob@example.org + role: view + asserts: + - equal: + path: data["desired.txt"] + value: "cozystack-oidc-0001a28136ca1a02\ncozystack-oidc-ec8dfb1cbc75e699\n" + - matchRegex: + path: data["crbs.yaml"] + pattern: 'name: cozystack-oidc-0001a28136ca1a02\nroleRef:\n apiGroup: rbac.authorization.k8s.io\n kind: ClusterRole\n name: cluster-admin\nsubjects:\n- apiGroup: rbac.authorization.k8s.io\n kind: User\n name: alice@example.org\n' + - matchRegex: + path: data["crbs.yaml"] + pattern: 'name: cozystack-oidc-ec8dfb1cbc75e699\nroleRef:\n apiGroup: rbac.authorization.k8s.io\n kind: ClusterRole\n name: view\nsubjects:\n- apiGroup: rbac.authorization.k8s.io\n kind: User\n name: bob@example.org\n' + + - it: the bootstrap Job mounts the bindings ConfigMap and carries no users[] entry in its script + # The script is one `sh -c` argument and Linux caps one argument at 128 KiB: + # a binding per user in it stops the container from starting at about 240 + # users. hack/kubernetes-oidc-bindings.bats renders 300 users to hold the + # size itself. release: name: kubernetes-app namespace: tenant-foo @@ -2575,23 +2641,130 @@ tests: role: admin - email: bob@example.org role: view + asserts: + - contains: + path: spec.template.spec.volumes + content: + name: oidc-bindings + configMap: + name: kubernetes-app-oidc-bindings + optional: true + documentIndex: 3 + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: oidc-bindings + mountPath: /etc/oidc-bindings + readOnly: true + documentIndex: 3 + # The script reads the directory from the environment, which is also how + # hack/kubernetes-oidc-bindings.bats points it at a scratch directory. + - contains: + path: spec.template.spec.containers[0].env + content: + name: OIDC_BINDINGS_DIR + value: /etc/oidc-bindings + documentIndex: 3 + - notMatchRegex: + path: spec.template.spec.containers[0].command[2] + pattern: 'alice@example\.org' + documentIndex: 3 + - notMatchRegex: + path: spec.template.spec.containers[0].command[2] + pattern: 'bob@example\.org' + documentIndex: 3 + + - it: the bootstrap Job stops before pruning when the desired list is not mounted + # `! grep -F` on a file that is not there is true, so a missing mount would + # delete every binding of the release. + release: + name: kubernetes-app + namespace: tenant-foo + template: templates/oidc-rbac-job.yaml + set: + _cluster.oidc-enabled: "true" + _cluster.root-host: example.org + oidc.mode: System asserts: - matchRegex: path: spec.template.spec.containers[0].command[2] - pattern: 'name: "alice@example.org"' + pattern: '(?s)if \[ ! -f "\$BINDINGS/desired\.txt" \]; then.*?exit 1\n\s*fi.*Pruning stale ClusterRoleBindings' documentIndex: 3 + + - it: the bootstrap Job applies the bindings file only when it has objects in it + # kubectl apply exits non-zero on a file with no objects, and a release + # with no users has none. + release: + name: kubernetes-app + namespace: tenant-foo + template: templates/oidc-rbac-job.yaml + set: + _cluster.oidc-enabled: "true" + _cluster.root-host: example.org + oidc.mode: System + asserts: - matchRegex: path: spec.template.spec.containers[0].command[2] - pattern: 'name: "bob@example.org"' + pattern: 'if \[ -s "\$BINDINGS/crbs\.yaml" \]; then\n\s+kubectl --kubeconfig "\$KUBECONFIG_TENANT" apply --filename "\$BINDINGS/crbs\.yaml"\n\s*fi' documentIndex: 3 + + - it: a release with no users renders an empty bindings ConfigMap + release: + name: kubernetes-app + namespace: tenant-foo + template: templates/oidc-rbac-bindings.yaml + set: + _cluster.oidc-enabled: "true" + _cluster.root-host: example.org + oidc.mode: System + oidc.users: [] + asserts: + - equal: + path: data["crbs.yaml"] + value: "" + - equal: + path: data["desired.txt"] + value: "" + + - it: CustomConfig mode renders the bindings ConfigMap for its users + release: + name: kubernetes-app + namespace: tenant-foo + template: templates/oidc-rbac-bindings.yaml + set: + _cluster.oidc-enabled: "false" + oidc.mode: CustomConfig + oidc.customConfig.config: | + apiVersion: apiserver.config.k8s.io/v1beta1 + kind: AuthenticationConfiguration + jwt: + - issuer: + url: https://idp.example.com + audiences: [my-cluster] + oidc.users: + - email: alice@example.org + role: admin + asserts: + - isKind: + of: ConfigMap - matchRegex: - path: spec.template.spec.containers[0].command[2] - pattern: 'name: cluster-admin' - documentIndex: 3 - - matchRegex: - path: spec.template.spec.containers[0].command[2] - pattern: 'name: view' - documentIndex: 3 + path: data["crbs.yaml"] + pattern: 'name: alice@example.org' + + - it: mode=None renders no bindings ConfigMap even when users are set + # The Job takes its early branch at mode None and never reads the file. + release: + name: kubernetes-app + namespace: tenant-foo + template: templates/oidc-rbac-bindings.yaml + set: + oidc.mode: None + oidc.users: + - email: alice@example.org + role: admin + asserts: + - hasDocuments: + count: 0 - it: mode=System aborts the Job instead of leaving the OIDC kubeconfig Secret as the install-time placeholder when the tenant CA is missing # A placeholder kubeconfig cannot authenticate, so the release must not go Ready with it.