#!/bin/sh set -e # Cameleer SaaS — Bootstrap Script # Creates Logto apps, users, organizations, roles. # Seeds cameleer_saas DB with tenant, environment, license. # Configures cameleer3-server OIDC. # Idempotent: checks existence before creating. LOGTO_ENDPOINT="${LOGTO_ENDPOINT:-http://logto:3001}" LOGTO_ADMIN_ENDPOINT="${LOGTO_ADMIN_ENDPOINT:-http://logto:3002}" LOGTO_PUBLIC_ENDPOINT="${LOGTO_PUBLIC_ENDPOINT:-http://localhost:3001}" MGMT_API_RESOURCE="https://default.logto.app/api" BOOTSTRAP_FILE="/data/logto-bootstrap.json" PG_HOST="${PG_HOST:-postgres}" PG_USER="${PG_USER:-cameleer}" PG_DB_LOGTO="logto" PG_DB_SAAS="${PG_DB_SAAS:-cameleer_saas}" # App names SPA_APP_NAME="Cameleer SaaS" M2M_APP_NAME="Cameleer SaaS Backend" TRAD_APP_NAME="Cameleer Dashboard" API_RESOURCE_INDICATOR="https://api.cameleer.local" API_RESOURCE_NAME="Cameleer SaaS API" # Users (configurable via env vars) SAAS_ADMIN_USER="${SAAS_ADMIN_USER:-admin}" SAAS_ADMIN_PASS="${SAAS_ADMIN_PASS:-admin}" TENANT_ADMIN_USER="${TENANT_ADMIN_USER:-camel}" TENANT_ADMIN_PASS="${TENANT_ADMIN_PASS:-camel}" # Tenant config TENANT_NAME="Example Tenant" TENANT_SLUG="default" BOOTSTRAP_TOKEN="${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}" # Server config SERVER_ENDPOINT="${SERVER_ENDPOINT:-http://cameleer3-server:8081}" SERVER_UI_USER="${SERVER_UI_USER:-admin}" SERVER_UI_PASS="${SERVER_UI_PASS:-admin}" # Redirect URIs (derived from PUBLIC_HOST and PUBLIC_PROTOCOL) HOST="${PUBLIC_HOST:-localhost}" PROTO="${PUBLIC_PROTOCOL:-https}" SPA_REDIRECT_URIS="[\"${PROTO}://${HOST}/platform/callback\"]" SPA_POST_LOGOUT_URIS="[\"${PROTO}://${HOST}/platform/login\"]" TRAD_REDIRECT_URIS="[\"${PROTO}://${HOST}/oidc/callback\",\"${PROTO}://${HOST}/server/oidc/callback\"]" TRAD_POST_LOGOUT_URIS="[\"${PROTO}://${HOST}\",\"${PROTO}://${HOST}/server\",\"${PROTO}://${HOST}/server/login?local\"]" log() { echo "[bootstrap] $1"; } pgpass() { PGPASSWORD="${PG_PASSWORD:-cameleer_dev}"; export PGPASSWORD; } # Install jq + curl apk add --no-cache jq curl >/dev/null 2>&1 # Read cached secrets from previous run if [ -f "$BOOTSTRAP_FILE" ]; then CACHED_M2M_SECRET=$(jq -r '.m2mClientSecret // empty' "$BOOTSTRAP_FILE" 2>/dev/null) CACHED_TRAD_SECRET=$(jq -r '.tradAppSecret // empty' "$BOOTSTRAP_FILE" 2>/dev/null) CACHED_SPA_ID=$(jq -r '.spaClientId // empty' "$BOOTSTRAP_FILE" 2>/dev/null) log "Found cached bootstrap file" if [ -n "$CACHED_M2M_SECRET" ] && [ -n "$CACHED_SPA_ID" ]; then log "Bootstrap already complete — skipping. Delete $BOOTSTRAP_FILE to force re-run." exit 0 fi fi # ============================================================ # PHASE 1: Wait for services # ============================================================ log "Waiting for Logto..." for i in $(seq 1 60); do if curl -sf "${LOGTO_ENDPOINT}/oidc/.well-known/openid-configuration" >/dev/null 2>&1; then log "Logto is ready." break fi [ "$i" -eq 60 ] && { log "ERROR: Logto not ready after 60s"; exit 1; } sleep 1 done log "Waiting for cameleer3-server..." for i in $(seq 1 60); do if curl -sf "${SERVER_ENDPOINT}/api/v1/health" >/dev/null 2>&1; then log "cameleer3-server is ready." break fi [ "$i" -eq 60 ] && { log "WARNING: cameleer3-server not ready after 60s — skipping OIDC config"; } sleep 1 done # ============================================================ # PHASE 2: Get Management API token # ============================================================ log "Reading m-default secret from database..." pgpass M_DEFAULT_SECRET=$(psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB_LOGTO" -t -A -c \ "SELECT secret FROM applications WHERE id = 'm-default' AND tenant_id = 'admin';") [ -z "$M_DEFAULT_SECRET" ] && { log "ERROR: m-default app not found"; exit 1; } get_admin_token() { curl -s -X POST "${LOGTO_ADMIN_ENDPOINT}/oidc/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -H "Host: ${HOST}:3002" \ -H "X-Forwarded-Proto: https" \ -d "grant_type=client_credentials&client_id=${1}&client_secret=${2}&resource=${MGMT_API_RESOURCE}&scope=all" } get_default_token() { curl -s -X POST "${LOGTO_ENDPOINT}/oidc/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -H "Host: ${HOST}" \ -d "grant_type=client_credentials&client_id=${1}&client_secret=${2}&resource=${MGMT_API_RESOURCE}&scope=all" } log "Getting Management API token..." TOKEN_RESPONSE=$(get_admin_token "m-default" "$M_DEFAULT_SECRET") TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token' 2>/dev/null) [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ] && { log "ERROR: Failed to get token"; exit 1; } log "Got Management API token." # Verify Management API is fully ready (Logto may still be initializing internally) log "Verifying Management API is responsive..." for i in $(seq 1 30); do VERIFY_RESPONSE=$(curl -s -H "Authorization: Bearer $TOKEN" -H "Host: ${HOST}" "${LOGTO_ENDPOINT}/api/roles" 2>/dev/null) if echo "$VERIFY_RESPONSE" | jq -e 'type == "array"' >/dev/null 2>&1; then log "Management API is ready." break fi [ "$i" -eq 30 ] && { log "ERROR: Management API not responsive after 30s"; exit 1; } sleep 1 done # --- Helper: Logto API calls --- api_get() { curl -s -H "Authorization: Bearer $TOKEN" -H "Host: ${HOST}" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || echo "[]" } api_post() { curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -H "Host: ${HOST}" \ -d "$2" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true } api_put() { curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -H "Host: ${HOST}" \ -d "$2" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true } api_delete() { curl -s -X DELETE -H "Authorization: Bearer $TOKEN" -H "Host: ${HOST}" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true } api_patch() { curl -s -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -H "Host: ${HOST}" \ -d "$2" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true } # ============================================================ # PHASE 3: Create Logto applications # ============================================================ EXISTING_APPS=$(api_get "/api/applications") # --- SPA app (for SaaS frontend) --- SPA_ID=$(echo "$EXISTING_APPS" | jq -r ".[] | select(.name == \"$SPA_APP_NAME\" and .type == \"SPA\") | .id") if [ -n "$SPA_ID" ]; then log "SPA app exists: $SPA_ID" else log "Creating SPA app..." SPA_RESPONSE=$(api_post "/api/applications" "{ \"name\": \"$SPA_APP_NAME\", \"type\": \"SPA\", \"oidcClientMetadata\": { \"redirectUris\": $SPA_REDIRECT_URIS, \"postLogoutRedirectUris\": $SPA_POST_LOGOUT_URIS } }") SPA_ID=$(echo "$SPA_RESPONSE" | jq -r '.id') log "Created SPA app: $SPA_ID" fi # --- Traditional Web App (for cameleer3-server OIDC) --- TRAD_ID=$(echo "$EXISTING_APPS" | jq -r ".[] | select(.name == \"$TRAD_APP_NAME\" and .type == \"Traditional\") | .id") TRAD_SECRET="" if [ -n "$TRAD_ID" ]; then log "Traditional app exists: $TRAD_ID" TRAD_SECRET="${CACHED_TRAD_SECRET:-}" else log "Creating Traditional Web app..." TRAD_RESPONSE=$(api_post "/api/applications" "{ \"name\": \"$TRAD_APP_NAME\", \"type\": \"Traditional\", \"oidcClientMetadata\": { \"redirectUris\": $TRAD_REDIRECT_URIS, \"postLogoutRedirectUris\": $TRAD_POST_LOGOUT_URIS } }") TRAD_ID=$(echo "$TRAD_RESPONSE" | jq -r '.id') TRAD_SECRET=$(echo "$TRAD_RESPONSE" | jq -r '.secret') [ "$TRAD_SECRET" = "null" ] && TRAD_SECRET="" log "Created Traditional app: $TRAD_ID" fi # Enable skip consent for the Traditional app (first-party SSO) api_put "/api/applications/$TRAD_ID" '{"isThirdParty": false, "customClientMetadata": {"alwaysIssueRefreshToken": true, "skipConsent": true}}' >/dev/null 2>&1 log "Traditional app: skip consent enabled." # --- API resource --- EXISTING_RESOURCES=$(api_get "/api/resources") API_RESOURCE_ID=$(echo "$EXISTING_RESOURCES" | jq -r ".[] | select(.indicator == \"$API_RESOURCE_INDICATOR\") | .id") if [ -n "$API_RESOURCE_ID" ]; then log "API resource exists: $API_RESOURCE_ID" else log "Creating API resource..." RESOURCE_RESPONSE=$(api_post "/api/resources" "{ \"name\": \"$API_RESOURCE_NAME\", \"indicator\": \"$API_RESOURCE_INDICATOR\" }") API_RESOURCE_ID=$(echo "$RESOURCE_RESPONSE" | jq -r '.id') log "Created API resource: $API_RESOURCE_ID" fi # ============================================================ # PHASE 3b: Create API resource scopes # ============================================================ log "Creating API resource scopes..." EXISTING_SCOPES=$(api_get "/api/resources/${API_RESOURCE_ID}/scopes") create_scope() { local name="$1" local desc="$2" local existing_id=$(echo "$EXISTING_SCOPES" | jq -r ".[] | select(.name == \"$name\") | .id") if [ -n "$existing_id" ]; then log " Scope '$name' exists: $existing_id" >&2 echo "$existing_id" else local resp=$(api_post "/api/resources/${API_RESOURCE_ID}/scopes" "{\"name\": \"$name\", \"description\": \"$desc\"}") local new_id=$(echo "$resp" | jq -r '.id') log " Created scope '$name': $new_id" >&2 echo "$new_id" fi } # Platform-level scope SCOPE_PLATFORM_ADMIN=$(create_scope "platform:admin" "SaaS platform administration") # Tenant-level scopes SCOPE_TENANT_MANAGE=$(create_scope "tenant:manage" "Manage tenant settings") SCOPE_BILLING_MANAGE=$(create_scope "billing:manage" "Manage billing") SCOPE_TEAM_MANAGE=$(create_scope "team:manage" "Manage team members") SCOPE_APPS_MANAGE=$(create_scope "apps:manage" "Create and delete apps") SCOPE_APPS_DEPLOY=$(create_scope "apps:deploy" "Deploy apps") SCOPE_SECRETS_MANAGE=$(create_scope "secrets:manage" "Manage secrets") SCOPE_OBSERVE_READ=$(create_scope "observe:read" "View observability data") SCOPE_OBSERVE_DEBUG=$(create_scope "observe:debug" "Debug and replay operations") SCOPE_SETTINGS_MANAGE=$(create_scope "settings:manage" "Manage settings") # Server-level scopes (mapped to server RBAC roles via JWT scope claim) SCOPE_SERVER_ADMIN=$(create_scope "server:admin" "Full server access") SCOPE_SERVER_OPERATOR=$(create_scope "server:operator" "Deploy and manage apps in server") SCOPE_SERVER_VIEWER=$(create_scope "server:viewer" "Read-only server observability") # Collect scope IDs for role assignment # Owner: full tenant control OWNER_SCOPE_IDS="\"$SCOPE_TENANT_MANAGE\",\"$SCOPE_BILLING_MANAGE\",\"$SCOPE_TEAM_MANAGE\",\"$SCOPE_APPS_MANAGE\",\"$SCOPE_APPS_DEPLOY\",\"$SCOPE_SECRETS_MANAGE\",\"$SCOPE_OBSERVE_READ\",\"$SCOPE_OBSERVE_DEBUG\",\"$SCOPE_SETTINGS_MANAGE\",\"$SCOPE_SERVER_ADMIN\"" # Operator: app lifecycle + observability (no billing/team/secrets/settings) OPERATOR_SCOPE_IDS="\"$SCOPE_APPS_MANAGE\",\"$SCOPE_APPS_DEPLOY\",\"$SCOPE_OBSERVE_READ\",\"$SCOPE_OBSERVE_DEBUG\",\"$SCOPE_SERVER_OPERATOR\"" # Viewer: read-only observability VIEWER_SCOPE_IDS="\"$SCOPE_OBSERVE_READ\",\"$SCOPE_SERVER_VIEWER\"" # Vendor (saas-vendor global role): platform:admin + all tenant scopes ALL_SCOPE_IDS="\"$SCOPE_PLATFORM_ADMIN\",$OWNER_SCOPE_IDS" # --- M2M app --- M2M_ID=$(echo "$EXISTING_APPS" | jq -r ".[] | select(.name == \"$M2M_APP_NAME\" and .type == \"MachineToMachine\") | .id") M2M_SECRET="" if [ -n "$M2M_ID" ]; then log "M2M app exists: $M2M_ID" M2M_SECRET="${CACHED_M2M_SECRET:-}" else log "Creating M2M app..." M2M_RESPONSE=$(api_post "/api/applications" "{ \"name\": \"$M2M_APP_NAME\", \"type\": \"MachineToMachine\" }") M2M_ID=$(echo "$M2M_RESPONSE" | jq -r '.id') M2M_SECRET=$(echo "$M2M_RESPONSE" | jq -r '.secret') log "Created M2M app: $M2M_ID" # Assign Management API role log "Assigning Management API access to M2M app..." pgpass MGMT_RESOURCE_ID=$(psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB_LOGTO" -t -A -c \ "SELECT id FROM resources WHERE indicator = '$MGMT_API_RESOURCE' AND tenant_id = 'default';") if [ -n "$MGMT_RESOURCE_ID" ]; then SCOPE_IDS=$(psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB_LOGTO" -t -A -c \ "SELECT json_agg(id) FROM scopes WHERE resource_id = '$MGMT_RESOURCE_ID' AND tenant_id = 'default';" | tr -d '[:space:]') ROLE_RESPONSE=$(api_post "/api/roles" "{ \"name\": \"cameleer-m2m-management\", \"description\": \"Full Management API access for Cameleer SaaS\", \"type\": \"MachineToMachine\", \"scopeIds\": $SCOPE_IDS }") ROLE_ID=$(echo "$ROLE_RESPONSE" | jq -r '.id') if [ -n "$ROLE_ID" ] && [ "$ROLE_ID" != "null" ]; then api_post "/api/roles/$ROLE_ID/applications" "{\"applicationIds\": [\"$M2M_ID\"]}" >/dev/null log "Assigned Management API role to M2M app." VERIFY=$(get_default_token "$M2M_ID" "$M2M_SECRET") VERIFY_TOKEN=$(echo "$VERIFY" | jq -r '.access_token') if [ -n "$VERIFY_TOKEN" ] && [ "$VERIFY_TOKEN" != "null" ]; then log "Verified M2M app works." else log "WARNING: M2M verification failed" M2M_SECRET="" fi fi fi fi # Create M2M role for the Cameleer API resource (server:admin access) — idempotent EXISTING_M2M_SERVER_ROLE=$(api_get "/api/roles" | jq -r '.[] | select(.name == "cameleer-m2m-server") | .id') if [ -z "$EXISTING_M2M_SERVER_ROLE" ]; then log "Creating M2M server access role..." SERVER_M2M_ROLE_RESPONSE=$(api_post "/api/roles" "{ \"name\": \"cameleer-m2m-server\", \"description\": \"Server API access for SaaS backend (M2M)\", \"type\": \"MachineToMachine\", \"scopeIds\": [\"$SCOPE_SERVER_ADMIN\"] }") EXISTING_M2M_SERVER_ROLE=$(echo "$SERVER_M2M_ROLE_RESPONSE" | jq -r '.id') fi if [ -n "$EXISTING_M2M_SERVER_ROLE" ] && [ "$EXISTING_M2M_SERVER_ROLE" != "null" ] && [ -n "$M2M_ID" ]; then api_post "/api/roles/$EXISTING_M2M_SERVER_ROLE/applications" "{\"applicationIds\": [\"$M2M_ID\"]}" >/dev/null 2>&1 log "Assigned server API role to M2M app: $EXISTING_M2M_SERVER_ROLE" fi # ============================================================ # PHASE 4: Create roles # ============================================================ # --- Organization roles: owner, operator, viewer --- # Note: platform-admin / saas-vendor global role is NOT created here. # It is injected via docker/vendor-seed.sh on the hosted SaaS environment only. log "Creating organization roles..." EXISTING_ORG_ROLES=$(api_get "/api/organization-roles") ORG_OWNER_ROLE_ID=$(echo "$EXISTING_ORG_ROLES" | jq -r '.[] | select(.name == "owner") | .id') if [ -n "$ORG_OWNER_ROLE_ID" ]; then log "Org owner role exists: $ORG_OWNER_ROLE_ID" else ORG_OWNER_RESPONSE=$(api_post "/api/organization-roles" "{ \"name\": \"owner\", \"description\": \"Platform owner — full tenant control\" }") ORG_OWNER_ROLE_ID=$(echo "$ORG_OWNER_RESPONSE" | jq -r '.id') log "Created org owner role: $ORG_OWNER_ROLE_ID" fi ORG_OPERATOR_ROLE_ID=$(echo "$EXISTING_ORG_ROLES" | jq -r '.[] | select(.name == "operator") | .id') if [ -z "$ORG_OPERATOR_ROLE_ID" ]; then ORG_OPERATOR_RESPONSE=$(api_post "/api/organization-roles" "{ \"name\": \"operator\", \"description\": \"Operator — manage apps, deploy, observe\" }") ORG_OPERATOR_ROLE_ID=$(echo "$ORG_OPERATOR_RESPONSE" | jq -r '.id') log "Created org operator role: $ORG_OPERATOR_ROLE_ID" fi ORG_VIEWER_ROLE_ID=$(echo "$EXISTING_ORG_ROLES" | jq -r '.[] | select(.name == "viewer") | .id') if [ -z "$ORG_VIEWER_ROLE_ID" ]; then ORG_VIEWER_RESPONSE=$(api_post "/api/organization-roles" "{ \"name\": \"viewer\", \"description\": \"Viewer — read-only observability\" }") ORG_VIEWER_ROLE_ID=$(echo "$ORG_VIEWER_RESPONSE" | jq -r '.id') log "Created org viewer role: $ORG_VIEWER_ROLE_ID" fi # Assign API resource scopes to org roles (these appear in org-scoped resource tokens) log "Assigning API resource scopes to organization roles..." api_put "/api/organization-roles/${ORG_OWNER_ROLE_ID}/resource-scopes" "{\"scopeIds\": [$OWNER_SCOPE_IDS]}" >/dev/null 2>&1 api_put "/api/organization-roles/${ORG_OPERATOR_ROLE_ID}/resource-scopes" "{\"scopeIds\": [$OPERATOR_SCOPE_IDS]}" >/dev/null 2>&1 api_put "/api/organization-roles/${ORG_VIEWER_ROLE_ID}/resource-scopes" "{\"scopeIds\": [$VIEWER_SCOPE_IDS]}" >/dev/null 2>&1 log "API resource scopes assigned to organization roles." # ============================================================ # PHASE 5: Create users # ============================================================ # --- Platform Owner --- log "Checking for platform owner user '$SAAS_ADMIN_USER'..." ADMIN_USER_ID=$(api_get "/api/users?search=$SAAS_ADMIN_USER" | jq -r ".[] | select(.username == \"$SAAS_ADMIN_USER\") | .id") if [ -n "$ADMIN_USER_ID" ]; then log "Platform owner exists: $ADMIN_USER_ID" else log "Creating platform owner '$SAAS_ADMIN_USER'..." ADMIN_RESPONSE=$(api_post "/api/users" "{ \"username\": \"$SAAS_ADMIN_USER\", \"password\": \"$SAAS_ADMIN_PASS\", \"name\": \"Platform Owner\" }") ADMIN_USER_ID=$(echo "$ADMIN_RESPONSE" | jq -r '.id') log "Created platform owner: $ADMIN_USER_ID" # No global role assigned — owner role is org-scoped. # SaaS vendor role is injected via docker/vendor-seed.sh on hosted environments. fi # --- Grant SaaS admin Logto console access (admin tenant, port 3002) --- log "Granting SaaS admin Logto console access..." # Get admin-tenant M2M token (m-default token has wrong audience for port 3002) ADMIN_MGMT_RESOURCE="https://admin.logto.app/api" log "Reading m-admin secret from database..." M_ADMIN_SECRET=$(psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB_LOGTO" -t -A -c \ "SELECT secret FROM applications WHERE id = 'm-admin' AND tenant_id = 'admin';" 2>/dev/null) if [ -z "$M_ADMIN_SECRET" ]; then log "WARNING: m-admin app not found — skipping console access" else ADMIN_TOKEN_RESPONSE=$(curl -s -X POST "${LOGTO_ADMIN_ENDPOINT}/oidc/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -H "Host: ${HOST}:3002" \ -H "X-Forwarded-Proto: https" \ -d "grant_type=client_credentials&client_id=m-admin&client_secret=${M_ADMIN_SECRET}&resource=${ADMIN_MGMT_RESOURCE}&scope=all") ADMIN_TOKEN=$(echo "$ADMIN_TOKEN_RESPONSE" | jq -r '.access_token' 2>/dev/null) if [ -z "$ADMIN_TOKEN" ] || [ "$ADMIN_TOKEN" = "null" ]; then log "WARNING: Failed to get admin tenant token — skipping console access" log "Response: $(echo "$ADMIN_TOKEN_RESPONSE" | head -c 200)" else log "Got admin tenant token." # Admin-tenant API helpers (port 3002, admin token) admin_api_get() { curl -s -H "Authorization: Bearer $ADMIN_TOKEN" -H "Host: ${HOST}:3002" -H "X-Forwarded-Proto: https" "${LOGTO_ADMIN_ENDPOINT}${1}" 2>/dev/null || echo "[]" } admin_api_post() { curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" -H "Host: ${HOST}:3002" -H "X-Forwarded-Proto: https" \ -d "$2" "${LOGTO_ADMIN_ENDPOINT}${1}" 2>/dev/null || true } admin_api_patch() { curl -s -X PATCH -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" -H "Host: ${HOST}:3002" -H "X-Forwarded-Proto: https" \ -d "$2" "${LOGTO_ADMIN_ENDPOINT}${1}" 2>/dev/null || true } # Check if admin user already exists on admin tenant ADMIN_TENANT_USER_ID=$(admin_api_get "/api/users?search=$SAAS_ADMIN_USER" | jq -r ".[] | select(.username == \"$SAAS_ADMIN_USER\") | .id" 2>/dev/null) if [ -z "$ADMIN_TENANT_USER_ID" ] || [ "$ADMIN_TENANT_USER_ID" = "null" ]; then log "Creating admin console user '$SAAS_ADMIN_USER'..." ADMIN_TENANT_RESPONSE=$(admin_api_post "/api/users" "{ \"username\": \"$SAAS_ADMIN_USER\", \"password\": \"$SAAS_ADMIN_PASS\", \"name\": \"Platform Admin\" }") ADMIN_TENANT_USER_ID=$(echo "$ADMIN_TENANT_RESPONSE" | jq -r '.id') log "Created admin console user: $ADMIN_TENANT_USER_ID" else log "Admin console user exists: $ADMIN_TENANT_USER_ID" fi if [ -n "$ADMIN_TENANT_USER_ID" ] && [ "$ADMIN_TENANT_USER_ID" != "null" ]; then # Assign both 'user' (required base role) and 'default:admin' (Management API access) ADMIN_USER_ROLE_ID=$(admin_api_get "/api/roles" | jq -r '.[] | select(.name == "user") | .id') ADMIN_ROLE_ID=$(admin_api_get "/api/roles" | jq -r '.[] | select(.name == "default:admin") | .id') ROLE_IDS_JSON="[]" if [ -n "$ADMIN_USER_ROLE_ID" ] && [ "$ADMIN_USER_ROLE_ID" != "null" ]; then ROLE_IDS_JSON=$(echo "$ROLE_IDS_JSON" | jq ". + [\"$ADMIN_USER_ROLE_ID\"]") fi if [ -n "$ADMIN_ROLE_ID" ] && [ "$ADMIN_ROLE_ID" != "null" ]; then ROLE_IDS_JSON=$(echo "$ROLE_IDS_JSON" | jq ". + [\"$ADMIN_ROLE_ID\"]") fi if [ "$ROLE_IDS_JSON" != "[]" ]; then admin_api_post "/api/users/$ADMIN_TENANT_USER_ID/roles" "{\"roleIds\": $ROLE_IDS_JSON}" >/dev/null 2>&1 log "Assigned admin tenant roles (user + default:admin)." else log "WARNING: admin tenant roles not found" fi # Add to t-default organization with admin role admin_api_post "/api/organizations/t-default/users" "{\"userIds\": [\"$ADMIN_TENANT_USER_ID\"]}" >/dev/null 2>&1 TENANT_ADMIN_ORG_ROLE_ID=$(admin_api_get "/api/organization-roles" | jq -r '.[] | select(.name == "admin") | .id') if [ -n "$TENANT_ADMIN_ORG_ROLE_ID" ] && [ "$TENANT_ADMIN_ORG_ROLE_ID" != "null" ]; then admin_api_post "/api/organizations/t-default/users/$ADMIN_TENANT_USER_ID/roles" "{\"organizationRoleIds\": [\"$TENANT_ADMIN_ORG_ROLE_ID\"]}" >/dev/null 2>&1 log "Added to t-default organization with admin role." fi # Switch admin tenant sign-in mode from Register to SignIn (user already created) admin_api_patch "/api/sign-in-exp" '{"signInMode": "SignIn"}' >/dev/null 2>&1 log "Set admin tenant sign-in mode to SignIn." log "SaaS admin granted Logto console access." else log "WARNING: Could not create admin console user" fi fi # end: ADMIN_TOKEN check fi # end: M_ADMIN_SECRET check # --- Viewer user (for testing read-only OIDC role in server) --- log "Checking for viewer user '$TENANT_ADMIN_USER'..." TENANT_USER_ID=$(api_get "/api/users?search=$TENANT_ADMIN_USER" | jq -r ".[] | select(.username == \"$TENANT_ADMIN_USER\") | .id") if [ -n "$TENANT_USER_ID" ]; then log "Viewer user exists: $TENANT_USER_ID" else log "Creating viewer user '$TENANT_ADMIN_USER'..." TENANT_RESPONSE=$(api_post "/api/users" "{ \"username\": \"$TENANT_ADMIN_USER\", \"password\": \"$TENANT_ADMIN_PASS\", \"name\": \"Viewer\" }") TENANT_USER_ID=$(echo "$TENANT_RESPONSE" | jq -r '.id') log "Created viewer user: $TENANT_USER_ID" fi # ============================================================ # PHASE 6: Create organization + add users # ============================================================ log "Checking for organization '$TENANT_NAME'..." EXISTING_ORGS=$(api_get "/api/organizations") ORG_ID=$(echo "$EXISTING_ORGS" | jq -r ".[] | select(.name == \"$TENANT_NAME\") | .id") if [ -n "$ORG_ID" ]; then log "Organization exists: $ORG_ID" else log "Creating organization '$TENANT_NAME'..." ORG_RESPONSE=$(api_post "/api/organizations" "{ \"name\": \"$TENANT_NAME\", \"description\": \"Bootstrap demo tenant\" }") ORG_ID=$(echo "$ORG_RESPONSE" | jq -r '.id') log "Created organization: $ORG_ID" fi # Add users to organization if [ -n "$ADMIN_USER_ID" ] && [ "$ADMIN_USER_ID" != "null" ]; then log "Adding platform owner to organization..." api_post "/api/organizations/$ORG_ID/users" "{\"userIds\": [\"$ADMIN_USER_ID\"]}" >/dev/null 2>&1 api_put "/api/organizations/$ORG_ID/users/$ADMIN_USER_ID/roles" "{\"organizationRoleIds\": [\"$ORG_OWNER_ROLE_ID\"]}" >/dev/null 2>&1 log "Platform owner added to org with owner role." fi if [ -n "$TENANT_USER_ID" ] && [ "$TENANT_USER_ID" != "null" ]; then log "Adding viewer user to organization..." api_post "/api/organizations/$ORG_ID/users" "{\"userIds\": [\"$TENANT_USER_ID\"]}" >/dev/null 2>&1 api_put "/api/organizations/$ORG_ID/users/$TENANT_USER_ID/roles" "{\"organizationRoleIds\": [\"$ORG_VIEWER_ROLE_ID\"]}" >/dev/null 2>&1 log "Viewer user added to org with viewer role." fi # ============================================================ # PHASE 7: Configure cameleer3-server OIDC # ============================================================ SERVER_HEALTHY="no" for i in 1 2 3; do if curl -sf "${SERVER_ENDPOINT}/api/v1/health" >/dev/null 2>&1; then SERVER_HEALTHY="yes" break fi sleep 2 done log "Phase 7 check: SERVER_HEALTHY=$SERVER_HEALTHY, TRAD_SECRET length=${#TRAD_SECRET}" if [ "$SERVER_HEALTHY" = "yes" ] && [ -n "$TRAD_SECRET" ]; then log "Configuring cameleer3-server OIDC..." # Login to server as admin SERVER_TOKEN_RESPONSE=$(curl -s -X POST "${SERVER_ENDPOINT}/api/v1/auth/login" \ -H "Content-Type: application/json" \ -d "{\"username\": \"$SERVER_UI_USER\", \"password\": \"$SERVER_UI_PASS\"}") SERVER_TOKEN=$(echo "$SERVER_TOKEN_RESPONSE" | jq -r '.accessToken' 2>/dev/null) if [ -n "$SERVER_TOKEN" ] && [ "$SERVER_TOKEN" != "null" ]; then # Configure OIDC OIDC_RESPONSE=$(curl -s -X PUT "${SERVER_ENDPOINT}/api/v1/admin/oidc" \ -H "Authorization: Bearer $SERVER_TOKEN" \ -H "Content-Type: application/json" \ -d "{ \"enabled\": true, \"issuerUri\": \"$LOGTO_PUBLIC_ENDPOINT/oidc\", \"clientId\": \"$TRAD_ID\", \"clientSecret\": \"$TRAD_SECRET\", \"autoSignup\": true, \"defaultRoles\": [\"VIEWER\"], \"displayNameClaim\": \"name\", \"rolesClaim\": \"roles\", \"audience\": \"$API_RESOURCE_INDICATOR\", \"additionalScopes\": [] }") log "OIDC config response: $(echo "$OIDC_RESPONSE" | head -c 200)" log "cameleer3-server OIDC configured." # Seed claim mapping rules (roles → server RBAC) log "Seeding claim mapping rules..." EXISTING_MAPPINGS=$(curl -s -H "Authorization: Bearer $SERVER_TOKEN" \ "${SERVER_ENDPOINT}/api/v1/admin/claim-mappings" 2>/dev/null || echo "[]") seed_claim_mapping() { local match_value="$1" local target="$2" local priority="$3" local exists=$(echo "$EXISTING_MAPPINGS" | jq -r ".[] | select(.matchValue == \"$match_value\") | .id") if [ -n "$exists" ]; then log " Claim mapping '$match_value' → $target exists" else local resp=$(curl -s -X POST "${SERVER_ENDPOINT}/api/v1/admin/claim-mappings" \ -H "Authorization: Bearer $SERVER_TOKEN" \ -H "Content-Type: application/json" \ -d "{\"claim\":\"roles\",\"matchType\":\"contains\",\"matchValue\":\"$match_value\",\"action\":\"assignRole\",\"target\":\"$target\",\"priority\":$priority}") log " Created claim mapping '$match_value' → $target" fi } seed_claim_mapping "server:admin" "ADMIN" 10 seed_claim_mapping "server:operator" "OPERATOR" 20 log "Claim mapping rules seeded." else log "WARNING: Could not login to cameleer3-server — skipping OIDC config" fi else log "WARNING: cameleer3-server not available or no Traditional app secret — skipping OIDC config" fi # ============================================================ # PHASE 7b: Configure Logto Custom JWT for access tokens # ============================================================ # Adds a 'roles' claim to access tokens based on user's org roles and global roles. # This allows the server to extract roles from the access token using rolesClaim: "roles". log "Configuring Logto Custom JWT for access tokens..." CUSTOM_JWT_SCRIPT='const getCustomJwtClaims = async ({ token, context, environmentVariables }) => { const roleMap = { owner: "server:admin", operator: "server:operator", viewer: "server:viewer" }; const roles = new Set(); if (context?.user?.organizationRoles) { for (const orgRole of context.user.organizationRoles) { const mapped = roleMap[orgRole.roleName]; if (mapped) roles.add(mapped); } } if (context?.user?.roles) { for (const role of context.user.roles) { if (role.name === "saas-vendor") roles.add("server:admin"); } } return roles.size > 0 ? { roles: [...roles] } : {}; };' CUSTOM_JWT_PAYLOAD=$(jq -n --arg script "$CUSTOM_JWT_SCRIPT" '{ script: $script }') CUSTOM_JWT_RESPONSE=$(api_put "/api/configs/jwt-customizer/access-token" "$CUSTOM_JWT_PAYLOAD" 2>&1) if echo "$CUSTOM_JWT_RESPONSE" | jq -e '.script' >/dev/null 2>&1; then log "Custom JWT configured for access tokens." else log "WARNING: Custom JWT configuration failed — server OIDC login may fall back to local roles" log "Response: $(echo "$CUSTOM_JWT_RESPONSE" | head -c 200)" fi # ============================================================ # PHASE 8: Configure sign-in branding # ============================================================ log "Configuring sign-in experience branding..." api_patch "/api/sign-in-exp" "{ \"color\": { \"primaryColor\": \"#C6820E\", \"isDarkModeEnabled\": true, \"darkPrimaryColor\": \"#D4941E\" }, \"branding\": { \"logoUrl\": \"${PROTO}://${HOST}/platform/logo.svg\", \"darkLogoUrl\": \"${PROTO}://${HOST}/platform/logo-dark.svg\" } }" log "Sign-in branding configured." # ============================================================ # PHASE 9: Cleanup seeded apps # ============================================================ if [ -n "$M2M_SECRET" ]; then log "Cleaning up seeded apps with known secrets..." for SEEDED_ID in "m-default" "m-admin" "s6cz3wajdv8gtdyz8e941"; do if echo "$EXISTING_APPS" | jq -e ".[] | select(.id == \"$SEEDED_ID\")" >/dev/null 2>&1; then api_delete "/api/applications/$SEEDED_ID" log "Deleted seeded app: $SEEDED_ID" fi done fi # ============================================================ # PHASE 10: Write bootstrap results # ============================================================ log "Writing bootstrap config to $BOOTSTRAP_FILE..." mkdir -p "$(dirname "$BOOTSTRAP_FILE")" cat > "$BOOTSTRAP_FILE" <