Files
cameleer-saas/docker/logto-bootstrap.sh
hsiegeln b3ac8a6bcc
All checks were successful
CI / build (push) Successful in 47s
CI / docker (push) Successful in 8s
fix: set admin tenant sign-in mode to SignIn after user creation
Admin tenant defaults to Register mode (onboarding flow). Since we
create the admin user via API, we need to switch to SignIn mode so
the custom sign-in UI can authenticate against the admin console.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:46:36 +02:00

633 lines
25 KiB
Bash

#!/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" <<EOF
{
"spaClientId": "$SPA_ID",
"m2mClientId": "$M2M_ID",
"m2mClientSecret": "$M2M_SECRET",
"tradAppId": "$TRAD_ID",
"tradAppSecret": "$TRAD_SECRET",
"apiResourceIndicator": "$API_RESOURCE_INDICATOR",
"organizationId": "$ORG_ID",
"tenantName": "$TENANT_NAME",
"tenantSlug": "$TENANT_SLUG",
"bootstrapToken": "$BOOTSTRAP_TOKEN",
"platformAdminUser": "$SAAS_ADMIN_USER",
"tenantAdminUser": "$TENANT_ADMIN_USER",
"oidcIssuerUri": "${LOGTO_ENDPOINT}/oidc",
"oidcAudience": "$API_RESOURCE_INDICATOR"
}
EOF
chmod 644 "$BOOTSTRAP_FILE"
log ""
log "=== Bootstrap complete! ==="
# dev only — remove credential logging in production
log " SaaS Owner: $SAAS_ADMIN_USER / $SAAS_ADMIN_PASS"
log " Tenant Admin: $TENANT_ADMIN_USER / $TENANT_ADMIN_PASS"
log " Tenant: $TENANT_NAME (slug: $TENANT_SLUG)"
log " Organization: $ORG_ID"
log " SPA Client ID: $SPA_ID"