Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions aws/keycloak/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, bootstrap secret fetches, and first-boot realm configuration
- Ignition-managed systemd units for Postgres, Keycloak, Traefik, bootstrap secret fetches, and realm configuration

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.
This stack configures the `lab` realm, one local admin account with password plus WebAuthn/YubiKey enrollment, and a minimal public OIDC client for Incus. It does **not** configure GitHub OIDC, OpenFGA, 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.

Expand All @@ -36,13 +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
`glab-keycloak-config.service` runs after Keycloak is healthy. It renders the
realm config, compares its SHA-256 hash with
`/var/lib/keycloak/config/lab-realm.sha256`, and skips when the stored hash
matches. When the hash changes, 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.
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
realm-config hash only after a successful import.

The follow-up Incus-side OIDC values are:

- `oidc.issuer = https://id.glab.lol/realms/lab`
- `oidc.client.id = incus`
- `oidc.scopes = openid,email,profile`
- `oidc.claim = preferred_username` if validation confirms that Keycloak emits
it in the Incus access token

After enrolling and validating the admin YubiKey in the browser, disable the
temporary master bootstrap admin manually:
Expand Down
2 changes: 1 addition & 1 deletion aws/keycloak/locals.tf
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ locals {
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_config_marker_path = "${local.keycloak_config_dir}/lab-realm.sha256"
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, "-", "")}"

Expand Down
27 changes: 27 additions & 0 deletions aws/keycloak/templates/keycloak/lab-realm.json.tftpl
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,33 @@
"config": {}
}
],
"clients": [
{
"clientId": "incus",
"name": "Incus",
"description": "Public OIDC client for Incus human authentication.",
"enabled": true,
"protocol": "openid-connect",
"publicClient": true,
"bearerOnly": false,
"standardFlowEnabled": false,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"authorizationServicesEnabled": false,
"frontchannelLogout": false,
"attributes": {
"oauth2.device.authorization.grant.enabled": "true"
},
"defaultClientScopes": [
"profile",
"email"
],
"optionalClientScopes": [
"offline_access"
]
}
],
"users": [
{
"username": "$(env:KEYCLOAK_LOCAL_ADMIN_USERNAME)",
Expand Down
29 changes: 16 additions & 13 deletions aws/keycloak/templates/scripts/run-keycloak-config.sh.tftpl
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,24 @@ 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'

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'

desired_hash="$(/usr/bin/sha256sum '${keycloak_realm_config_path}' | /usr/bin/awk '{print $1}')"
if [ -f '${keycloak_config_marker_path}' ] && [ "$(cat '${keycloak_config_marker_path}')" = "$desired_hash" ]; then
echo "Keycloak lab realm configuration already at $desired_hash."
rm -f '${keycloak_realm_config_path}'
exit 0
fi

/usr/bin/docker run --rm \
--network host \
--user 0:0 \
Expand All @@ -25,13 +34,6 @@ rm -f '${config_output_path}' '${keycloak_realm_config_path}' '${keycloak_realm_
--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
Expand Down Expand Up @@ -59,5 +61,6 @@ done
-v '${keycloak_realm_config_path}:/config/lab-realm.json:ro' \
'${keycloak_config_cli_image}'

touch '${keycloak_config_marker_path}'
printf '%s\n' "$desired_hash" >'${keycloak_config_marker_path}'
chmod 0600 '${keycloak_config_marker_path}'
rm -f '${keycloak_realm_config_path}'
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
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
Expand Down
20 changes: 20 additions & 0 deletions aws/keycloak/tests/main.tftest.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,16 @@ run "plan_defaults" {
error_message = "The config script should fetch only the Keycloak admin_env field into /run."
}

assert {
condition = strcontains(local.run_config_script, "sha256sum '/run/glab/keycloak/lab-realm.json'") && strcontains(local.run_config_script, "cat '/var/lib/keycloak/config/lab-realm.sha256'") && strcontains(local.run_config_script, "printf '%s\\n' \"$desired_hash\" >'/var/lib/keycloak/config/lab-realm.sha256'")
error_message = "The config script should use a rendered realm hash marker instead of a permanent first-import marker."
}

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."
}

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."
Expand All @@ -156,6 +166,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\": \"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."
}

assert {
condition = strcontains(local.lab_realm_config, "\"standardFlowEnabled\": false") && strcontains(local.lab_realm_config, "\"implicitFlowEnabled\": false") && strcontains(local.lab_realm_config, "\"directAccessGrantsEnabled\": false") && strcontains(local.lab_realm_config, "\"serviceAccountsEnabled\": false") && strcontains(local.lab_realm_config, "\"authorizationServicesEnabled\": false")
error_message = "The Incus client should keep unused OIDC grants and authorization services disabled."
}

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."
Expand Down
Loading