From 4a1c4766b8ba198578590d6fd4c15bfcb58872f5 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Wed, 14 Jan 2026 01:07:47 -0300 Subject: [PATCH] feat: add harbor/vault oidc automation --- services/harbor/helmrelease.yaml | 23 +-- .../harbor-oidc-secret-ensure-job.yaml | 142 ++++++++++++++++++ services/keycloak/kustomization.yaml | 1 + services/vault/kustomization.yaml | 7 + services/vault/oidc-config-cronjob.yaml | 114 ++++++++++++++ .../vault/scripts/vault_oidc_configure.sh | 77 ++++++++++ 6 files changed, 349 insertions(+), 15 deletions(-) create mode 100644 services/keycloak/harbor-oidc-secret-ensure-job.yaml create mode 100644 services/vault/oidc-config-cronjob.yaml create mode 100644 services/vault/scripts/vault_oidc_configure.sh diff --git a/services/harbor/helmrelease.yaml b/services/harbor/helmrelease.yaml index 5b384d7..249a3f3 100644 --- a/services/harbor/helmrelease.yaml +++ b/services/harbor/helmrelease.yaml @@ -117,21 +117,14 @@ spec: existingSecret: harbor-core existingXsrfSecret: harbor-core existingXsrfSecretKey: CSRF_KEY - # OIDC config; client secret is stored out-of-band. - configureUserSettings: | - { - "auth_mode": "oidc_auth", - "oidc_name": "Keycloak", - "oidc_endpoint": "https://sso.bstein.dev/realms/atlas", - "oidc_client_id": "harbor", - "oidc_verify_cert": true, - "oidc_auto_onboard": true, - "oidc_scope": "openid,profile,email,groups", - "oidc_groups_claim": "groups", - "oidc_user_claim": "preferred_username", - "oidc_admin_group": "admin", - "oidc_logout": true - } + # OIDC config is injected via CONFIG_OVERWRITE_JSON from the harbor-oidc secret. + extraEnvVars: + - name: CONFIG_OVERWRITE_JSON + valueFrom: + secretKeyRef: + name: harbor-oidc + key: CONFIG_OVERWRITE_JSON + optional: true affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: diff --git a/services/keycloak/harbor-oidc-secret-ensure-job.yaml b/services/keycloak/harbor-oidc-secret-ensure-job.yaml new file mode 100644 index 0000000..974f01a --- /dev/null +++ b/services/keycloak/harbor-oidc-secret-ensure-job.yaml @@ -0,0 +1,142 @@ +# services/keycloak/harbor-oidc-secret-ensure-job.yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: harbor-oidc-secret-ensure-1 + namespace: sso +spec: + backoffLimit: 0 + ttlSecondsAfterFinished: 3600 + template: + spec: + serviceAccountName: mas-secrets-ensure + restartPolicy: Never + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: kubernetes.io/arch + operator: In + values: ["arm64"] + - key: node-role.kubernetes.io/worker + operator: Exists + containers: + - name: apply + image: alpine:3.20 + command: ["/bin/sh", "-c"] + args: + - | + set -euo pipefail + apk add --no-cache curl jq kubectl >/dev/null + + KC_URL="http://keycloak.sso.svc.cluster.local" + ACCESS_TOKEN="" + for attempt in 1 2 3 4 5; do + TOKEN_JSON="$(curl -sS -X POST "$KC_URL/realms/master/protocol/openid-connect/token" \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -d "grant_type=password" \ + -d "client_id=admin-cli" \ + -d "username=${KEYCLOAK_ADMIN}" \ + -d "password=${KEYCLOAK_ADMIN_PASSWORD}" || true)" + ACCESS_TOKEN="$(echo "$TOKEN_JSON" | jq -r '.access_token' 2>/dev/null || true)" + if [ -n "$ACCESS_TOKEN" ] && [ "$ACCESS_TOKEN" != "null" ]; then + break + fi + echo "Keycloak token request failed (attempt ${attempt})" >&2 + sleep $((attempt * 2)) + done + if [ -z "$ACCESS_TOKEN" ] || [ "$ACCESS_TOKEN" = "null" ]; then + echo "Failed to fetch Keycloak admin token" >&2 + exit 1 + fi + + CLIENT_QUERY="$(curl -sS -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + "$KC_URL/admin/realms/atlas/clients?clientId=harbor" || true)" + CLIENT_ID="$(echo "$CLIENT_QUERY" | jq -r '.[0].id' 2>/dev/null || true)" + + if [ -z "$CLIENT_ID" ] || [ "$CLIENT_ID" = "null" ]; then + create_payload='{"clientId":"harbor","enabled":true,"protocol":"openid-connect","publicClient":false,"standardFlowEnabled":true,"implicitFlowEnabled":false,"directAccessGrantsEnabled":false,"serviceAccountsEnabled":false,"redirectUris":["https://registry.bstein.dev/c/oidc/callback"],"webOrigins":["https://registry.bstein.dev"],"rootUrl":"https://registry.bstein.dev","baseUrl":"/"}' + status="$(curl -sS -o /dev/null -w "%{http_code}" -X POST \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + -H 'Content-Type: application/json' \ + -d "${create_payload}" \ + "$KC_URL/admin/realms/atlas/clients")" + if [ "$status" != "201" ] && [ "$status" != "204" ]; then + echo "Keycloak client create failed (status ${status})" >&2 + exit 1 + fi + CLIENT_QUERY="$(curl -sS -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + "$KC_URL/admin/realms/atlas/clients?clientId=harbor" || true)" + CLIENT_ID="$(echo "$CLIENT_QUERY" | jq -r '.[0].id' 2>/dev/null || true)" + fi + + if [ -z "$CLIENT_ID" ] || [ "$CLIENT_ID" = "null" ]; then + echo "Keycloak client harbor not found" >&2 + exit 1 + fi + + SCOPE_ID="$(curl -sS -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + "$KC_URL/admin/realms/atlas/client-scopes?search=groups" | jq -r '.[] | select(.name=="groups") | .id' 2>/dev/null | head -n1 || true)" + if [ -z "$SCOPE_ID" ] || [ "$SCOPE_ID" = "null" ]; then + echo "Keycloak client scope groups not found" >&2 + exit 1 + fi + + DEFAULT_SCOPES="$(curl -sS -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + "$KC_URL/admin/realms/atlas/clients/${CLIENT_ID}/default-client-scopes" || true)" + OPTIONAL_SCOPES="$(curl -sS -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + "$KC_URL/admin/realms/atlas/clients/${CLIENT_ID}/optional-client-scopes" || true)" + + if ! echo "$DEFAULT_SCOPES" | jq -e '.[] | select(.name=="groups")' >/dev/null 2>&1 \ + && ! echo "$OPTIONAL_SCOPES" | jq -e '.[] | select(.name=="groups")' >/dev/null 2>&1; then + status="$(curl -sS -o /dev/null -w "%{http_code}" -X PUT \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + "$KC_URL/admin/realms/atlas/clients/${CLIENT_ID}/optional-client-scopes/${SCOPE_ID}")" + if [ "$status" != "200" ] && [ "$status" != "201" ] && [ "$status" != "204" ]; then + status="$(curl -sS -o /dev/null -w "%{http_code}" -X POST \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + "$KC_URL/admin/realms/atlas/clients/${CLIENT_ID}/optional-client-scopes/${SCOPE_ID}")" + if [ "$status" != "200" ] && [ "$status" != "201" ] && [ "$status" != "204" ]; then + echo "Failed to attach groups client scope to harbor (status ${status})" >&2 + exit 1 + fi + fi + fi + + CLIENT_SECRET="$(curl -sS -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + "$KC_URL/admin/realms/atlas/clients/${CLIENT_ID}/client-secret" | jq -r '.value' 2>/dev/null || true)" + if [ -z "$CLIENT_SECRET" ] || [ "$CLIENT_SECRET" = "null" ]; then + echo "Keycloak client secret not found" >&2 + exit 1 + fi + + CONFIG_OVERWRITE_JSON="$(jq -nc \ + --arg auth_mode "oidc_auth" \ + --arg oidc_name "Keycloak" \ + --arg oidc_client_id "harbor" \ + --arg oidc_client_secret "${CLIENT_SECRET}" \ + --arg oidc_endpoint "https://sso.bstein.dev/realms/atlas" \ + --arg oidc_scope "openid,profile,email,groups" \ + --arg oidc_user_claim "preferred_username" \ + --arg oidc_groups_claim "groups" \ + --arg oidc_admin_group "admin" \ + --argjson oidc_auto_onboard true \ + --argjson oidc_verify_cert true \ + --argjson oidc_logout true \ + '{\n auth_mode: $auth_mode,\n oidc_name: $oidc_name,\n oidc_client_id: $oidc_client_id,\n oidc_client_secret: $oidc_client_secret,\n oidc_endpoint: $oidc_endpoint,\n oidc_scope: $oidc_scope,\n oidc_user_claim: $oidc_user_claim,\n oidc_groups_claim: $oidc_groups_claim,\n oidc_admin_group: $oidc_admin_group,\n oidc_auto_onboard: $oidc_auto_onboard,\n oidc_verify_cert: $oidc_verify_cert,\n oidc_logout: $oidc_logout\n }')" + + kubectl -n harbor create secret generic harbor-oidc \ + --from-literal=CONFIG_OVERWRITE_JSON="${CONFIG_OVERWRITE_JSON}" \ + --dry-run=client -o yaml | kubectl -n harbor apply -f - >/dev/null + env: + - name: KEYCLOAK_ADMIN + valueFrom: + secretKeyRef: + name: keycloak-admin + key: username + - name: KEYCLOAK_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: keycloak-admin + key: password diff --git a/services/keycloak/kustomization.yaml b/services/keycloak/kustomization.yaml index ddb4ab2..c334e5e 100644 --- a/services/keycloak/kustomization.yaml +++ b/services/keycloak/kustomization.yaml @@ -19,6 +19,7 @@ resources: - mas-secrets-ensure-job.yaml - synapse-oidc-secret-ensure-job.yaml - logs-oidc-secret-ensure-job.yaml + - harbor-oidc-secret-ensure-job.yaml - service.yaml - ingress.yaml generatorOptions: diff --git a/services/vault/kustomization.yaml b/services/vault/kustomization.yaml index b39fc48..1ab70bc 100644 --- a/services/vault/kustomization.yaml +++ b/services/vault/kustomization.yaml @@ -8,7 +8,14 @@ resources: - rbac.yaml - configmap.yaml - statefulset.yaml + - oidc-config-cronjob.yaml - service.yaml - ingress.yaml - certificate.yaml - serverstransport.yaml +generatorOptions: + disableNameSuffixHash: true +configMapGenerator: + - name: vault-oidc-config-script + files: + - vault_oidc_configure.sh=scripts/vault_oidc_configure.sh diff --git a/services/vault/oidc-config-cronjob.yaml b/services/vault/oidc-config-cronjob.yaml new file mode 100644 index 0000000..15131a8 --- /dev/null +++ b/services/vault/oidc-config-cronjob.yaml @@ -0,0 +1,114 @@ +# services/vault/oidc-config-cronjob.yaml +apiVersion: batch/v1 +kind: CronJob +metadata: + name: vault-oidc-config + namespace: vault +spec: + schedule: "*/15 * * * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 1 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + backoffLimit: 1 + template: + spec: + serviceAccountName: vault + restartPolicy: Never + nodeSelector: + kubernetes.io/arch: arm64 + node-role.kubernetes.io/worker: "true" + containers: + - name: configure-oidc + image: hashicorp/vault:1.17.6 + imagePullPolicy: IfNotPresent + command: + - bash + - /scripts/vault_oidc_configure.sh + env: + - name: VAULT_ADDR + value: http://vault.vault.svc.cluster.local:8200 + - name: VAULT_TOKEN + valueFrom: + secretKeyRef: + name: vault-oidc-admin-token + key: token + - name: VAULT_OIDC_DISCOVERY_URL + valueFrom: + secretKeyRef: + name: vault-oidc-config + key: discovery_url + - name: VAULT_OIDC_CLIENT_ID + valueFrom: + secretKeyRef: + name: vault-oidc-config + key: client_id + - name: VAULT_OIDC_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: vault-oidc-config + key: client_secret + - name: VAULT_OIDC_DEFAULT_ROLE + valueFrom: + secretKeyRef: + name: vault-oidc-config + key: default_role + optional: true + - name: VAULT_OIDC_SCOPES + valueFrom: + secretKeyRef: + name: vault-oidc-config + key: scopes + optional: true + - name: VAULT_OIDC_USER_CLAIM + valueFrom: + secretKeyRef: + name: vault-oidc-config + key: user_claim + optional: true + - name: VAULT_OIDC_GROUPS_CLAIM + valueFrom: + secretKeyRef: + name: vault-oidc-config + key: groups_claim + optional: true + - name: VAULT_OIDC_TOKEN_POLICIES + valueFrom: + secretKeyRef: + name: vault-oidc-config + key: token_policies + optional: true + - name: VAULT_OIDC_REDIRECT_URIS + valueFrom: + secretKeyRef: + name: vault-oidc-config + key: redirect_uris + optional: true + - name: VAULT_OIDC_BOUND_AUDIENCES + valueFrom: + secretKeyRef: + name: vault-oidc-config + key: bound_audiences + optional: true + - name: VAULT_OIDC_BOUND_CLAIMS + valueFrom: + secretKeyRef: + name: vault-oidc-config + key: bound_claims + optional: true + - name: VAULT_OIDC_BOUND_CLAIMS_TYPE + valueFrom: + secretKeyRef: + name: vault-oidc-config + key: bound_claims_type + optional: true + volumeMounts: + - name: oidc-config-script + mountPath: /scripts + readOnly: true + volumes: + - name: oidc-config-script + configMap: + name: vault-oidc-config-script + defaultMode: 0555 diff --git a/services/vault/scripts/vault_oidc_configure.sh b/services/vault/scripts/vault_oidc_configure.sh new file mode 100644 index 0000000..3cd4a2d --- /dev/null +++ b/services/vault/scripts/vault_oidc_configure.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +log() { echo "[vault-oidc] $*"; } + +status_json="$(vault status -format=json || true)" +if [[ -z "${status_json}" ]]; then + log "vault status failed; check VAULT_ADDR and VAULT_TOKEN" + exit 1 +fi + +if ! grep -q '"initialized":true' <<<"${status_json}"; then + log "vault not initialized; skipping" + exit 0 +fi + +if grep -q '"sealed":true' <<<"${status_json}"; then + log "vault sealed; skipping" + exit 0 +fi + +: "${VAULT_OIDC_DISCOVERY_URL:?set VAULT_OIDC_DISCOVERY_URL}" +: "${VAULT_OIDC_CLIENT_ID:?set VAULT_OIDC_CLIENT_ID}" +: "${VAULT_OIDC_CLIENT_SECRET:?set VAULT_OIDC_CLIENT_SECRET}" + +role="${VAULT_OIDC_DEFAULT_ROLE:-atlas}" +scopes="${VAULT_OIDC_SCOPES:-openid profile email groups}" +user_claim="${VAULT_OIDC_USER_CLAIM:-preferred_username}" +groups_claim="${VAULT_OIDC_GROUPS_CLAIM:-groups}" +token_policies="${VAULT_OIDC_TOKEN_POLICIES:-default}" +redirect_uris="${VAULT_OIDC_REDIRECT_URIS:-https://secret.bstein.dev/ui/vault/auth/oidc/oidc/callback}" +bound_audiences="${VAULT_OIDC_BOUND_AUDIENCES:-${VAULT_OIDC_CLIENT_ID}}" +bound_claims="${VAULT_OIDC_BOUND_CLAIMS:-}" +bound_claims_type="${VAULT_OIDC_BOUND_CLAIMS_TYPE:-}" + +if ! vault auth list -format=json | grep -q '"oidc/"'; then + log "enabling oidc auth method" + vault auth enable oidc +fi + +log "configuring oidc auth" +vault write auth/oidc/config \ + oidc_discovery_url="${VAULT_OIDC_DISCOVERY_URL}" \ + oidc_client_id="${VAULT_OIDC_CLIENT_ID}" \ + oidc_client_secret="${VAULT_OIDC_CLIENT_SECRET}" \ + default_role="${role}" + +vault auth tune -listing-visibility=unauth oidc >/dev/null + +role_args=( + "user_claim=${user_claim}" + "oidc_scopes=${scopes}" + "token_policies=${token_policies}" + "bound_audiences=${bound_audiences}" +) + +if [[ -n "${groups_claim}" ]]; then + role_args+=("groups_claim=${groups_claim}") +fi +if [[ -n "${bound_claims}" ]]; then + role_args+=("bound_claims=${bound_claims}") +fi +if [[ -n "${bound_claims_type}" ]]; then + role_args+=("bound_claims_type=${bound_claims_type}") +fi + +IFS=',' read -r -a redirect_items <<<"${redirect_uris}" +for uri in "${redirect_items[@]}"; do + trimmed="${uri#"${uri%%[![:space:]]*}"}" + trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" + if [[ -n "${trimmed}" ]]; then + role_args+=("allowed_redirect_uris=${trimmed}") + fi +done + +log "configuring oidc role ${role}" +vault write "auth/oidc/role/${role}" "${role_args[@]}"