From 816596f062f995c48c210176e6e1c767cb155d5b Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Thu, 30 Apr 2026 14:07:46 -0700 Subject: [PATCH 1/2] feat(keycloak): configure local WebAuthn admin --- aws/keycloak/README.md | 26 +++- aws/keycloak/locals.tf | 75 +++++++++- .../templates/keycloak/lab-realm.json.tftpl | 137 ++++++++++++++++++ .../scripts/disable-bootstrap-admin.sh.tftpl | 50 +++++++ .../scripts/run-keycloak-config.sh.tftpl | 62 ++++++++ .../glab-keycloak-config.service.tftpl | 12 ++ ...loak-disable-bootstrap-admin.service.tftpl | 9 ++ aws/keycloak/tests/main.tftest.hcl | 75 +++++++++- aws/keycloak/variables.tf | 44 ++++++ 9 files changed, 477 insertions(+), 13 deletions(-) create mode 100644 aws/keycloak/templates/keycloak/lab-realm.json.tftpl create mode 100644 aws/keycloak/templates/scripts/disable-bootstrap-admin.sh.tftpl create mode 100644 aws/keycloak/templates/scripts/run-keycloak-config.sh.tftpl create mode 100644 aws/keycloak/templates/systemd/glab-keycloak-config.service.tftpl create mode 100644 aws/keycloak/templates/systemd/glab-keycloak-disable-bootstrap-admin.service.tftpl diff --git a/aws/keycloak/README.md b/aws/keycloak/README.md index 9e8fdb7..d38ccd3 100644 --- a/aws/keycloak/README.md +++ b/aws/keycloak/README.md @@ -13,9 +13,9 @@ This stack creates: - a GitHub token broker Lambda, sourced from `meigma/github-token-broker`, for short-lived `GilmanLab/secrets` access - Keycloak instance-role permission to invoke the token broker - Keycloak instance-role permission to decrypt only SOPS secrets with `Repo=GilmanLab/secrets` and `Scope=keycloak` -- Ignition-managed systemd units for Postgres, Keycloak, Traefik, and bootstrap secret fetches +- Ignition-managed systemd units for Postgres, Keycloak, Traefik, bootstrap secret fetches, and first-boot realm configuration -This stack intentionally stops at starting Keycloak. It does **not** configure realms, GitHub OIDC, clients, backups, Synology sync, Kubernetes OIDC, Argo CD, Grafana, IAM Identity Center federation, or `keycloak-config-cli`. +This stack configures only the initial `lab` realm and one local admin account with password plus WebAuthn/YubiKey enrollment. It does **not** configure GitHub OIDC, service clients, backups, Synology sync, Kubernetes OIDC, Argo CD, Grafana, or IAM Identity Center federation. Traefik obtains the `id.glab.lol` certificate from Let's Encrypt through DNS-01. Cloudflare delegates `_acme-challenge.id.glab.lol` to the public Route 53 `acme.glab.lol` zone, and the Keycloak instance role may mutate only the delegated TXT record for this hostname. @@ -36,6 +36,24 @@ secrets get services/keycloak/bootstrap.sops.yaml \ Plaintext bootstrap material is written only under `/run/glab/keycloak`. The persistent data volume stores Postgres state in `/var/lib/keycloak/postgres` and ACME state in `/var/lib/keycloak/acme`; the Postgres directory is owned by the container's Postgres UID. +`glab-keycloak-config.service` runs once after Keycloak is healthy. It fetches +`services/keycloak/admin.sops.yaml` through the same broker-backed `labctl` +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. The service writes +`/var/lib/keycloak/config/lab-realm-imported` after a successful import so it +does not run again on reboot. + +After enrolling and validating the admin YubiKey in the browser, disable the +temporary master bootstrap admin manually: + +```sh +aws ssm send-command \ + --document-name AWS-RunShellScript \ + --targets Key=instanceids,Values="$(tofu output -raw instance_id)" \ + --parameters commands='["systemctl start glab-keycloak-disable-bootstrap-admin.service"]' +``` + ## Prerequisites - OpenTofu `>= 1.10` @@ -50,6 +68,7 @@ Plaintext bootstrap material is written only under `/run/glab/keycloak`. The per - `gh` and `sha256sum` on the apply host so the broker module can download and verify the pinned release asset - Cloudflare delegates `acme.glab.lol` to the `aws/lab-foundation` `acme_zone_name_servers` output and sets `_acme-challenge.id.glab.lol` as a CNAME to `_acme-challenge.id.acme.glab.lol` - `secrets/services/keycloak/bootstrap.sops.yaml` exists in `GilmanLab/secrets` with SOPS KMS context `Repo=GilmanLab/secrets` and `Scope=keycloak` +- `secrets/services/keycloak/admin.sops.yaml` exists in `GilmanLab/secrets` with SOPS KMS context `Repo=GilmanLab/secrets` and `Scope=keycloak` The expected local operator flow is to export both AWS values via `direnv`. @@ -96,9 +115,12 @@ aws ssm send-command \ --targets Key=instanceids,Values="$(tofu output -raw instance_id)" \ --parameters commands='[ "systemctl is-active glab-keycloak-bootstrap.service glab-keycloak-postgres.service glab-keycloak.service glab-keycloak-traefik.service", + "systemctl is-active glab-keycloak-config.service || systemctl status --no-pager glab-keycloak-config.service", "findmnt /var/lib/keycloak", "stat -c %a\\ %s\\ %n /run/glab/keycloak/stack.env", + "stat -c %a\\ %s\\ %n /run/glab/keycloak/admin.env", "cut -d= -f1 /run/glab/keycloak/stack.env | sort", + "cut -d= -f1 /run/glab/keycloak/admin.env | sort", "curl -fsS http://127.0.0.1:9000/health/ready" ]' ``` diff --git a/aws/keycloak/locals.tf b/aws/keycloak/locals.tf index 820401d..14e6afe 100644 --- a/aws/keycloak/locals.tf +++ b/aws/keycloak/locals.tf @@ -13,6 +13,9 @@ locals { postgres_state_dir = "${var.data_dir}/postgres" keycloak_env_path = "${var.bootstrap_runtime_dir}/keycloak.env" helper_script_dir = "${var.runtime_dir}/bin" + keycloak_config_dir = "${var.data_dir}/config" + keycloak_config_marker_path = "${local.keycloak_config_dir}/lab-realm-imported" + keycloak_realm_config_path = "${var.bootstrap_runtime_dir}/lab-realm.json" data_volume_device_path = "/dev/disk/by-id/nvme-Amazon_Elastic_Block_Store_${replace(aws_ebs_volume.keycloak_data.id, "-", "")}" traefik_dynamic_config = templatefile("${path.module}/templates/traefik_dynamic.yml.tftpl", { @@ -20,12 +23,18 @@ locals { traefik_certificate_resolver = local.traefik_certificate_resolver }) - bootstrap_unit_name = "glab-keycloak-bootstrap.service" - data_unit_name = "glab-keycloak-data.service" - network_unit_name = "glab-keycloak-network.service" - postgres_unit_name = "glab-keycloak-postgres.service" - keycloak_unit_name = "glab-keycloak.service" - traefik_unit_name = "glab-keycloak-traefik.service" + lab_realm_config = templatefile("${path.module}/templates/keycloak/lab-realm.json.tftpl", { + private_hostname = var.private_hostname + }) + + bootstrap_unit_name = "glab-keycloak-bootstrap.service" + config_unit_name = "glab-keycloak-config.service" + data_unit_name = "glab-keycloak-data.service" + disable_bootstrap_admin_unit_name = "glab-keycloak-disable-bootstrap-admin.service" + network_unit_name = "glab-keycloak-network.service" + postgres_unit_name = "glab-keycloak-postgres.service" + keycloak_unit_name = "glab-keycloak.service" + traefik_unit_name = "glab-keycloak-traefik.service" runtime_template_vars = { acme_ca_server = var.acme_ca_server @@ -36,14 +45,24 @@ locals { bootstrap_runtime_dir = var.bootstrap_runtime_dir bootstrap_secret_path = var.bootstrap_secret_path bootstrap_unit_name = local.bootstrap_unit_name + config_field = var.config_field + config_output_path = var.config_output_path + config_secret_path = var.config_secret_path + config_unit_name = local.config_unit_name data_dir = var.data_dir data_unit_name = local.data_unit_name data_volume_device_path = local.data_volume_device_path data_volume_label = var.data_volume_label + disable_bootstrap_admin_unit_name = local.disable_bootstrap_admin_unit_name github_token_broker_function_name = var.github_token_broker_function_name helper_script_dir = local.helper_script_dir + keycloak_config_cli_image = var.keycloak_config_cli_image + keycloak_config_dir = local.keycloak_config_dir + keycloak_config_marker_path = local.keycloak_config_marker_path keycloak_env_path = local.keycloak_env_path keycloak_image = var.keycloak_image + keycloak_realm_config_path = local.keycloak_realm_config_path + lab_realm_config_gzip_base64 = base64gzip(local.lab_realm_config) keycloak_unit_name = local.keycloak_unit_name labctl_image = var.labctl_image network_unit_name = local.network_unit_name @@ -66,6 +85,8 @@ locals { wait_postgres_script = templatefile("${path.module}/templates/scripts/wait-postgres.sh.tftpl", local.runtime_template_vars) run_keycloak_script = templatefile("${path.module}/templates/scripts/run-keycloak.sh.tftpl", local.runtime_template_vars) run_traefik_script = templatefile("${path.module}/templates/scripts/run-traefik.sh.tftpl", local.runtime_template_vars) + run_config_script = templatefile("${path.module}/templates/scripts/run-keycloak-config.sh.tftpl", local.runtime_template_vars) + disable_admin_script = templatefile("${path.module}/templates/scripts/disable-bootstrap-admin.sh.tftpl", local.runtime_template_vars) data_unit = templatefile("${path.module}/templates/systemd/${local.data_unit_name}.tftpl", local.runtime_template_vars) network_unit = templatefile("${path.module}/templates/systemd/${local.network_unit_name}.tftpl", local.runtime_template_vars) @@ -73,6 +94,11 @@ locals { postgres_unit = templatefile("${path.module}/templates/systemd/${local.postgres_unit_name}.tftpl", local.runtime_template_vars) keycloak_unit = templatefile("${path.module}/templates/systemd/${local.keycloak_unit_name}.tftpl", local.runtime_template_vars) traefik_unit = templatefile("${path.module}/templates/systemd/${local.traefik_unit_name}.tftpl", local.runtime_template_vars) + config_unit = templatefile("${path.module}/templates/systemd/${local.config_unit_name}.tftpl", local.runtime_template_vars) + disable_admin_unit = templatefile( + "${path.module}/templates/systemd/${local.disable_bootstrap_admin_unit_name}.tftpl", + local.runtime_template_vars, + ) ignition_files = [ { @@ -124,9 +150,23 @@ locals { source = "data:text/plain;charset=utf-8;base64,${base64encode(local.run_traefik_script)}" } }, + { + path = "${local.helper_script_dir}/run-keycloak-config.sh" + mode = 493 + contents = { + source = "data:text/plain;charset=utf-8;base64,${base64encode(local.run_config_script)}" + } + }, + { + path = "${local.helper_script_dir}/disable-bootstrap-admin.sh" + mode = 493 + contents = { + source = "data:text/plain;charset=utf-8;base64,${base64encode(local.disable_admin_script)}" + } + }, ] - ignition_config = jsonencode({ + ignition_payload_config = jsonencode({ ignition = { version = "3.3.0" } @@ -178,12 +218,33 @@ locals { enabled = true name = local.keycloak_unit_name }, + { + contents = local.config_unit + enabled = true + name = local.config_unit_name + }, { contents = local.traefik_unit enabled = true name = local.traefik_unit_name }, + { + contents = local.disable_admin_unit + name = local.disable_bootstrap_admin_unit_name + }, ] } }) + + ignition_config = jsonencode({ + ignition = { + version = "3.3.0" + config = { + replace = { + source = "data:application/vnd.coreos.ignition+json;base64,${base64gzip(local.ignition_payload_config)}" + compression = "gzip" + } + } + } + }) } diff --git a/aws/keycloak/templates/keycloak/lab-realm.json.tftpl b/aws/keycloak/templates/keycloak/lab-realm.json.tftpl new file mode 100644 index 0000000..b05f79d --- /dev/null +++ b/aws/keycloak/templates/keycloak/lab-realm.json.tftpl @@ -0,0 +1,137 @@ +{ + "realm": "lab", + "enabled": true, + "displayName": "glab", + "registrationAllowed": false, + "resetPasswordAllowed": false, + "rememberMe": false, + "loginWithEmailAllowed": false, + "duplicateEmailsAllowed": false, + "verifyEmail": false, + "browserFlow": "glab browser", + "webAuthnPolicyRpEntityName": "glab", + "webAuthnPolicyRpId": "${private_hostname}", + "webAuthnPolicySignatureAlgorithms": [ + "ES256", + "RS256" + ], + "webAuthnPolicyAttestationConveyancePreference": "none", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "discouraged", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": true, + "authenticationFlows": [ + { + "alias": "glab browser", + "description": "Browser flow for local password plus WebAuthn when enrolled.", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": false, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "authenticatorFlow": false + }, + { + "authenticator": "identity-provider-redirector", + "requirement": "ALTERNATIVE", + "priority": 20, + "userSetupAllowed": false, + "authenticatorFlow": false + }, + { + "flowAlias": "glab browser forms", + "requirement": "ALTERNATIVE", + "priority": 30, + "userSetupAllowed": false, + "authenticatorFlow": true + } + ] + }, + { + "alias": "glab browser forms", + "description": "Local password form with conditional WebAuthn second factor.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": false, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "authenticatorFlow": false + }, + { + "flowAlias": "glab webauthn conditional", + "requirement": "CONDITIONAL", + "priority": 20, + "userSetupAllowed": false, + "authenticatorFlow": true + } + ] + }, + { + "alias": "glab webauthn conditional", + "description": "Require WebAuthn when the user has a WebAuthn credential.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": false, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "authenticatorFlow": false + }, + { + "authenticator": "webauthn-authenticator", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "authenticatorFlow": false + } + ] + } + ], + "requiredActions": [ + { + "alias": "webauthn-register", + "name": "WebAuthn Register", + "providerId": "webauthn-register", + "enabled": true, + "defaultAction": false, + "priority": 20, + "config": {} + } + ], + "users": [ + { + "username": "$(env:KEYCLOAK_LOCAL_ADMIN_USERNAME)", + "email": "$(env:KEYCLOAK_LOCAL_ADMIN_EMAIL)", + "emailVerified": true, + "enabled": true, + "requiredActions": [ + "webauthn-register" + ], + "credentials": [ + { + "type": "password", + "userLabel": "initial", + "value": "$(env:KEYCLOAK_LOCAL_ADMIN_PASSWORD)", + "temporary": false + } + ], + "clientRoles": { + "realm-management": [ + "realm-admin" + ] + } + } + ] +} diff --git a/aws/keycloak/templates/scripts/disable-bootstrap-admin.sh.tftpl b/aws/keycloak/templates/scripts/disable-bootstrap-admin.sh.tftpl new file mode 100644 index 0000000..6fe52a0 --- /dev/null +++ b/aws/keycloak/templates/scripts/disable-bootstrap-admin.sh.tftpl @@ -0,0 +1,50 @@ +#!/bin/sh +set -eu + +. '${bootstrap_output_path}' + +deadline=$(( $(date +%s) + 300 )) +until /usr/bin/curl -fsS http://127.0.0.1:9000/health/ready >/dev/null; do + if [ "$(date +%s)" -ge "$deadline" ]; then + echo 'Timed out waiting for Keycloak readiness.' >&2 + exit 1 + fi + sleep 5 +done + +/usr/bin/docker run --rm \ + --network keycloak \ + -e KC_BOOTSTRAP_ADMIN_USERNAME="$KC_BOOTSTRAP_ADMIN_USERNAME" \ + -e KC_BOOTSTRAP_ADMIN_PASSWORD="$KC_BOOTSTRAP_ADMIN_PASSWORD" \ + --entrypoint /bin/sh \ + '${keycloak_image}' \ + -c 'set -eu + /opt/keycloak/bin/kcadm.sh config credentials \ + --server http://keycloak:8080 \ + --realm master \ + --user "$KC_BOOTSTRAP_ADMIN_USERNAME" \ + --password "$KC_BOOTSTRAP_ADMIN_PASSWORD" >/dev/null + + user_id="$( + /opt/keycloak/bin/kcadm.sh get users \ + -r master \ + -q username="$KC_BOOTSTRAP_ADMIN_USERNAME" \ + --fields id \ + --format csv \ + --noquotes | + { + line= + IFS= read -r line || true + if [ "$line" = "id" ]; then + IFS= read -r line || true + fi + printf "%s" "$line" + } + )" + if [ -z "$user_id" ]; then + echo "Could not find bootstrap admin user." >&2 + exit 1 + fi + + /opt/keycloak/bin/kcadm.sh update "users/$user_id" -r master -s enabled=false + ' diff --git a/aws/keycloak/templates/scripts/run-keycloak-config.sh.tftpl b/aws/keycloak/templates/scripts/run-keycloak-config.sh.tftpl new file mode 100644 index 0000000..34a69b0 --- /dev/null +++ b/aws/keycloak/templates/scripts/run-keycloak-config.sh.tftpl @@ -0,0 +1,62 @@ +#!/bin/sh +set -eu + +. '${bootstrap_output_path}' + +if [ -f '${keycloak_config_marker_path}' ]; then + echo 'Keycloak lab realm configuration already imported.' + exit 0 +fi + +mkdir -p '${bootstrap_runtime_dir}' '${keycloak_config_dir}' +chmod 0700 '${bootstrap_runtime_dir}' +rm -f '${config_output_path}' '${keycloak_realm_config_path}' '${keycloak_realm_config_path}.gz.b64' + +/usr/bin/docker run --rm \ + --network host \ + --user 0:0 \ + -v '${bootstrap_runtime_dir}:${bootstrap_runtime_dir}' \ + '${labctl_image}' \ + secrets get '${config_secret_path}' \ + --source github \ + --field '${config_field}' \ + --output '${config_output_path}' \ + --aws-region '${aws_region}' \ + --broker-function '${github_token_broker_function_name}' +chmod 0600 '${config_output_path}' + +cat >'${keycloak_realm_config_path}.gz.b64' <<'EOF_REALM_CONFIG' +${lab_realm_config_gzip_base64} +EOF_REALM_CONFIG +/usr/bin/base64 -d '${keycloak_realm_config_path}.gz.b64' | /usr/bin/gzip -d >'${keycloak_realm_config_path}' +chmod 0600 '${keycloak_realm_config_path}' +rm -f '${keycloak_realm_config_path}.gz.b64' + +deadline=$(( $(date +%s) + 300 )) +until /usr/bin/curl -fsS http://127.0.0.1:9000/health/ready >/dev/null; do + if [ "$(date +%s)" -ge "$deadline" ]; then + echo 'Timed out waiting for Keycloak readiness.' >&2 + exit 1 + fi + 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 \ + --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}' + +touch '${keycloak_config_marker_path}' +chmod 0600 '${keycloak_config_marker_path}' diff --git a/aws/keycloak/templates/systemd/glab-keycloak-config.service.tftpl b/aws/keycloak/templates/systemd/glab-keycloak-config.service.tftpl new file mode 100644 index 0000000..e7e67a6 --- /dev/null +++ b/aws/keycloak/templates/systemd/glab-keycloak-config.service.tftpl @@ -0,0 +1,12 @@ +[Unit] +Description=Configure Keycloak lab realm +Requires=docker.service ${data_unit_name} ${network_unit_name} ${bootstrap_unit_name} ${keycloak_unit_name} +After=docker.service ${data_unit_name} ${network_unit_name} ${bootstrap_unit_name} ${keycloak_unit_name} +ConditionPathExists=!${keycloak_config_marker_path} + +[Service] +Type=oneshot +ExecStart=${helper_script_dir}/run-keycloak-config.sh + +[Install] +WantedBy=multi-user.target diff --git a/aws/keycloak/templates/systemd/glab-keycloak-disable-bootstrap-admin.service.tftpl b/aws/keycloak/templates/systemd/glab-keycloak-disable-bootstrap-admin.service.tftpl new file mode 100644 index 0000000..bf70419 --- /dev/null +++ b/aws/keycloak/templates/systemd/glab-keycloak-disable-bootstrap-admin.service.tftpl @@ -0,0 +1,9 @@ +[Unit] +Description=Disable temporary Keycloak master bootstrap admin +Requires=docker.service ${network_unit_name} ${bootstrap_unit_name} ${keycloak_unit_name} +After=docker.service ${network_unit_name} ${bootstrap_unit_name} ${keycloak_unit_name} +ConditionPathExists=${keycloak_config_marker_path} + +[Service] +Type=oneshot +ExecStart=${helper_script_dir}/disable-bootstrap-admin.sh diff --git a/aws/keycloak/tests/main.tftest.hcl b/aws/keycloak/tests/main.tftest.hcl index 030d2b2..e4a5e01 100644 --- a/aws/keycloak/tests/main.tftest.hcl +++ b/aws/keycloak/tests/main.tftest.hcl @@ -107,12 +107,12 @@ run "plan_defaults" { } assert { - condition = strcontains(local.ignition_config, "\"version\":\"3.3.0\"") - error_message = "The Keycloak host should pass raw Ignition v3 JSON as EC2 user data." + condition = strcontains(local.ignition_config, "\"compression\":\"gzip\"") && strcontains(local.ignition_config, "data:application/vnd.coreos.ignition+json;base64,") + error_message = "The Keycloak host should pass a compressed Ignition v3 config through EC2 user data." } assert { - condition = strcontains(local.ignition_config, "amazon-ssm-agent.service") + condition = strcontains(local.ignition_payload_config, "amazon-ssm-agent.service") error_message = "The Keycloak host should enable the Flatcar AWS SSM agent." } @@ -131,16 +131,46 @@ run "plan_defaults" { error_message = "The bootstrap unit should fetch only the Keycloak stack_env field into /run." } + assert { + condition = strcontains(local.run_config_script, "secrets get 'services/keycloak/admin.sops.yaml'") && strcontains(local.run_config_script, "--field '/admin_env'") && strcontains(local.run_config_script, "--output '/run/glab/keycloak/admin.env'") + error_message = "The config script should fetch only the Keycloak admin_env field into /run." + } + + assert { + condition = strcontains(local.run_config_script, "quay.io/adorsys/keycloak-config-cli@sha256:2d2a0663cf324379d9ffab896db8d00293cd0326151968b319cf166f6eec8fca") + error_message = "The config script should use the pinned keycloak-config-cli image digest." + } + + assert { + condition = strcontains(local.lab_realm_config, "\"realm\": \"lab\"") && strcontains(local.lab_realm_config, "\"webAuthnPolicyRpId\": \"id.glab.lol\"") && strcontains(local.lab_realm_config, "\"webAuthnPolicyUserVerificationRequirement\": \"discouraged\"") + error_message = "The realm config should define the lab realm with touch-only WebAuthn policy for id.glab.lol." + } + + assert { + condition = strcontains(local.lab_realm_config, "\"username\": \"$(env:KEYCLOAK_LOCAL_ADMIN_USERNAME)\"") && strcontains(local.lab_realm_config, "\"value\": \"$(env:KEYCLOAK_LOCAL_ADMIN_PASSWORD)\"") && strcontains(local.lab_realm_config, "\"webauthn-register\"") + error_message = "The realm config should create the local admin from runtime env and require WebAuthn registration." + } + assert { condition = strcontains(local.prepare_data_script, "/var/lib/keycloak/postgres") && strcontains(local.prepare_data_script, "/var/lib/keycloak/acme") && strcontains(local.prepare_data_script, "chown 999:999") error_message = "The data preparation script should place Postgres and ACME state on the data volume." } assert { - condition = strcontains(local.ignition_config, "/etc/glab/keycloak/bin/run-keycloak.sh") && !strcontains(local.ignition_config, "/usr/local/lib/glab-keycloak") + condition = strcontains(local.ignition_payload_config, "/etc/glab/keycloak/bin/run-keycloak.sh") && !strcontains(local.ignition_payload_config, "/usr/local/lib/glab-keycloak") error_message = "Ignition should write helper scripts under writable root-backed config paths, not Flatcar's read-only /usr tree." } + assert { + condition = strcontains(local.ignition_payload_config, "/etc/glab/keycloak/bin/run-keycloak-config.sh") && strcontains(local.ignition_payload_config, "glab-keycloak-config.service") + error_message = "Ignition should install and enable the first-boot Keycloak config service." + } + + assert { + condition = strcontains(local.ignition_payload_config, "/etc/glab/keycloak/bin/disable-bootstrap-admin.sh") && strcontains(local.ignition_payload_config, "glab-keycloak-disable-bootstrap-admin.service") + error_message = "Ignition should install the manual bootstrap admin disable service." + } + assert { condition = strcontains(local.run_keycloak_script, "/run/glab/keycloak/keycloak.env") && strcontains(local.run_keycloak_script, "KC_DB_PASSWORD") error_message = "The Keycloak runner should derive container-only env from the /run bootstrap secret." @@ -268,6 +298,11 @@ run "plan_overrides" { error_message = "The private hostname override should propagate to DNS." } + assert { + condition = strcontains(local.lab_realm_config, "\"webAuthnPolicyRpId\": \"id.staging.glab.lol\"") + error_message = "The WebAuthn RP ID should follow the private hostname override." + } + assert { condition = local.acme_challenge_record_name == "_acme-challenge.id.staging.acme.glab.lol" error_message = "The delegated ACME challenge record should follow the private hostname override." @@ -376,3 +411,35 @@ run "reject_non_keycloak_secret_path" { var.bootstrap_secret_path, ] } + +run "reject_non_keycloak_config_secret_path" { + command = plan + + providers = { + aws = aws.mock + } + + variables { + config_secret_path = "network/vyos/admin.sops.yaml" + } + + expect_failures = [ + var.config_secret_path, + ] +} + +run "reject_unpinned_keycloak_config_cli_image" { + command = plan + + providers = { + aws = aws.mock + } + + variables { + keycloak_config_cli_image = "quay.io/adorsys/keycloak-config-cli:latest-26" + } + + expect_failures = [ + var.keycloak_config_cli_image, + ] +} diff --git a/aws/keycloak/variables.tf b/aws/keycloak/variables.tf index 7f0217a..eed96b6 100644 --- a/aws/keycloak/variables.tf +++ b/aws/keycloak/variables.tf @@ -81,6 +81,39 @@ variable "bootstrap_secret_path" { } } +variable "config_field" { + description = "RFC 6901 field path extracted from the SOPS file by labctl for Keycloak realm configuration." + type = string + default = "/admin_env" + + validation { + condition = startswith(var.config_field, "/") + error_message = "config_field must be an RFC 6901 pointer beginning with '/'." + } +} + +variable "config_output_path" { + description = "Path where labctl writes the decrypted Keycloak realm configuration dotenv payload." + type = string + default = "/run/glab/keycloak/admin.env" + + validation { + condition = startswith(var.config_output_path, "/run/") + error_message = "config_output_path must stay under /run." + } +} + +variable "config_secret_path" { + description = "Path to the SOPS-encrypted Keycloak realm configuration secret in GilmanLab/secrets." + type = string + default = "services/keycloak/admin.sops.yaml" + + validation { + condition = can(regex("^services/keycloak/[^[:space:]]+\\.sops\\.yaml$", var.config_secret_path)) + error_message = "config_secret_path must point at a services/keycloak/*.sops.yaml file." + } +} + variable "data_dir" { description = "Mount point for the encrypted Keycloak data volume." type = string @@ -268,6 +301,17 @@ variable "keycloak_image" { default = "quay.io/keycloak/keycloak:26.6.1" } +variable "keycloak_config_cli_image" { + description = "Pinned keycloak-config-cli container image used for first-boot realm configuration." + type = string + default = "quay.io/adorsys/keycloak-config-cli@sha256:2d2a0663cf324379d9ffab896db8d00293cd0326151968b319cf166f6eec8fca" + + validation { + condition = startswith(var.keycloak_config_cli_image, "quay.io/adorsys/keycloak-config-cli@sha256:") + error_message = "keycloak_config_cli_image must be pinned by digest." + } +} + variable "lab_cidrs" { description = "CIDR blocks on the lab side that may reach Keycloak over HTTPS." type = set(string) From be5c7d118798885a15a01d67a47fd4706d2347a6 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Thu, 30 Apr 2026 14:28:11 -0700 Subject: [PATCH 2/2] fix(keycloak): run config importer as root --- aws/keycloak/templates/scripts/run-keycloak-config.sh.tftpl | 1 + aws/keycloak/tests/main.tftest.hcl | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/aws/keycloak/templates/scripts/run-keycloak-config.sh.tftpl b/aws/keycloak/templates/scripts/run-keycloak-config.sh.tftpl index 34a69b0..7a3ac68 100644 --- a/aws/keycloak/templates/scripts/run-keycloak-config.sh.tftpl +++ b/aws/keycloak/templates/scripts/run-keycloak-config.sh.tftpl @@ -45,6 +45,7 @@ done /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" \ diff --git a/aws/keycloak/tests/main.tftest.hcl b/aws/keycloak/tests/main.tftest.hcl index e4a5e01..4a0bf9a 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 the pinned keycloak-config-cli image digest." } + assert { + condition = strcontains(local.run_config_script, "--name keycloak-config-cli") && strcontains(local.run_config_script, "--user 0:0") + error_message = "The config-cli container should run as root so it can read root-owned generated config files." + } + assert { condition = strcontains(local.lab_realm_config, "\"realm\": \"lab\"") && strcontains(local.lab_realm_config, "\"webAuthnPolicyRpId\": \"id.glab.lol\"") && strcontains(local.lab_realm_config, "\"webAuthnPolicyUserVerificationRequirement\": \"discouraged\"") error_message = "The realm config should define the lab realm with touch-only WebAuthn policy for id.glab.lol."