The bootstrap script runs before the SaaS app starts, but the tenants table only exists after Flyway migrations run in the SaaS app. This circular dependency caused Phase 12b's psql commands to fail under set -e, crashing the Logto container on first install in single-tenant mode. Now the bootstrap only handles Logto-side setup (org, user roles, OIDC redirect URIs), and the installer creates the tenant DB record after verify_health confirms the SaaS app is up. Also makes docker_compose_up tolerant of transient startup errors since verify_health is the real health gate. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
750 lines
32 KiB
Bash
750 lines
32 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}"
|
|
|
|
# Vendor seed (optional — creates saas-vendor role + vendor user)
|
|
VENDOR_SEED_ENABLED="${VENDOR_SEED_ENABLED:-false}"
|
|
VENDOR_USER="${VENDOR_USER:-vendor}"
|
|
VENDOR_PASS="${VENDOR_PASS:-vendor}"
|
|
VENDOR_NAME="${VENDOR_NAME:-SaaS Vendor}"
|
|
|
|
# No server config — servers are provisioned dynamically by the vendor console
|
|
|
|
# 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\",\"${PROTO}://${HOST}/platform/\"]"
|
|
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; }
|
|
|
|
# When BOOTSTRAP_LOCAL=true (running inside Logto container with localhost endpoints),
|
|
# skip Host/X-Forwarded-Proto headers — they cause issuer mismatches with localhost
|
|
if [ "$BOOTSTRAP_LOCAL" = "true" ]; then
|
|
HOST_ARGS=""
|
|
ADMIN_HOST_ARGS=""
|
|
else
|
|
HOST_ARGS="-H Host:${HOST}"
|
|
ADMIN_HOST_ARGS="-H Host:${HOST}:3002 -H X-Forwarded-Proto:https"
|
|
fi
|
|
|
|
# Install jq + curl if not already available (deps are baked into cameleer-logto image)
|
|
if ! command -v jq >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1; then
|
|
if command -v apk >/dev/null 2>&1; then
|
|
apk add --no-cache jq curl >/dev/null 2>&1
|
|
elif command -v apt-get >/dev/null 2>&1; then
|
|
apt-get update -qq && apt-get install -y -qq jq curl >/dev/null 2>&1
|
|
fi
|
|
fi
|
|
|
|
# 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
|
|
|
|
# No server wait — servers are provisioned dynamically by the vendor console
|
|
|
|
# ============================================================
|
|
# 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" \
|
|
$ADMIN_HOST_ARGS \
|
|
-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" \
|
|
$HOST_ARGS \
|
|
-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" $HOST_ARGS "${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" $HOST_ARGS "${LOGTO_ENDPOINT}${1}" 2>/dev/null || echo "[]"
|
|
}
|
|
api_post() {
|
|
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" $HOST_ARGS \
|
|
-d "$2" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true
|
|
}
|
|
api_put() {
|
|
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" $HOST_ARGS \
|
|
-d "$2" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true
|
|
}
|
|
api_delete() {
|
|
curl -s -X DELETE -H "Authorization: Bearer $TOKEN" $HOST_ARGS "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true
|
|
}
|
|
api_patch() {
|
|
curl -s -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" $HOST_ARGS \
|
|
-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"
|
|
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" \
|
|
$ADMIN_HOST_ARGS \
|
|
-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" $ADMIN_HOST_ARGS "${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" $ADMIN_HOST_ARGS \
|
|
-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" $ADMIN_HOST_ARGS \
|
|
-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
|
|
|
|
# Switch sign-in mode from Register to SignIn (admin user already created)
|
|
admin_api_patch "/api/sign-in-exp" '{"signInMode": "SignIn"}' >/dev/null 2>&1
|
|
log "Set 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
|
|
|
|
# No viewer user — tenant users are created by the vendor during tenant provisioning.
|
|
# No example organization — tenants are created via the vendor console.
|
|
# No server OIDC config — each provisioned server gets OIDC from env vars.
|
|
ORG_ID=""
|
|
|
|
# ============================================================
|
|
# 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" <<EOF
|
|
{
|
|
"spaClientId": "$SPA_ID",
|
|
"m2mClientId": "$M2M_ID",
|
|
"m2mClientSecret": "$M2M_SECRET",
|
|
"tradAppId": "$TRAD_ID",
|
|
"tradAppSecret": "$TRAD_SECRET",
|
|
"apiResourceIndicator": "$API_RESOURCE_INDICATOR",
|
|
"platformAdminUser": "$SAAS_ADMIN_USER",
|
|
"oidcIssuerUri": "${LOGTO_ENDPOINT}/oidc",
|
|
"oidcAudience": "$API_RESOURCE_INDICATOR"
|
|
}
|
|
EOF
|
|
chmod 644 "$BOOTSTRAP_FILE"
|
|
|
|
# ============================================================
|
|
# Phase 12: Deployment Mode (vendor or single-tenant)
|
|
# ============================================================
|
|
|
|
TENANT_ORG_NAME="${TENANT_ORG_NAME:-}"
|
|
|
|
if [ "$VENDOR_SEED_ENABLED" = "true" ]; then
|
|
log ""
|
|
log "=== Phase 12a: Vendor Seed ==="
|
|
|
|
# Create saas-vendor global role with all API scopes
|
|
log "Checking for saas-vendor role..."
|
|
EXISTING_ROLES=$(api_get "/api/roles")
|
|
VENDOR_ROLE_ID=$(echo "$EXISTING_ROLES" | jq -r '.[] | select(.name == "saas-vendor" and .type == "User") | .id')
|
|
|
|
if [ -z "$VENDOR_ROLE_ID" ]; then
|
|
ALL_SCOPE_IDS=$(api_get "/api/resources/$API_RESOURCE_ID/scopes" | jq '[.[].id]')
|
|
log "Creating saas-vendor role with all scopes..."
|
|
VENDOR_ROLE_RESPONSE=$(api_post "/api/roles" "{
|
|
\"name\": \"saas-vendor\",
|
|
\"description\": \"SaaS vendor — full platform control across all tenants\",
|
|
\"type\": \"User\",
|
|
\"scopeIds\": $ALL_SCOPE_IDS
|
|
}")
|
|
VENDOR_ROLE_ID=$(echo "$VENDOR_ROLE_RESPONSE" | jq -r '.id')
|
|
log "Created saas-vendor role: $VENDOR_ROLE_ID"
|
|
else
|
|
log "saas-vendor role exists: $VENDOR_ROLE_ID"
|
|
fi
|
|
|
|
# Assign vendor role to admin user
|
|
if [ -n "$VENDOR_ROLE_ID" ] && [ "$VENDOR_ROLE_ID" != "null" ] && [ -n "$ADMIN_USER_ID" ]; then
|
|
api_post "/api/users/$ADMIN_USER_ID/roles" "{\"roleIds\": [\"$VENDOR_ROLE_ID\"]}" >/dev/null 2>&1
|
|
log "Assigned saas-vendor role to admin user."
|
|
fi
|
|
|
|
# Create separate vendor user if credentials provided
|
|
if [ -n "$VENDOR_USER" ] && [ "$VENDOR_USER" != "$SAAS_ADMIN_USER" ]; then
|
|
log "Checking for vendor user '$VENDOR_USER'..."
|
|
VENDOR_USER_ID=$(api_get "/api/users?search=$VENDOR_USER" | jq -r ".[] | select(.username == \"$VENDOR_USER\") | .id")
|
|
|
|
if [ -z "$VENDOR_USER_ID" ]; then
|
|
log "Creating vendor user '$VENDOR_USER'..."
|
|
VENDOR_RESPONSE=$(api_post "/api/users" "{
|
|
\"username\": \"$VENDOR_USER\",
|
|
\"password\": \"$VENDOR_PASS\",
|
|
\"name\": \"$VENDOR_NAME\"
|
|
}")
|
|
VENDOR_USER_ID=$(echo "$VENDOR_RESPONSE" | jq -r '.id')
|
|
log "Created vendor user: $VENDOR_USER_ID"
|
|
else
|
|
log "Vendor user exists: $VENDOR_USER_ID"
|
|
fi
|
|
|
|
# Assign saas-vendor role to vendor user
|
|
if [ -n "$VENDOR_ROLE_ID" ] && [ "$VENDOR_ROLE_ID" != "null" ]; then
|
|
api_post "/api/users/$VENDOR_USER_ID/roles" "{\"roleIds\": [\"$VENDOR_ROLE_ID\"]}" >/dev/null 2>&1
|
|
log "Assigned saas-vendor role to vendor user."
|
|
fi
|
|
|
|
# Add vendor to all existing organizations with owner role
|
|
log "Adding vendor to all organizations..."
|
|
ORG_OWNER_ROLE_ID=$(api_get "/api/organization-roles" | jq -r '.[] | select(.name == "owner") | .id')
|
|
ORGS=$(api_get "/api/organizations")
|
|
ORG_COUNT=$(echo "$ORGS" | jq 'length')
|
|
|
|
for i in $(seq 0 $((ORG_COUNT - 1))); do
|
|
SEED_ORG_ID=$(echo "$ORGS" | jq -r ".[$i].id")
|
|
SEED_ORG_NAME=$(echo "$ORGS" | jq -r ".[$i].name")
|
|
api_post "/api/organizations/$SEED_ORG_ID/users" "{\"userIds\": [\"$VENDOR_USER_ID\"]}" >/dev/null 2>&1
|
|
if [ -n "$ORG_OWNER_ROLE_ID" ] && [ "$ORG_OWNER_ROLE_ID" != "null" ]; then
|
|
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" $HOST_ARGS \
|
|
-d "{\"organizationRoleIds\": [\"$ORG_OWNER_ROLE_ID\"]}" \
|
|
"${LOGTO_ENDPOINT}/api/organizations/$SEED_ORG_ID/users/$VENDOR_USER_ID/roles" >/dev/null 2>&1
|
|
fi
|
|
log " Added to org '$SEED_ORG_NAME' with owner role."
|
|
done
|
|
|
|
# Grant vendor user Logto console access
|
|
if [ -n "$ADMIN_TOKEN" ] && [ "$ADMIN_TOKEN" != "null" ]; then
|
|
log "Granting vendor Logto console access..."
|
|
VENDOR_CONSOLE_USER_ID=$(admin_api_get "/api/users?search=$VENDOR_USER" | jq -r ".[] | select(.username == \"$VENDOR_USER\") | .id" 2>/dev/null)
|
|
if [ -z "$VENDOR_CONSOLE_USER_ID" ] || [ "$VENDOR_CONSOLE_USER_ID" = "null" ]; then
|
|
VENDOR_CONSOLE_RESPONSE=$(admin_api_post "/api/users" "{
|
|
\"username\": \"$VENDOR_USER\",
|
|
\"password\": \"$VENDOR_PASS\",
|
|
\"name\": \"$VENDOR_NAME\"
|
|
}")
|
|
VENDOR_CONSOLE_USER_ID=$(echo "$VENDOR_CONSOLE_RESPONSE" | jq -r '.id')
|
|
log "Created vendor console user: $VENDOR_CONSOLE_USER_ID"
|
|
else
|
|
log "Vendor console user exists: $VENDOR_CONSOLE_USER_ID"
|
|
fi
|
|
if [ -n "$VENDOR_CONSOLE_USER_ID" ] && [ "$VENDOR_CONSOLE_USER_ID" != "null" ]; then
|
|
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')
|
|
V_ROLE_IDS="[]"
|
|
[ -n "$ADMIN_USER_ROLE_ID" ] && [ "$ADMIN_USER_ROLE_ID" != "null" ] && V_ROLE_IDS=$(echo "$V_ROLE_IDS" | jq ". + [\"$ADMIN_USER_ROLE_ID\"]")
|
|
[ -n "$ADMIN_ROLE_ID" ] && [ "$ADMIN_ROLE_ID" != "null" ] && V_ROLE_IDS=$(echo "$V_ROLE_IDS" | jq ". + [\"$ADMIN_ROLE_ID\"]")
|
|
[ "$V_ROLE_IDS" != "[]" ] && admin_api_post "/api/users/$VENDOR_CONSOLE_USER_ID/roles" "{\"roleIds\": $V_ROLE_IDS}" >/dev/null 2>&1
|
|
log "Vendor granted Logto console access."
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
log "Vendor seed complete."
|
|
|
|
elif [ -n "$TENANT_ORG_NAME" ]; then
|
|
log ""
|
|
log "=== Phase 12b: Single-Tenant Setup ==="
|
|
|
|
# Create organization for the tenant
|
|
TENANT_SLUG=$(echo "$TENANT_ORG_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g; s/--*/-/g; s/^-//; s/-$//')
|
|
log "Creating organization '$TENANT_ORG_NAME' (slug: $TENANT_SLUG)..."
|
|
|
|
EXISTING_ORG_ID=$(api_get "/api/organizations" | jq -r ".[] | select(.name == \"$TENANT_ORG_NAME\") | .id")
|
|
if [ -n "$EXISTING_ORG_ID" ]; then
|
|
log "Organization already exists: $EXISTING_ORG_ID"
|
|
TENANT_ORG_ID="$EXISTING_ORG_ID"
|
|
else
|
|
ORG_RESPONSE=$(api_post "/api/organizations" "{\"name\": \"$TENANT_ORG_NAME\"}")
|
|
TENANT_ORG_ID=$(echo "$ORG_RESPONSE" | jq -r '.id')
|
|
log "Created organization: $TENANT_ORG_ID"
|
|
fi
|
|
|
|
# Add admin user to organization with owner role
|
|
if [ -n "$TENANT_ORG_ID" ] && [ "$TENANT_ORG_ID" != "null" ]; then
|
|
api_post "/api/organizations/$TENANT_ORG_ID/users" "{\"userIds\": [\"$ADMIN_USER_ID\"]}" >/dev/null 2>&1
|
|
ORG_OWNER_ROLE_ID=$(api_get "/api/organization-roles" | jq -r '.[] | select(.name == "owner") | .id')
|
|
if [ -n "$ORG_OWNER_ROLE_ID" ] && [ "$ORG_OWNER_ROLE_ID" != "null" ]; then
|
|
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" $HOST_ARGS \
|
|
-d "{\"organizationRoleIds\": [\"$ORG_OWNER_ROLE_ID\"]}" \
|
|
"${LOGTO_ENDPOINT}/api/organizations/$TENANT_ORG_ID/users/$ADMIN_USER_ID/roles" >/dev/null 2>&1
|
|
fi
|
|
log "Added admin user to organization with owner role."
|
|
|
|
# Register OIDC redirect URIs for the tenant
|
|
TRAD_APP=$(api_get "/api/applications" | jq -r ".[] | select(.name == \"$TRAD_APP_NAME\") | .id")
|
|
if [ -n "$TRAD_APP" ] && [ "$TRAD_APP" != "null" ]; then
|
|
EXISTING_URIS=$(api_get "/api/applications/$TRAD_APP" | jq -r '.oidcClientMetadata.redirectUris')
|
|
NEW_URI="${PROTO}://${HOST}/t/${TENANT_SLUG}/oidc/callback"
|
|
if ! echo "$EXISTING_URIS" | jq -e ".[] | select(. == \"$NEW_URI\")" >/dev/null 2>&1; then
|
|
UPDATED_URIS=$(echo "$EXISTING_URIS" | jq ". + [\"$NEW_URI\"]")
|
|
api_patch "/api/applications/$TRAD_APP" "{\"oidcClientMetadata\": {\"redirectUris\": $UPDATED_URIS}}" >/dev/null 2>&1
|
|
log "Registered OIDC redirect URI for tenant: $NEW_URI"
|
|
fi
|
|
fi
|
|
|
|
# NOTE: Tenant DB record is created by the installer after Flyway migrations
|
|
# have run (the tenants table doesn't exist yet at bootstrap time).
|
|
fi
|
|
|
|
log "Single-tenant setup complete."
|
|
fi
|
|
|
|
log ""
|
|
log "=== Bootstrap complete! ==="
|
|
# dev only — remove credential logging in production
|
|
log " SPA Client ID: $SPA_ID"
|
|
if [ "$VENDOR_SEED_ENABLED" = "true" ]; then
|
|
log " Vendor: $VENDOR_USER / $VENDOR_PASS (role: saas-vendor)"
|
|
fi
|
|
log ""
|
|
log " No tenants created — use the vendor console to create tenants."
|
|
log ""
|