diff --git a/.env.template b/.env.template index 422758d..d563dcb 100644 --- a/.env.template +++ b/.env.template @@ -42,6 +42,10 @@ REPLICATION_BACKEND_PUBLIC_KEY= # random secret used for token encryption (replication-backend) REPLICATION_BACKEND_JWT_SECRET= +# Keycloak service account for replication-backend permission checks +REPLICATION_BACKEND_KEYCLOAK_CLIENT_ID=aam-backend +REPLICATION_BACKEND_KEYCLOAK_CLIENT_SECRET= + # Sentry configuration (app, replication-backend and aam-backend) SENTRY_DSN= SENTRY_DSN_REPLICATION_BACKEND= diff --git a/docker-compose.yml b/docker-compose.yml index 4aad234..80c094f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -66,6 +66,10 @@ services: SENTRY_ENABLED: ${SENTRY_ENABLED} SENTRY_INSTANCE_NAME: ${INSTANCE_NAME}.${INSTANCE_DOMAIN} SENTRY_ENVIRONMENT: ${SENTRY_ENVIRONMENT} + KEYCLOAK_ADMIN_BASE_URL: https://${KEYCLOAK_URL} + KEYCLOAK_REALM: ${INSTANCE_NAME} + KEYCLOAK_ADMIN_CLIENT_ID: ${REPLICATION_BACKEND_KEYCLOAK_CLIENT_ID:-aam-backend} + KEYCLOAK_ADMIN_CLIENT_SECRET: ${REPLICATION_BACKEND_KEYCLOAK_CLIENT_SECRET} PORT: 5984 restart: unless-stopped profiles: @@ -81,6 +85,7 @@ services: - external_web depends_on: - couchdb-with-permissions + - replication-backend - aam-backend-service-db - rabbitmq volumes: diff --git a/scripts/enable-backend.sh b/scripts/enable-backend.sh index d3273e4..5cd02ab 100755 --- a/scripts/enable-backend.sh +++ b/scripts/enable-backend.sh @@ -51,6 +51,9 @@ RENDER_API_CLIENT_ID_DEV=$(bws secret -t "$BWS_ACCESS_TOKEN" get "b53d7a1d-220e- RENDER_API_CLIENT_SECRET_DEV=$(bws secret -t "$BWS_ACCESS_TOKEN" get "83a8e38b-fc22-461f-91a0-b22700712b62" | jq -r .value) SENTRY_AUTH_TOKEN=$(bws secret -t "$BWS_ACCESS_TOKEN" get "b9a3e1eb-3925-4ed6-93f4-b2270073c82c" | jq -r .value) SENTRY_DSN_BACKEND=$(bws secret -t "$BWS_ACCESS_TOKEN" get "a858a580-9643-4330-8667-b2270073d7a6" | jq -r .value) +KEYCLOAK_HOST=$(bws secret -t "$BWS_ACCESS_TOKEN" get "3db87144-76c9-4690-8f59-b22600c8c927" | jq -r .value) +KEYCLOAK_PASSWORD=$(bws secret -t "$BWS_ACCESS_TOKEN" get "c5f42f09-b1c8-43a8-ae75-b22600c8f2e5" | jq -r .value) +KEYCLOAK_USER=$(bws secret -t "$BWS_ACCESS_TOKEN" get "fbe4ba07-538d-49e2-92dd-b22600c8d9d2" | jq -r .value) chars=abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 @@ -101,9 +104,12 @@ setEnv() { local key="$1" local value="$2" local path="$3" + # escape sed special characters in value (\, &, |) + local escaped + escaped=$(printf '%s' "$value" | sed 's/[\\&|]/\\&/g') - sed -i "s|^$key=.*|$key=$value|g" "$path" # linux - # gsed -i "s|^$key=.*|$key=$value|g" "$path" # macos + sed -i "s|^$key=.*|$key=$escaped|g" "$path" # linux + # gsed -i "s|^$key=.*|$key=$escaped|g" "$path" # macos } # Funktion zum Abrufen der Umgebungsvariablen @@ -130,6 +136,107 @@ generate_password() { done } +getKeycloakToken() { + token=$(curl -s -L "https://$KEYCLOAK_HOST/realms/master/protocol/openid-connect/token" -H 'Content-Type: application/x-www-form-urlencoded' --data-urlencode username="$KEYCLOAK_USER" --data-urlencode password="$KEYCLOAK_PASSWORD" --data-urlencode grant_type=password --data-urlencode client_id=admin-cli) + token=${token#*\"access_token\":\"} + token=${token%%\"*} +} + +createKeycloakBackendClient() { + local realm="$1" + clientSecret="" + + getKeycloakToken + + # check if aam-backend client already exists (idempotent) + local existing + existing=$(curl -s -L "https://$KEYCLOAK_HOST/admin/realms/$realm/clients?clientId=aam-backend" \ + -H "Authorization: Bearer $token") + clientUuid=$(echo "$existing" | jq -r '.[0].id // empty') + + if [ -z "$clientUuid" ]; then + # create the aam-backend client (confidential, service account enabled) + clientResponse=$(curl -s -D - -o /dev/null -X POST "https://$KEYCLOAK_HOST/admin/realms/$realm/clients" \ + -H "Authorization: Bearer $token" \ + -H "Content-Type: application/json" \ + -d '{ + "clientId": "aam-backend", + "enabled": true, + "clientAuthenticatorType": "client-secret", + "serviceAccountsEnabled": true, + "publicClient": false, + "standardFlowEnabled": false, + "directAccessGrantsEnabled": false, + "protocol": "openid-connect" + }') + + # extract client UUID from Location header + location=$(echo "$clientResponse" | grep -i "^location:") + clientUuid=$(echo "$location" | sed -n 's#.*\([a-f0-9]\{8\}-[a-f0-9]\{4\}-[a-f0-9]\{4\}-[a-f0-9]\{4\}-[a-f0-9]\{12\}\).*#\1#p') + + if [ -z "$clientUuid" ]; then + echo "ERROR: Failed to create aam-backend client in Keycloak realm '$realm'." + return 1 + fi + + echo "Created aam-backend client: $clientUuid" + else + echo "aam-backend client already exists: $clientUuid" + fi + + # get client secret + clientSecret=$(curl -s -L "https://$KEYCLOAK_HOST/admin/realms/$realm/clients/$clientUuid/client-secret" \ + -H "Authorization: Bearer $token" | jq -r '.value // empty') + + if [ -z "$clientSecret" ]; then + echo "ERROR: Failed to retrieve client secret for aam-backend client in realm '$realm'." + return 1 + fi + + # get the service account user + serviceAccountUserId=$(curl -s -L "https://$KEYCLOAK_HOST/admin/realms/$realm/clients/$clientUuid/service-account-user" \ + -H "Authorization: Bearer $token" | jq -r '.id // empty') + + if [ -z "$serviceAccountUserId" ]; then + echo "ERROR: Failed to retrieve service account user for aam-backend client in realm '$realm'." + return 1 + fi + + # get the realm-management client UUID + realmMgmtClientUuid=$(curl -s -L "https://$KEYCLOAK_HOST/admin/realms/$realm/clients?clientId=realm-management" \ + -H "Authorization: Bearer $token" | jq -r '.[0].id // empty') + + if [ -z "$realmMgmtClientUuid" ]; then + echo "ERROR: Failed to retrieve realm-management client in realm '$realm'." + return 1 + fi + + # get the manage-realm role from realm-management client + manageRealmRole=$(curl -s -L "https://$KEYCLOAK_HOST/admin/realms/$realm/clients/$realmMgmtClientUuid/roles/manage-realm" \ + -H "Authorization: Bearer $token") + + # assign manage-realm role to the service account + curl -s -X POST "https://$KEYCLOAK_HOST/admin/realms/$realm/users/$serviceAccountUserId/role-mappings/clients/$realmMgmtClientUuid" \ + -H "Authorization: Bearer $token" \ + -H "Content-Type: application/json" \ + -d "[$manageRealmRole]" + + echo "Assigned manage-realm role to aam-backend service account." + + # ensure the "roles" client scope is assigned (required for role claims in the access token) + local rolesScopeUuid + rolesScopeUuid=$(curl -s -L "https://$KEYCLOAK_HOST/admin/realms/$realm/client-scopes" \ + -H "Authorization: Bearer $token" | jq -r '.[] | select(.name == "roles") | .id // empty') + + if [ -n "$rolesScopeUuid" ]; then + curl -s -X PUT "https://$KEYCLOAK_HOST/admin/realms/$realm/clients/$clientUuid/default-client-scopes/$rolesScopeUuid" \ + -H "Authorization: Bearer $token" + echo "Ensured 'roles' client scope on aam-backend client." + else + echo "WARNING: Could not find 'roles' client scope in realm '$realm'." + fi +} + ############################## # script ############################## @@ -182,6 +289,9 @@ setEnv CRYPTO_CONFIGURATION_SECRET "$password" "$path/config/aam-backend-service setEnv SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUERURI "https://keycloak.aam-digital.com/realms/$instance" "$path/config/aam-backend-service/application.env" setEnv SPRING_DATASOURCE_USERNAME "$(getVar "$path/.env" COUCHDB_USER)" "$path/config/aam-backend-service/application.env" setEnv SPRING_DATASOURCE_PASSWORD "$(getVar "$path/.env" COUCHDB_PASSWORD)" "$path/config/aam-backend-service/application.env" +setEnv AAMREPLICATIONBACKENDCLIENTCONFIGURATION_BASEPATH "http://replication-backend:5984" "$path/config/aam-backend-service/application.env" +setEnv AAMREPLICATIONBACKENDCLIENTCONFIGURATION_BASICAUTHUSERNAME "$(getVar "$path/.env" COUCHDB_USER)" "$path/config/aam-backend-service/application.env" +setEnv AAMREPLICATIONBACKENDCLIENTCONFIGURATION_BASICAUTHPASSWORD "$(getVar "$path/.env" COUCHDB_PASSWORD)" "$path/config/aam-backend-service/application.env" setEnv COUCHDBCLIENTCONFIGURATION_BASICAUTHUSERNAME "$(getVar "$path/.env" COUCHDB_USER)" "$path/config/aam-backend-service/application.env" setEnv COUCHDBCLIENTCONFIGURATION_BASICAUTHPASSWORD "$(getVar "$path/.env" COUCHDB_PASSWORD)" "$path/config/aam-backend-service/application.env" setEnv SQSCLIENTCONFIGURATION_BASICAUTHUSERNAME "$(getVar "$path/.env" COUCHDB_USER)" "$path/config/aam-backend-service/application.env" @@ -195,6 +305,22 @@ setEnv SENTRY_AUTH_TOKEN "$SENTRY_AUTH_TOKEN" "$path/config/aam-backend-service/ setEnv SENTRY_DSN "$SENTRY_DSN_BACKEND" "$path/config/aam-backend-service/application.env" setEnv SENTRY_SERVER_NAME "$instance.$DOMAIN" "$path/config/aam-backend-service/application.env" +# create aam-backend Keycloak client for permission checks +if ! createKeycloakBackendClient "$instance"; then + echo "ERROR: Failed to create/get Keycloak backend client for '$instance'. Aborting." + exit 1 +fi +if [ -z "$clientSecret" ]; then + echo "ERROR: Keycloak client created but secret could not be retrieved for '$instance'. Aborting." + exit 1 +fi + +# ensure key exists before setting (older .env templates may lack it) +if ! grep -q '^REPLICATION_BACKEND_KEYCLOAK_CLIENT_SECRET=' "$path/.env"; then + echo "REPLICATION_BACKEND_KEYCLOAK_CLIENT_SECRET=" >> "$path/.env" +fi +setEnv REPLICATION_BACKEND_KEYCLOAK_CLIENT_SECRET "$clientSecret" "$path/.env" + setEnv COMPOSE_PROFILES "full-stack" "$path/.env" (cd "$path" && docker compose up -d) diff --git a/scripts/migrate-permission-check.sh b/scripts/migrate-permission-check.sh new file mode 100755 index 0000000..d31a4d8 --- /dev/null +++ b/scripts/migrate-permission-check.sh @@ -0,0 +1,299 @@ +#!/bin/bash + +# Migration script: adds Keycloak service account for replication-backend permission checks. +# +# For each instance: +# - Adds REPLICATION_BACKEND_KEYCLOAK_CLIENT_ID / SECRET to .env +# - For full-stack instances: creates the aam-backend Keycloak client with manage-realm role +# - For full-stack instances: ensures AAMREPLICATIONBACKENDCLIENTCONFIGURATION_BASICAUTH* vars exist in application.env +# +# Usage: +# ./migrate-permission-check.sh # migrate all instances +# ./migrate-permission-check.sh # migrate single instance +# +# Requires: BWS_ACCESS_TOKEN set in environment or setup.env + +set -uo pipefail + +baseDirectory="/var/docker" +source "$baseDirectory/ndb-setup/setup.env" + +############################## +# BWS secrets +############################## + +if [[ -z "${BWS_ACCESS_TOKEN:-}" ]]; then + echo "BWS_ACCESS_TOKEN is not set. Abort." + exit 1 +fi + +bws config server-base https://vault.bitwarden.eu + +KEYCLOAK_HOST=$(bws secret -t "$BWS_ACCESS_TOKEN" get "3db87144-76c9-4690-8f59-b22600c8c927" | jq -r .value) +KEYCLOAK_PASSWORD=$(bws secret -t "$BWS_ACCESS_TOKEN" get "c5f42f09-b1c8-43a8-ae75-b22600c8f2e5" | jq -r .value) +KEYCLOAK_USER=$(bws secret -t "$BWS_ACCESS_TOKEN" get "fbe4ba07-538d-49e2-92dd-b22600c8d9d2" | jq -r .value) + +############################## +# helpers +############################## + +getVar() { + local file="$1" + local var="$2" + grep "^$var=" "$file" 2>/dev/null | cut -d '=' -f2- || echo "" +} + +setEnv() { + local key="$1" + local value="$2" + local file="$3" + # escape sed special characters in value (\, &, |) + local escaped + escaped=$(printf '%s' "$value" | sed 's/[\\&|]/\\&/g') + sed -i "s|^$key=.*|$key=$escaped|g" "$file" +} + +# Create a timestamped backup of a file +backupFile() { + local file="$1" + local backup="$file.bak-$(date +%Y%m%d%H%M%S)" + if [ -f "$file" ]; then + cp "$file" "$backup" + echo " backup: $(basename "$backup")" + fi +} + +# Append a variable to a file if it does not already exist +ensureEnv() { + local key="$1" + local value="$2" + local file="$3" + if ! grep -q "^$key=" "$file" 2>/dev/null; then + echo "$key=$value" >> "$file" + echo " + added $key to $(basename "$file")" + fi +} + +getKeycloakToken() { + local raw + raw=$(curl -s -L "https://$KEYCLOAK_HOST/realms/master/protocol/openid-connect/token" \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode username="$KEYCLOAK_USER" \ + --data-urlencode password="$KEYCLOAK_PASSWORD" \ + --data-urlencode grant_type=password \ + --data-urlencode client_id=admin-cli) + token=${raw#*\"access_token\":\"} + token=${token%%\"*} + + if [ -z "$token" ] || [ "$token" = "$raw" ]; then + echo " ERROR: Failed to get Keycloak admin token." + return 1 + fi +} + +# Creates the aam-backend Keycloak client (if it doesn't exist) and assigns manage-realm role. +# Sets $clientSecret on success. +createOrGetKeycloakBackendClient() { + local realm="$1" + clientSecret="" + + getKeycloakToken + + # check if aam-backend client already exists + local existing + existing=$(curl -s -L "https://$KEYCLOAK_HOST/admin/realms/$realm/clients?clientId=aam-backend" \ + -H "Authorization: Bearer $token") + local existingUuid + existingUuid=$(echo "$existing" | jq -r '.[0].id // empty') + + if [ -n "$existingUuid" ]; then + echo " aam-backend client already exists: $existingUuid" + clientSecret=$(curl -s -L "https://$KEYCLOAK_HOST/admin/realms/$realm/clients/$existingUuid/client-secret" \ + -H "Authorization: Bearer $token" | jq -r '.value // empty') + + # ensure service account has manage-realm role (idempotent) + assignManageRealmRole "$realm" "$existingUuid" + return 0 + fi + + # create the aam-backend client (confidential, service account enabled) + local clientResponse + clientResponse=$(curl -s -D - -o /dev/null -X POST "https://$KEYCLOAK_HOST/admin/realms/$realm/clients" \ + -H "Authorization: Bearer $token" \ + -H "Content-Type: application/json" \ + -d '{ + "clientId": "aam-backend", + "enabled": true, + "clientAuthenticatorType": "client-secret", + "serviceAccountsEnabled": true, + "publicClient": false, + "standardFlowEnabled": false, + "directAccessGrantsEnabled": false, + "protocol": "openid-connect" + }') + + local location clientUuid + location=$(echo "$clientResponse" | grep -i "^location:") + clientUuid=$(echo "$location" | sed -n 's#.*\([a-f0-9]\{8\}-[a-f0-9]\{4\}-[a-f0-9]\{4\}-[a-f0-9]\{4\}-[a-f0-9]\{12\}\).*#\1#p') + + if [ -z "$clientUuid" ]; then + echo " ERROR: Failed to create aam-backend client in realm '$realm'." + return 1 + fi + + echo " Created aam-backend client: $clientUuid" + + clientSecret=$(curl -s -L "https://$KEYCLOAK_HOST/admin/realms/$realm/clients/$clientUuid/client-secret" \ + -H "Authorization: Bearer $token" | jq -r '.value // empty') + + assignManageRealmRole "$realm" "$clientUuid" +} + +assignManageRealmRole() { + local realm="$1" + local aamBackendClientUuid="$2" + + local serviceAccountUserId + serviceAccountUserId=$(curl -s -L "https://$KEYCLOAK_HOST/admin/realms/$realm/clients/$aamBackendClientUuid/service-account-user" \ + -H "Authorization: Bearer $token" | jq -r '.id // empty') + + if [ -z "$serviceAccountUserId" ]; then + echo " WARNING: Could not get service account user for aam-backend client." + return 1 + fi + + local realmMgmtClientUuid + realmMgmtClientUuid=$(curl -s -L "https://$KEYCLOAK_HOST/admin/realms/$realm/clients?clientId=realm-management" \ + -H "Authorization: Bearer $token" | jq -r '.[0].id // empty') + + local manageRealmRole + manageRealmRole=$(curl -s -L "https://$KEYCLOAK_HOST/admin/realms/$realm/clients/$realmMgmtClientUuid/roles/manage-realm" \ + -H "Authorization: Bearer $token") + + curl -s -X POST "https://$KEYCLOAK_HOST/admin/realms/$realm/users/$serviceAccountUserId/role-mappings/clients/$realmMgmtClientUuid" \ + -H "Authorization: Bearer $token" \ + -H "Content-Type: application/json" \ + -d "[$manageRealmRole]" + + echo " Ensured manage-realm role on aam-backend service account." + + # ensure the "roles" client scope is assigned (required for role claims in the access token) + local rolesScopeUuid + rolesScopeUuid=$(curl -s -L "https://$KEYCLOAK_HOST/admin/realms/$realm/client-scopes" \ + -H "Authorization: Bearer $token" | jq -r '.[] | select(.name == "roles") | .id // empty') + + if [ -n "$rolesScopeUuid" ]; then + curl -s -X PUT "https://$KEYCLOAK_HOST/admin/realms/$realm/clients/$aamBackendClientUuid/default-client-scopes/$rolesScopeUuid" \ + -H "Authorization: Bearer $token" + echo " Ensured 'roles' client scope on aam-backend client." + else + echo " WARNING: Could not find 'roles' client scope in realm '$realm'." + fi +} + +############################## +# migrate one instance +############################## + +migrate_instance() { + local instanceDir="$1" + local instance + instance=$(basename "$instanceDir") + instance=${instance#"$PREFIX"} + + local envFile="$instanceDir/.env" + local appEnvFile="$instanceDir/config/aam-backend-service/application.env" + + if [ ! -f "$envFile" ]; then + echo "[$instance] no .env file, skipping" + return + fi + + local profile + profile=$(getVar "$envFile" COMPOSE_PROFILES) + + # only full-stack instances need migration (permission check is only used by aam-backend-service) + if [ "$profile" != "full-stack" ]; then + echo "[$instance] profile=$profile — skipping (not full-stack)" + return + fi + + echo "[$instance] migrating..." + + # backup files before any modification + backupFile "$envFile" + backupFile "$instanceDir/docker-compose.yml" + [ -f "$appEnvFile" ] && backupFile "$appEnvFile" + + # 0. Update docker-compose.yml from shared ndb-setup template + cp "$baseDirectory/ndb-setup/docker-compose.yml" "$instanceDir/docker-compose.yml" + echo " Updated docker-compose.yml from ndb-setup template" + + # 1. Add Keycloak vars to .env (for replication-backend) + ensureEnv "REPLICATION_BACKEND_KEYCLOAK_CLIENT_ID" "aam-backend" "$envFile" + ensureEnv "REPLICATION_BACKEND_KEYCLOAK_CLIENT_SECRET" "" "$envFile" + + # 2. Create aam-backend Keycloak client (or get existing) + assign manage-realm + if createOrGetKeycloakBackendClient "$instance"; then + if [ -n "$clientSecret" ]; then + setEnv "REPLICATION_BACKEND_KEYCLOAK_CLIENT_SECRET" "$clientSecret" "$envFile" + echo " Set REPLICATION_BACKEND_KEYCLOAK_CLIENT_SECRET in .env" + else + echo " ERROR: Client created/fetched but secret could not be retrieved." + echo " Skipping restart for $instance to avoid broken permission-check config." + return 1 + fi + else + echo " ERROR: Failed to create or get Keycloak backend client for $instance." + echo " Skipping restart for this instance to avoid broken permission-check config." + return 1 + fi + + # 3. Ensure application.env has replication-backend basic auth vars + if [ -f "$appEnvFile" ]; then + local couchUser couchPass + couchUser=$(getVar "$envFile" COUCHDB_USER) + couchPass=$(getVar "$envFile" COUCHDB_PASSWORD) + + ensureEnv "AAMREPLICATIONBACKENDCLIENTCONFIGURATION_BASEPATH" "http://replication-backend:5984" "$appEnvFile" + ensureEnv "AAMREPLICATIONBACKENDCLIENTCONFIGURATION_BASICAUTHUSERNAME" "$couchUser" "$appEnvFile" + ensureEnv "AAMREPLICATIONBACKENDCLIENTCONFIGURATION_BASICAUTHPASSWORD" "$couchPass" "$appEnvFile" + else + echo " no application.env found — skipping aam-backend-service config" + fi + + # 4. Restart + echo " Restarting..." + (cd "$instanceDir" && docker compose down && docker compose pull && docker compose up -d) + + echo "[$instance] done" + echo "" +} + +############################## +# main +############################## + +if [ -n "${1:-}" ]; then + # single instance mode + path="$baseDirectory/$PREFIX$1" + if [ ! -d "$path" ]; then + echo "Instance directory not found: $path" + exit 1 + fi + migrate_instance "$path" +else + # all instances + if [ -z "${PREFIX:-}" ]; then + echo "ERROR: PREFIX is not set. Aborting to avoid operating on all directories." + exit 1 + fi + cd "$baseDirectory" + for D in ${PREFIX}*; do + if [ -d "$D" ]; then + migrate_instance "$baseDirectory/$D" + fi + done +fi + +echo "Migration complete."