From a1dda10a04c15dc1d545bd959a9f91f29a2ad4fb Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Thu, 30 Apr 2026 17:59:53 -0700 Subject: [PATCH] fix(keycloak): use service account for config imports --- aws/keycloak/README.md | 4 +- .../templates/keycloak/lab-realm.json.tftpl | 26 ++++++++++ .../scripts/run-keycloak-config.sh.tftpl | 48 ++++++++++++------- aws/keycloak/tests/main.tftest.hcl | 15 ++++++ 4 files changed, 75 insertions(+), 18 deletions(-) diff --git a/aws/keycloak/README.md b/aws/keycloak/README.md index f641dd9..c8ad6d1 100644 --- a/aws/keycloak/README.md +++ b/aws/keycloak/README.md @@ -44,7 +44,9 @@ matches. When the hash changes, it fetches path, writes `/run/glab/keycloak/admin.env`, then runs pinned `keycloak-config-cli` to create the `lab` realm, local admin user, and touch-only WebAuthn policy. It also creates the public `incus` OIDC client with -OAuth 2.0 Device Authorization Grant enabled. The service writes the new +OAuth 2.0 Device Authorization Grant enabled and the confidential +`glab-keycloak-config` service account used for later imports after the +temporary master bootstrap admin is disabled. The service writes the new realm-config hash only after a successful import. The follow-up Incus-side OIDC values are: diff --git a/aws/keycloak/templates/keycloak/lab-realm.json.tftpl b/aws/keycloak/templates/keycloak/lab-realm.json.tftpl index 6c50a4c..f569e54 100644 --- a/aws/keycloak/templates/keycloak/lab-realm.json.tftpl +++ b/aws/keycloak/templates/keycloak/lab-realm.json.tftpl @@ -111,6 +111,22 @@ } ], "clients": [ + { + "clientId": "glab-keycloak-config", + "name": "glab Keycloak Config", + "description": "Confidential automation client for applying declarative lab realm configuration.", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "bearerOnly": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "authorizationServicesEnabled": false, + "frontchannelLogout": false, + "secret": "$(env:KEYCLOAK_CONFIG_CLIENT_SECRET)" + }, { "clientId": "incus", "name": "Incus", @@ -138,6 +154,16 @@ } ], "users": [ + { + "username": "service-account-glab-keycloak-config", + "enabled": true, + "serviceAccountClientId": "glab-keycloak-config", + "clientRoles": { + "realm-management": [ + "realm-admin" + ] + } + }, { "username": "$(env:KEYCLOAK_LOCAL_ADMIN_USERNAME)", "email": "$(env:KEYCLOAK_LOCAL_ADMIN_EMAIL)", diff --git a/aws/keycloak/templates/scripts/run-keycloak-config.sh.tftpl b/aws/keycloak/templates/scripts/run-keycloak-config.sh.tftpl index acfc68e..b098eaa 100644 --- a/aws/keycloak/templates/scripts/run-keycloak-config.sh.tftpl +++ b/aws/keycloak/templates/scripts/run-keycloak-config.sh.tftpl @@ -33,6 +33,7 @@ fi --aws-region '${aws_region}' \ --broker-function '${github_token_broker_function_name}' chmod 0600 '${config_output_path}' +. '${config_output_path}' deadline=$(( $(date +%s) + 300 )) until /usr/bin/curl -fsS http://127.0.0.1:9000/health/ready >/dev/null; do @@ -43,23 +44,36 @@ until /usr/bin/curl -fsS http://127.0.0.1:9000/health/ready >/dev/null; do sleep 5 done -/usr/bin/docker rm -f keycloak-config-cli >/dev/null 2>&1 || true -/usr/bin/docker run --rm \ - --name keycloak-config-cli \ - --network keycloak \ - --user 0:0 \ - --env-file '${config_output_path}' \ - -e KEYCLOAK_URL='http://keycloak:8080' \ - -e KEYCLOAK_USER="$KC_BOOTSTRAP_ADMIN_USERNAME" \ - -e KEYCLOAK_PASSWORD="$KC_BOOTSTRAP_ADMIN_PASSWORD" \ - -e KEYCLOAK_LOGINREALM='master' \ - -e KEYCLOAK_AVAILABILITYCHECK_ENABLED='true' \ - -e KEYCLOAK_AVAILABILITYCHECK_TIMEOUT='120s' \ - -e IMPORT_FILES_LOCATIONS='/config/lab-realm.json' \ - -e IMPORT_VARSUBSTITUTION_ENABLED='true' \ - -e IMPORT_VARSUBSTITUTION_UNDEFINEDISERROR='true' \ - -v '${keycloak_realm_config_path}:/config/lab-realm.json:ro' \ - '${keycloak_config_cli_image}' +run_import() { + /usr/bin/docker rm -f keycloak-config-cli >/dev/null 2>&1 || true + /usr/bin/docker run --rm \ + --name keycloak-config-cli \ + --network keycloak \ + --user 0:0 \ + --env-file '${config_output_path}' \ + "$@" \ + -e KEYCLOAK_URL='http://keycloak:8080' \ + -e KEYCLOAK_AVAILABILITYCHECK_ENABLED='true' \ + -e KEYCLOAK_AVAILABILITYCHECK_TIMEOUT='120s' \ + -e IMPORT_FILES_LOCATIONS='/config/lab-realm.json' \ + -e IMPORT_VARSUBSTITUTION_ENABLED='true' \ + -e IMPORT_VARSUBSTITUTION_UNDEFINEDISERROR='true' \ + -v '${keycloak_realm_config_path}:/config/lab-realm.json:ro' \ + '${keycloak_config_cli_image}' +} + +if [ -f '${keycloak_config_marker_path}' ]; then + run_import \ + -e KEYCLOAK_LOGINREALM='lab' \ + -e KEYCLOAK_CLIENTID='glab-keycloak-config' \ + -e KEYCLOAK_CLIENTSECRET="$KEYCLOAK_CONFIG_CLIENT_SECRET" \ + -e KEYCLOAK_GRANTTYPE='client_credentials' +else + run_import \ + -e KEYCLOAK_LOGINREALM='master' \ + -e KEYCLOAK_USER="$KC_BOOTSTRAP_ADMIN_USERNAME" \ + -e KEYCLOAK_PASSWORD="$KC_BOOTSTRAP_ADMIN_PASSWORD" +fi printf '%s\n' "$desired_hash" >'${keycloak_config_marker_path}' chmod 0600 '${keycloak_config_marker_path}' diff --git a/aws/keycloak/tests/main.tftest.hcl b/aws/keycloak/tests/main.tftest.hcl index bfd2c6c..d4acc7a 100644 --- a/aws/keycloak/tests/main.tftest.hcl +++ b/aws/keycloak/tests/main.tftest.hcl @@ -141,6 +141,11 @@ run "plan_defaults" { error_message = "The config script should use a rendered realm hash marker instead of a permanent first-import marker." } + assert { + condition = strcontains(local.run_config_script, "KEYCLOAK_LOGINREALM='lab'") && strcontains(local.run_config_script, "KEYCLOAK_CLIENTID='glab-keycloak-config'") && strcontains(local.run_config_script, "KEYCLOAK_CLIENTSECRET=\"$KEYCLOAK_CONFIG_CLIENT_SECRET\"") && strcontains(local.run_config_script, "KEYCLOAK_GRANTTYPE='client_credentials'") + error_message = "The config script should use the dedicated config service account after the first successful hash-marked import." + } + assert { condition = !strcontains(local.config_unit, "ConditionPathExists") error_message = "The config unit should run on boot and let the config script decide whether the realm config changed." @@ -166,6 +171,16 @@ run "plan_defaults" { error_message = "The realm config should create the local admin from runtime env and require WebAuthn registration." } + assert { + condition = strcontains(local.lab_realm_config, "\"clientId\": \"glab-keycloak-config\"") && strcontains(local.lab_realm_config, "\"serviceAccountsEnabled\": true") && strcontains(local.lab_realm_config, "\"secret\": \"$(env:KEYCLOAK_CONFIG_CLIENT_SECRET)\"") + error_message = "The realm config should define a confidential service account client for future config imports." + } + + assert { + condition = strcontains(local.lab_realm_config, "\"username\": \"service-account-glab-keycloak-config\"") && strcontains(local.lab_realm_config, "\"serviceAccountClientId\": \"glab-keycloak-config\"") && strcontains(local.lab_realm_config, "\"realm-admin\"") + error_message = "The config service account should receive realm-admin privileges through realm-management." + } + assert { condition = strcontains(local.lab_realm_config, "\"clientId\": \"incus\"") && strcontains(local.lab_realm_config, "\"publicClient\": true") && strcontains(local.lab_realm_config, "\"oauth2.device.authorization.grant.enabled\": \"true\"") error_message = "The realm config should define a public Incus OIDC client with device authorization enabled."