#!/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) log "Found cached bootstrap file" 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" \ -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." # --- 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 ALL_TENANT_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\"" ALL_SCOPE_IDS="\"$SCOPE_PLATFORM_ADMIN\",$ALL_TENANT_SCOPE_IDS" MEMBER_SCOPE_IDS="\"$SCOPE_APPS_DEPLOY\",\"$SCOPE_OBSERVE_READ\",\"$SCOPE_OBSERVE_DEBUG\",\"$SCOPE_SERVER_VIEWER\"" # --- 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 # ============================================================ # PHASE 4: Create roles # ============================================================ # --- Global platform-admin role --- log "Creating platform-admin role..." EXISTING_ROLES=$(api_get "/api/roles") PA_ROLE_ID=$(echo "$EXISTING_ROLES" | jq -r '.[] | select(.name == "platform-admin" and .type == "User") | .id') if [ -n "$PA_ROLE_ID" ]; then log "platform-admin role exists: $PA_ROLE_ID" # Ensure scopes are assigned (idempotent) api_post "/api/roles/${PA_ROLE_ID}/scopes" "{\"scopeIds\": [$ALL_SCOPE_IDS]}" >/dev/null 2>&1 else PA_RESPONSE=$(api_post "/api/roles" "{ \"name\": \"platform-admin\", \"description\": \"SaaS platform administrator\", \"type\": \"User\", \"scopeIds\": [$ALL_SCOPE_IDS] }") PA_ROLE_ID=$(echo "$PA_RESPONSE" | jq -r '.id') log "Created platform-admin role: $PA_ROLE_ID" fi # --- Organization roles --- log "Creating organization roles..." EXISTING_ORG_ROLES=$(api_get "/api/organization-roles") ORG_ADMIN_ROLE_ID=$(echo "$EXISTING_ORG_ROLES" | jq -r '.[] | select(.name == "admin") | .id') if [ -n "$ORG_ADMIN_ROLE_ID" ]; then log "Org admin role exists: $ORG_ADMIN_ROLE_ID" else ORG_ADMIN_RESPONSE=$(api_post "/api/organization-roles" "{ \"name\": \"admin\", \"description\": \"Tenant administrator\" }") ORG_ADMIN_ROLE_ID=$(echo "$ORG_ADMIN_RESPONSE" | jq -r '.id') log "Created org admin role: $ORG_ADMIN_ROLE_ID" fi ORG_MEMBER_ROLE_ID=$(echo "$EXISTING_ORG_ROLES" | jq -r '.[] | select(.name == "member") | .id') if [ -z "$ORG_MEMBER_ROLE_ID" ]; then ORG_MEMBER_RESPONSE=$(api_post "/api/organization-roles" "{ \"name\": \"member\", \"description\": \"Tenant member\" }") ORG_MEMBER_ROLE_ID=$(echo "$ORG_MEMBER_RESPONSE" | jq -r '.id') log "Created org member role: $ORG_MEMBER_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_ADMIN_ROLE_ID}/resource-scopes" "{\"scopeIds\": [$ALL_TENANT_SCOPE_IDS]}" >/dev/null 2>&1 api_put "/api/organization-roles/${ORG_MEMBER_ROLE_ID}/resource-scopes" "{\"scopeIds\": [$MEMBER_SCOPE_IDS]}" >/dev/null 2>&1 log "API resource scopes assigned to organization roles." # ============================================================ # PHASE 5: Create users # ============================================================ # --- SaaS Owner --- log "Checking for SaaS 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 "SaaS owner exists: $ADMIN_USER_ID" else log "Creating SaaS owner '$SAAS_ADMIN_USER'..." ADMIN_RESPONSE=$(api_post "/api/users" "{ \"username\": \"$SAAS_ADMIN_USER\", \"password\": \"$SAAS_ADMIN_PASS\", \"name\": \"Platform Admin\" }") ADMIN_USER_ID=$(echo "$ADMIN_RESPONSE" | jq -r '.id') log "Created SaaS owner: $ADMIN_USER_ID" # Assign platform-admin role if [ -n "$PA_ROLE_ID" ] && [ "$PA_ROLE_ID" != "null" ]; then api_post "/api/users/$ADMIN_USER_ID/roles" "{\"roleIds\": [\"$PA_ROLE_ID\"]}" >/dev/null log "Assigned platform-admin role to SaaS owner." fi 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" \ -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" "${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" \ -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" \ -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 # --- Tenant Admin --- log "Checking for tenant admin '$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 "Tenant admin exists: $TENANT_USER_ID" else log "Creating tenant admin '$TENANT_ADMIN_USER'..." TENANT_RESPONSE=$(api_post "/api/users" "{ \"username\": \"$TENANT_ADMIN_USER\", \"password\": \"$TENANT_ADMIN_PASS\", \"name\": \"Tenant Admin\" }") TENANT_USER_ID=$(echo "$TENANT_RESPONSE" | jq -r '.id') log "Created tenant admin: $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 "$TENANT_USER_ID" ] && [ "$TENANT_USER_ID" != "null" ]; then log "Adding tenant 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_MEMBER_ROLE_ID\"]}" >/dev/null 2>&1 log "Tenant user added to org with member role." fi if [ -n "$ADMIN_USER_ID" ] && [ "$ADMIN_USER_ID" != "null" ]; then log "Adding SaaS 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_ADMIN_ROLE_ID\"]}" >/dev/null 2>&1 log "SaaS owner added to org with admin 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\": \"scope\", \"audience\": \"$API_RESOURCE_INDICATOR\" }") log "OIDC config response: $(echo "$OIDC_RESPONSE" | head -c 200)" log "cameleer3-server OIDC configured." 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 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" <