2026-04-05 00:22:22 +02:00
|
|
|
#!/bin/sh
|
|
|
|
|
set -e
|
|
|
|
|
|
2026-04-05 02:50:51 +02:00
|
|
|
# Cameleer SaaS — Bootstrap Script
|
|
|
|
|
# Creates Logto apps, users, organizations, roles.
|
|
|
|
|
# Seeds cameleer_saas DB with tenant, environment, license.
|
2026-04-15 15:28:44 +02:00
|
|
|
# Configures cameleer-server OIDC.
|
2026-04-05 00:22:22 +02:00
|
|
|
# Idempotent: checks existence before creating.
|
|
|
|
|
|
2026-04-13 22:51:33 +02:00
|
|
|
LOGTO_ENDPOINT="${LOGTO_ENDPOINT:-http://cameleer-logto:3001}"
|
|
|
|
|
LOGTO_ADMIN_ENDPOINT="${LOGTO_ADMIN_ENDPOINT:-http://cameleer-logto:3002}"
|
2026-04-05 02:50:51 +02:00
|
|
|
LOGTO_PUBLIC_ENDPOINT="${LOGTO_PUBLIC_ENDPOINT:-http://localhost:3001}"
|
2026-04-05 00:22:22 +02:00
|
|
|
MGMT_API_RESOURCE="https://default.logto.app/api"
|
|
|
|
|
BOOTSTRAP_FILE="/data/logto-bootstrap.json"
|
2026-04-13 22:51:33 +02:00
|
|
|
PG_HOST="${PG_HOST:-cameleer-postgres}"
|
2026-04-05 00:22:22 +02:00
|
|
|
PG_USER="${PG_USER:-cameleer}"
|
2026-04-05 02:50:51 +02:00
|
|
|
PG_DB_LOGTO="logto"
|
|
|
|
|
PG_DB_SAAS="${PG_DB_SAAS:-cameleer_saas}"
|
2026-04-05 00:22:22 +02:00
|
|
|
|
2026-04-05 02:50:51 +02:00
|
|
|
# App names
|
2026-04-05 00:22:22 +02:00
|
|
|
SPA_APP_NAME="Cameleer SaaS"
|
|
|
|
|
M2M_APP_NAME="Cameleer SaaS Backend"
|
2026-04-05 02:50:51 +02:00
|
|
|
TRAD_APP_NAME="Cameleer Dashboard"
|
2026-04-05 01:01:32 +02:00
|
|
|
API_RESOURCE_INDICATOR="https://api.cameleer.local"
|
|
|
|
|
API_RESOURCE_NAME="Cameleer SaaS API"
|
2026-04-05 00:22:22 +02:00
|
|
|
|
2026-04-05 02:50:51 +02:00
|
|
|
# Users (configurable via env vars)
|
|
|
|
|
SAAS_ADMIN_USER="${SAAS_ADMIN_USER:-admin}"
|
|
|
|
|
SAAS_ADMIN_PASS="${SAAS_ADMIN_PASS:-admin}"
|
2026-04-05 00:22:22 +02:00
|
|
|
|
2026-04-13 20:36:52 +02:00
|
|
|
# No server config — servers are provisioned dynamically by the admin console
|
2026-04-05 02:50:51 +02:00
|
|
|
|
2026-04-05 18:14:25 +02:00
|
|
|
# Redirect URIs (derived from PUBLIC_HOST and PUBLIC_PROTOCOL)
|
2026-04-05 17:07:20 +02:00
|
|
|
HOST="${PUBLIC_HOST:-localhost}"
|
2026-04-05 18:14:25 +02:00
|
|
|
PROTO="${PUBLIC_PROTOCOL:-https}"
|
2026-04-05 23:06:41 +02:00
|
|
|
SPA_REDIRECT_URIS="[\"${PROTO}://${HOST}/platform/callback\"]"
|
2026-04-10 13:41:18 +02:00
|
|
|
SPA_POST_LOGOUT_URIS="[\"${PROTO}://${HOST}/platform/login\",\"${PROTO}://${HOST}/platform/\"]"
|
2026-04-06 01:10:40 +02:00
|
|
|
TRAD_REDIRECT_URIS="[\"${PROTO}://${HOST}/oidc/callback\",\"${PROTO}://${HOST}/server/oidc/callback\"]"
|
2026-04-06 17:47:12 +02:00
|
|
|
TRAD_POST_LOGOUT_URIS="[\"${PROTO}://${HOST}\",\"${PROTO}://${HOST}/server\",\"${PROTO}://${HOST}/server/login?local\"]"
|
2026-04-05 02:50:51 +02:00
|
|
|
|
|
|
|
|
log() { echo "[bootstrap] $1"; }
|
|
|
|
|
pgpass() { PGPASSWORD="${PG_PASSWORD:-cameleer_dev}"; export PGPASSWORD; }
|
|
|
|
|
|
2026-04-13 17:44:02 +02:00
|
|
|
# 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
|
|
|
|
|
|
2026-04-13 16:17:13 +02:00
|
|
|
# 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
|
2026-04-05 00:22:22 +02:00
|
|
|
|
2026-04-05 12:44:27 +02:00
|
|
|
# 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)
|
2026-04-07 15:36:43 +02:00
|
|
|
CACHED_SPA_ID=$(jq -r '.spaClientId // empty' "$BOOTSTRAP_FILE" 2>/dev/null)
|
2026-04-05 12:44:27 +02:00
|
|
|
log "Found cached bootstrap file"
|
2026-04-07 15:36:43 +02:00
|
|
|
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
|
2026-04-05 12:44:27 +02:00
|
|
|
fi
|
|
|
|
|
|
2026-04-05 02:50:51 +02:00
|
|
|
# ============================================================
|
|
|
|
|
# PHASE 1: Wait for services
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
|
|
|
|
log "Waiting for Logto..."
|
2026-04-05 00:22:22 +02:00
|
|
|
for i in $(seq 1 60); do
|
2026-04-05 00:28:23 +02:00
|
|
|
if curl -sf "${LOGTO_ENDPOINT}/oidc/.well-known/openid-configuration" >/dev/null 2>&1; then
|
2026-04-05 00:22:22 +02:00
|
|
|
log "Logto is ready."
|
|
|
|
|
break
|
|
|
|
|
fi
|
|
|
|
|
[ "$i" -eq 60 ] && { log "ERROR: Logto not ready after 60s"; exit 1; }
|
|
|
|
|
sleep 1
|
|
|
|
|
done
|
|
|
|
|
|
2026-04-13 20:36:52 +02:00
|
|
|
# No server wait — servers are provisioned dynamically by the admin console
|
2026-04-05 02:50:51 +02:00
|
|
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
# PHASE 2: Get Management API token
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
2026-04-05 00:22:22 +02:00
|
|
|
log "Reading m-default secret from database..."
|
2026-04-05 02:50:51 +02:00
|
|
|
pgpass
|
|
|
|
|
M_DEFAULT_SECRET=$(psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB_LOGTO" -t -A -c \
|
2026-04-05 00:28:23 +02:00
|
|
|
"SELECT secret FROM applications WHERE id = 'm-default' AND tenant_id = 'admin';")
|
2026-04-05 02:50:51 +02:00
|
|
|
[ -z "$M_DEFAULT_SECRET" ] && { log "ERROR: m-default app not found"; exit 1; }
|
2026-04-05 00:22:22 +02:00
|
|
|
|
2026-04-05 00:33:43 +02:00
|
|
|
get_admin_token() {
|
2026-04-05 00:28:23 +02:00
|
|
|
curl -s -X POST "${LOGTO_ADMIN_ENDPOINT}/oidc/token" \
|
|
|
|
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
2026-04-13 17:44:02 +02:00
|
|
|
$ADMIN_HOST_ARGS \
|
2026-04-05 00:28:23 +02:00
|
|
|
-d "grant_type=client_credentials&client_id=${1}&client_secret=${2}&resource=${MGMT_API_RESOURCE}&scope=all"
|
2026-04-05 00:22:22 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-05 00:33:43 +02:00
|
|
|
get_default_token() {
|
|
|
|
|
curl -s -X POST "${LOGTO_ENDPOINT}/oidc/token" \
|
|
|
|
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
2026-04-13 17:44:02 +02:00
|
|
|
$HOST_ARGS \
|
2026-04-05 00:33:43 +02:00
|
|
|
-d "grant_type=client_credentials&client_id=${1}&client_secret=${2}&resource=${MGMT_API_RESOURCE}&scope=all"
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 00:22:22 +02:00
|
|
|
log "Getting Management API token..."
|
2026-04-05 00:33:43 +02:00
|
|
|
TOKEN_RESPONSE=$(get_admin_token "m-default" "$M_DEFAULT_SECRET")
|
2026-04-05 00:28:23 +02:00
|
|
|
TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token' 2>/dev/null)
|
2026-04-05 00:22:22 +02:00
|
|
|
[ -z "$TOKEN" ] || [ "$TOKEN" = "null" ] && { log "ERROR: Failed to get token"; exit 1; }
|
|
|
|
|
log "Got Management API token."
|
|
|
|
|
|
2026-04-07 18:18:08 +02:00
|
|
|
# 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
|
2026-04-13 17:44:02 +02:00
|
|
|
VERIFY_RESPONSE=$(curl -s -H "Authorization: Bearer $TOKEN" $HOST_ARGS "${LOGTO_ENDPOINT}/api/roles" 2>/dev/null)
|
2026-04-07 18:18:08 +02:00
|
|
|
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
|
|
|
|
|
|
2026-04-07 00:07:17 +02:00
|
|
|
# --- Helper: Logto API calls ---
|
2026-04-05 00:22:22 +02:00
|
|
|
api_get() {
|
2026-04-13 17:44:02 +02:00
|
|
|
curl -s -H "Authorization: Bearer $TOKEN" $HOST_ARGS "${LOGTO_ENDPOINT}${1}" 2>/dev/null || echo "[]"
|
2026-04-05 00:22:22 +02:00
|
|
|
}
|
|
|
|
|
api_post() {
|
2026-04-13 17:44:02 +02:00
|
|
|
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" $HOST_ARGS \
|
2026-04-05 00:28:23 +02:00
|
|
|
-d "$2" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true
|
2026-04-05 00:22:22 +02:00
|
|
|
}
|
2026-04-05 02:50:51 +02:00
|
|
|
api_put() {
|
2026-04-13 17:44:02 +02:00
|
|
|
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" $HOST_ARGS \
|
2026-04-05 02:50:51 +02:00
|
|
|
-d "$2" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true
|
|
|
|
|
}
|
2026-04-05 00:22:22 +02:00
|
|
|
api_delete() {
|
2026-04-13 17:44:02 +02:00
|
|
|
curl -s -X DELETE -H "Authorization: Bearer $TOKEN" $HOST_ARGS "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true
|
2026-04-05 00:22:22 +02:00
|
|
|
}
|
2026-04-06 10:45:19 +02:00
|
|
|
api_patch() {
|
2026-04-13 17:44:02 +02:00
|
|
|
curl -s -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" $HOST_ARGS \
|
2026-04-06 10:45:19 +02:00
|
|
|
-d "$2" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true
|
|
|
|
|
}
|
2026-04-05 00:22:22 +02:00
|
|
|
|
2026-04-05 02:50:51 +02:00
|
|
|
# ============================================================
|
|
|
|
|
# PHASE 3: Create Logto applications
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
2026-04-05 00:22:22 +02:00
|
|
|
EXISTING_APPS=$(api_get "/api/applications")
|
|
|
|
|
|
2026-04-05 02:50:51 +02:00
|
|
|
# --- SPA app (for SaaS frontend) ---
|
|
|
|
|
SPA_ID=$(echo "$EXISTING_APPS" | jq -r ".[] | select(.name == \"$SPA_APP_NAME\" and .type == \"SPA\") | .id")
|
2026-04-05 00:22:22 +02:00
|
|
|
if [ -n "$SPA_ID" ]; then
|
2026-04-05 02:50:51 +02:00
|
|
|
log "SPA app exists: $SPA_ID"
|
2026-04-05 00:22:22 +02:00
|
|
|
else
|
|
|
|
|
log "Creating SPA app..."
|
|
|
|
|
SPA_RESPONSE=$(api_post "/api/applications" "{
|
|
|
|
|
\"name\": \"$SPA_APP_NAME\",
|
|
|
|
|
\"type\": \"SPA\",
|
|
|
|
|
\"oidcClientMetadata\": {
|
2026-04-05 02:50:51 +02:00
|
|
|
\"redirectUris\": $SPA_REDIRECT_URIS,
|
|
|
|
|
\"postLogoutRedirectUris\": $SPA_POST_LOGOUT_URIS
|
2026-04-05 00:22:22 +02:00
|
|
|
}
|
|
|
|
|
}")
|
|
|
|
|
SPA_ID=$(echo "$SPA_RESPONSE" | jq -r '.id')
|
|
|
|
|
log "Created SPA app: $SPA_ID"
|
|
|
|
|
fi
|
|
|
|
|
|
2026-04-15 15:28:44 +02:00
|
|
|
# --- Traditional Web App (for cameleer-server OIDC) ---
|
2026-04-05 02:50:51 +02:00
|
|
|
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"
|
2026-04-05 12:44:27 +02:00
|
|
|
TRAD_SECRET="${CACHED_TRAD_SECRET:-}"
|
2026-04-05 02:50:51 +02:00
|
|
|
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')
|
2026-04-05 16:31:41 +02:00
|
|
|
[ "$TRAD_SECRET" = "null" ] && TRAD_SECRET=""
|
2026-04-05 02:50:51 +02:00
|
|
|
log "Created Traditional app: $TRAD_ID"
|
|
|
|
|
fi
|
|
|
|
|
|
2026-04-06 01:30:25 +02:00
|
|
|
# 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."
|
|
|
|
|
|
2026-04-05 02:50:51 +02:00
|
|
|
# --- API resource ---
|
2026-04-05 01:01:32 +02:00
|
|
|
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
|
2026-04-05 02:50:51 +02:00
|
|
|
log "API resource exists: $API_RESOURCE_ID"
|
2026-04-05 01:01:32 +02:00
|
|
|
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
|
|
|
|
|
|
2026-04-05 14:01:43 +02:00
|
|
|
# ============================================================
|
|
|
|
|
# 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
|
2026-04-05 15:32:53 +02:00
|
|
|
log " Scope '$name' exists: $existing_id" >&2
|
2026-04-05 14:01:43 +02:00
|
|
|
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')
|
2026-04-05 15:32:53 +02:00
|
|
|
log " Created scope '$name': $new_id" >&2
|
2026-04-05 14:01:43 +02:00
|
|
|
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")
|
|
|
|
|
|
2026-04-06 10:45:19 +02:00
|
|
|
# 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")
|
|
|
|
|
|
2026-04-05 14:01:43 +02:00
|
|
|
# Collect scope IDs for role assignment
|
feat: 4-role model — owner, operator, viewer + vendor-seed
Redesign the role model from 3 roles (platform-admin, admin, member)
to 4 clear personas:
- owner (org role): full tenant control — billing, team, apps, deploy
- operator (org role): app lifecycle + observability, no billing/team
- viewer (org role): read-only observability
- saas-vendor (global role, hosted only): cross-tenant platform admin
Bootstrap changes:
- Rename org roles: admin→owner, member→operator, add viewer
- Remove platform-admin global role (moved to vendor-seed)
- admin user gets owner role, camel user gets viewer role
- Custom JWT maps: owner→server:admin, operator→server:operator,
viewer→server:viewer, saas-vendor→server:admin
New docker/vendor-seed.sh for hosted SaaS environments only.
Remove sidebar user/logout link (TopBar handles logout).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:49:16 +02:00
|
|
|
# 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"
|
2026-04-05 14:01:43 +02:00
|
|
|
|
2026-04-05 02:50:51 +02:00
|
|
|
# --- M2M app ---
|
2026-04-05 00:22:22 +02:00
|
|
|
M2M_ID=$(echo "$EXISTING_APPS" | jq -r ".[] | select(.name == \"$M2M_APP_NAME\" and .type == \"MachineToMachine\") | .id")
|
|
|
|
|
M2M_SECRET=""
|
|
|
|
|
if [ -n "$M2M_ID" ]; then
|
2026-04-05 02:50:51 +02:00
|
|
|
log "M2M app exists: $M2M_ID"
|
2026-04-05 12:44:27 +02:00
|
|
|
M2M_SECRET="${CACHED_M2M_SECRET:-}"
|
2026-04-05 00:22:22 +02:00
|
|
|
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
|
2026-04-05 02:50:51 +02:00
|
|
|
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 \
|
2026-04-05 00:22:22 +02:00
|
|
|
"SELECT id FROM resources WHERE indicator = '$MGMT_API_RESOURCE' AND tenant_id = 'default';")
|
|
|
|
|
|
|
|
|
|
if [ -n "$MGMT_RESOURCE_ID" ]; then
|
2026-04-05 02:50:51 +02:00
|
|
|
SCOPE_IDS=$(psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB_LOGTO" -t -A -c \
|
2026-04-05 00:22:22 +02:00
|
|
|
"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."
|
|
|
|
|
|
2026-04-05 00:33:43 +02:00
|
|
|
VERIFY=$(get_default_token "$M2M_ID" "$M2M_SECRET")
|
2026-04-05 00:22:22 +02:00
|
|
|
VERIFY_TOKEN=$(echo "$VERIFY" | jq -r '.access_token')
|
|
|
|
|
if [ -n "$VERIFY_TOKEN" ] && [ "$VERIFY_TOKEN" != "null" ]; then
|
|
|
|
|
log "Verified M2M app works."
|
|
|
|
|
else
|
2026-04-05 02:50:51 +02:00
|
|
|
log "WARNING: M2M verification failed"
|
2026-04-05 00:22:22 +02:00
|
|
|
M2M_SECRET=""
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
fi
|
2026-04-07 17:08:37 +02:00
|
|
|
|
|
|
|
|
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"
|
2026-04-05 00:22:22 +02:00
|
|
|
fi
|
|
|
|
|
|
2026-04-05 02:50:51 +02:00
|
|
|
# ============================================================
|
|
|
|
|
# PHASE 4: Create roles
|
|
|
|
|
# ============================================================
|
2026-04-05 00:22:22 +02:00
|
|
|
|
feat: 4-role model — owner, operator, viewer + vendor-seed
Redesign the role model from 3 roles (platform-admin, admin, member)
to 4 clear personas:
- owner (org role): full tenant control — billing, team, apps, deploy
- operator (org role): app lifecycle + observability, no billing/team
- viewer (org role): read-only observability
- saas-vendor (global role, hosted only): cross-tenant platform admin
Bootstrap changes:
- Rename org roles: admin→owner, member→operator, add viewer
- Remove platform-admin global role (moved to vendor-seed)
- admin user gets owner role, camel user gets viewer role
- Custom JWT maps: owner→server:admin, operator→server:operator,
viewer→server:viewer, saas-vendor→server:admin
New docker/vendor-seed.sh for hosted SaaS environments only.
Remove sidebar user/logout link (TopBar handles logout).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:49:16 +02:00
|
|
|
# --- Organization roles: owner, operator, viewer ---
|
2026-04-13 20:36:52 +02:00
|
|
|
# Note: saas-vendor global role is created in Phase 12 and assigned to the admin user.
|
feat: 4-role model — owner, operator, viewer + vendor-seed
Redesign the role model from 3 roles (platform-admin, admin, member)
to 4 clear personas:
- owner (org role): full tenant control — billing, team, apps, deploy
- operator (org role): app lifecycle + observability, no billing/team
- viewer (org role): read-only observability
- saas-vendor (global role, hosted only): cross-tenant platform admin
Bootstrap changes:
- Rename org roles: admin→owner, member→operator, add viewer
- Remove platform-admin global role (moved to vendor-seed)
- admin user gets owner role, camel user gets viewer role
- Custom JWT maps: owner→server:admin, operator→server:operator,
viewer→server:viewer, saas-vendor→server:admin
New docker/vendor-seed.sh for hosted SaaS environments only.
Remove sidebar user/logout link (TopBar handles logout).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:49:16 +02:00
|
|
|
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"
|
2026-04-05 00:22:22 +02:00
|
|
|
else
|
feat: 4-role model — owner, operator, viewer + vendor-seed
Redesign the role model from 3 roles (platform-admin, admin, member)
to 4 clear personas:
- owner (org role): full tenant control — billing, team, apps, deploy
- operator (org role): app lifecycle + observability, no billing/team
- viewer (org role): read-only observability
- saas-vendor (global role, hosted only): cross-tenant platform admin
Bootstrap changes:
- Rename org roles: admin→owner, member→operator, add viewer
- Remove platform-admin global role (moved to vendor-seed)
- admin user gets owner role, camel user gets viewer role
- Custom JWT maps: owner→server:admin, operator→server:operator,
viewer→server:viewer, saas-vendor→server:admin
New docker/vendor-seed.sh for hosted SaaS environments only.
Remove sidebar user/logout link (TopBar handles logout).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:49:16 +02:00
|
|
|
ORG_OWNER_RESPONSE=$(api_post "/api/organization-roles" "{
|
|
|
|
|
\"name\": \"owner\",
|
|
|
|
|
\"description\": \"Platform owner — full tenant control\"
|
2026-04-05 00:22:22 +02:00
|
|
|
}")
|
feat: 4-role model — owner, operator, viewer + vendor-seed
Redesign the role model from 3 roles (platform-admin, admin, member)
to 4 clear personas:
- owner (org role): full tenant control — billing, team, apps, deploy
- operator (org role): app lifecycle + observability, no billing/team
- viewer (org role): read-only observability
- saas-vendor (global role, hosted only): cross-tenant platform admin
Bootstrap changes:
- Rename org roles: admin→owner, member→operator, add viewer
- Remove platform-admin global role (moved to vendor-seed)
- admin user gets owner role, camel user gets viewer role
- Custom JWT maps: owner→server:admin, operator→server:operator,
viewer→server:viewer, saas-vendor→server:admin
New docker/vendor-seed.sh for hosted SaaS environments only.
Remove sidebar user/logout link (TopBar handles logout).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:49:16 +02:00
|
|
|
ORG_OWNER_ROLE_ID=$(echo "$ORG_OWNER_RESPONSE" | jq -r '.id')
|
|
|
|
|
log "Created org owner role: $ORG_OWNER_ROLE_ID"
|
2026-04-05 00:22:22 +02:00
|
|
|
fi
|
|
|
|
|
|
feat: 4-role model — owner, operator, viewer + vendor-seed
Redesign the role model from 3 roles (platform-admin, admin, member)
to 4 clear personas:
- owner (org role): full tenant control — billing, team, apps, deploy
- operator (org role): app lifecycle + observability, no billing/team
- viewer (org role): read-only observability
- saas-vendor (global role, hosted only): cross-tenant platform admin
Bootstrap changes:
- Rename org roles: admin→owner, member→operator, add viewer
- Remove platform-admin global role (moved to vendor-seed)
- admin user gets owner role, camel user gets viewer role
- Custom JWT maps: owner→server:admin, operator→server:operator,
viewer→server:viewer, saas-vendor→server:admin
New docker/vendor-seed.sh for hosted SaaS environments only.
Remove sidebar user/logout link (TopBar handles logout).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:49:16 +02:00
|
|
|
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\"
|
2026-04-05 02:50:51 +02:00
|
|
|
}")
|
feat: 4-role model — owner, operator, viewer + vendor-seed
Redesign the role model from 3 roles (platform-admin, admin, member)
to 4 clear personas:
- owner (org role): full tenant control — billing, team, apps, deploy
- operator (org role): app lifecycle + observability, no billing/team
- viewer (org role): read-only observability
- saas-vendor (global role, hosted only): cross-tenant platform admin
Bootstrap changes:
- Rename org roles: admin→owner, member→operator, add viewer
- Remove platform-admin global role (moved to vendor-seed)
- admin user gets owner role, camel user gets viewer role
- Custom JWT maps: owner→server:admin, operator→server:operator,
viewer→server:viewer, saas-vendor→server:admin
New docker/vendor-seed.sh for hosted SaaS environments only.
Remove sidebar user/logout link (TopBar handles logout).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:49:16 +02:00
|
|
|
ORG_OPERATOR_ROLE_ID=$(echo "$ORG_OPERATOR_RESPONSE" | jq -r '.id')
|
|
|
|
|
log "Created org operator role: $ORG_OPERATOR_ROLE_ID"
|
2026-04-05 02:50:51 +02:00
|
|
|
fi
|
|
|
|
|
|
feat: 4-role model — owner, operator, viewer + vendor-seed
Redesign the role model from 3 roles (platform-admin, admin, member)
to 4 clear personas:
- owner (org role): full tenant control — billing, team, apps, deploy
- operator (org role): app lifecycle + observability, no billing/team
- viewer (org role): read-only observability
- saas-vendor (global role, hosted only): cross-tenant platform admin
Bootstrap changes:
- Rename org roles: admin→owner, member→operator, add viewer
- Remove platform-admin global role (moved to vendor-seed)
- admin user gets owner role, camel user gets viewer role
- Custom JWT maps: owner→server:admin, operator→server:operator,
viewer→server:viewer, saas-vendor→server:admin
New docker/vendor-seed.sh for hosted SaaS environments only.
Remove sidebar user/logout link (TopBar handles logout).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:49:16 +02:00
|
|
|
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\"
|
2026-04-05 02:50:51 +02:00
|
|
|
}")
|
feat: 4-role model — owner, operator, viewer + vendor-seed
Redesign the role model from 3 roles (platform-admin, admin, member)
to 4 clear personas:
- owner (org role): full tenant control — billing, team, apps, deploy
- operator (org role): app lifecycle + observability, no billing/team
- viewer (org role): read-only observability
- saas-vendor (global role, hosted only): cross-tenant platform admin
Bootstrap changes:
- Rename org roles: admin→owner, member→operator, add viewer
- Remove platform-admin global role (moved to vendor-seed)
- admin user gets owner role, camel user gets viewer role
- Custom JWT maps: owner→server:admin, operator→server:operator,
viewer→server:viewer, saas-vendor→server:admin
New docker/vendor-seed.sh for hosted SaaS environments only.
Remove sidebar user/logout link (TopBar handles logout).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:49:16 +02:00
|
|
|
ORG_VIEWER_ROLE_ID=$(echo "$ORG_VIEWER_RESPONSE" | jq -r '.id')
|
|
|
|
|
log "Created org viewer role: $ORG_VIEWER_ROLE_ID"
|
2026-04-05 02:50:51 +02:00
|
|
|
fi
|
|
|
|
|
|
2026-04-05 15:32:53 +02:00
|
|
|
# Assign API resource scopes to org roles (these appear in org-scoped resource tokens)
|
|
|
|
|
log "Assigning API resource scopes to organization roles..."
|
feat: 4-role model — owner, operator, viewer + vendor-seed
Redesign the role model from 3 roles (platform-admin, admin, member)
to 4 clear personas:
- owner (org role): full tenant control — billing, team, apps, deploy
- operator (org role): app lifecycle + observability, no billing/team
- viewer (org role): read-only observability
- saas-vendor (global role, hosted only): cross-tenant platform admin
Bootstrap changes:
- Rename org roles: admin→owner, member→operator, add viewer
- Remove platform-admin global role (moved to vendor-seed)
- admin user gets owner role, camel user gets viewer role
- Custom JWT maps: owner→server:admin, operator→server:operator,
viewer→server:viewer, saas-vendor→server:admin
New docker/vendor-seed.sh for hosted SaaS environments only.
Remove sidebar user/logout link (TopBar handles logout).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:49:16 +02:00
|
|
|
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
|
2026-04-05 15:32:53 +02:00
|
|
|
log "API resource scopes assigned to organization roles."
|
2026-04-05 14:01:43 +02:00
|
|
|
|
2026-04-05 02:50:51 +02:00
|
|
|
# ============================================================
|
|
|
|
|
# PHASE 5: Create users
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
feat: 4-role model — owner, operator, viewer + vendor-seed
Redesign the role model from 3 roles (platform-admin, admin, member)
to 4 clear personas:
- owner (org role): full tenant control — billing, team, apps, deploy
- operator (org role): app lifecycle + observability, no billing/team
- viewer (org role): read-only observability
- saas-vendor (global role, hosted only): cross-tenant platform admin
Bootstrap changes:
- Rename org roles: admin→owner, member→operator, add viewer
- Remove platform-admin global role (moved to vendor-seed)
- admin user gets owner role, camel user gets viewer role
- Custom JWT maps: owner→server:admin, operator→server:operator,
viewer→server:viewer, saas-vendor→server:admin
New docker/vendor-seed.sh for hosted SaaS environments only.
Remove sidebar user/logout link (TopBar handles logout).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:49:16 +02:00
|
|
|
# --- Platform Owner ---
|
|
|
|
|
log "Checking for platform owner user '$SAAS_ADMIN_USER'..."
|
2026-04-05 02:50:51 +02:00
|
|
|
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
|
feat: 4-role model — owner, operator, viewer + vendor-seed
Redesign the role model from 3 roles (platform-admin, admin, member)
to 4 clear personas:
- owner (org role): full tenant control — billing, team, apps, deploy
- operator (org role): app lifecycle + observability, no billing/team
- viewer (org role): read-only observability
- saas-vendor (global role, hosted only): cross-tenant platform admin
Bootstrap changes:
- Rename org roles: admin→owner, member→operator, add viewer
- Remove platform-admin global role (moved to vendor-seed)
- admin user gets owner role, camel user gets viewer role
- Custom JWT maps: owner→server:admin, operator→server:operator,
viewer→server:viewer, saas-vendor→server:admin
New docker/vendor-seed.sh for hosted SaaS environments only.
Remove sidebar user/logout link (TopBar handles logout).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:49:16 +02:00
|
|
|
log "Platform owner exists: $ADMIN_USER_ID"
|
2026-04-05 02:50:51 +02:00
|
|
|
else
|
feat: 4-role model — owner, operator, viewer + vendor-seed
Redesign the role model from 3 roles (platform-admin, admin, member)
to 4 clear personas:
- owner (org role): full tenant control — billing, team, apps, deploy
- operator (org role): app lifecycle + observability, no billing/team
- viewer (org role): read-only observability
- saas-vendor (global role, hosted only): cross-tenant platform admin
Bootstrap changes:
- Rename org roles: admin→owner, member→operator, add viewer
- Remove platform-admin global role (moved to vendor-seed)
- admin user gets owner role, camel user gets viewer role
- Custom JWT maps: owner→server:admin, operator→server:operator,
viewer→server:viewer, saas-vendor→server:admin
New docker/vendor-seed.sh for hosted SaaS environments only.
Remove sidebar user/logout link (TopBar handles logout).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:49:16 +02:00
|
|
|
log "Creating platform owner '$SAAS_ADMIN_USER'..."
|
2026-04-05 02:50:51 +02:00
|
|
|
ADMIN_RESPONSE=$(api_post "/api/users" "{
|
|
|
|
|
\"username\": \"$SAAS_ADMIN_USER\",
|
|
|
|
|
\"password\": \"$SAAS_ADMIN_PASS\",
|
feat: 4-role model — owner, operator, viewer + vendor-seed
Redesign the role model from 3 roles (platform-admin, admin, member)
to 4 clear personas:
- owner (org role): full tenant control — billing, team, apps, deploy
- operator (org role): app lifecycle + observability, no billing/team
- viewer (org role): read-only observability
- saas-vendor (global role, hosted only): cross-tenant platform admin
Bootstrap changes:
- Rename org roles: admin→owner, member→operator, add viewer
- Remove platform-admin global role (moved to vendor-seed)
- admin user gets owner role, camel user gets viewer role
- Custom JWT maps: owner→server:admin, operator→server:operator,
viewer→server:viewer, saas-vendor→server:admin
New docker/vendor-seed.sh for hosted SaaS environments only.
Remove sidebar user/logout link (TopBar handles logout).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:49:16 +02:00
|
|
|
\"name\": \"Platform Owner\"
|
2026-04-05 02:50:51 +02:00
|
|
|
}")
|
|
|
|
|
ADMIN_USER_ID=$(echo "$ADMIN_RESPONSE" | jq -r '.id')
|
feat: 4-role model — owner, operator, viewer + vendor-seed
Redesign the role model from 3 roles (platform-admin, admin, member)
to 4 clear personas:
- owner (org role): full tenant control — billing, team, apps, deploy
- operator (org role): app lifecycle + observability, no billing/team
- viewer (org role): read-only observability
- saas-vendor (global role, hosted only): cross-tenant platform admin
Bootstrap changes:
- Rename org roles: admin→owner, member→operator, add viewer
- Remove platform-admin global role (moved to vendor-seed)
- admin user gets owner role, camel user gets viewer role
- Custom JWT maps: owner→server:admin, operator→server:operator,
viewer→server:viewer, saas-vendor→server:admin
New docker/vendor-seed.sh for hosted SaaS environments only.
Remove sidebar user/logout link (TopBar handles logout).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:49:16 +02:00
|
|
|
log "Created platform owner: $ADMIN_USER_ID"
|
2026-04-13 18:09:10 +02:00
|
|
|
fi
|
|
|
|
|
|
2026-04-06 23:28:40 +02:00
|
|
|
# --- Grant SaaS admin Logto console access (admin tenant, port 3002) ---
|
2026-04-06 10:45:19 +02:00
|
|
|
log "Granting SaaS admin Logto console access..."
|
2026-04-06 23:28:40 +02:00
|
|
|
|
2026-04-06 23:37:51 +02:00
|
|
|
# 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" \
|
2026-04-13 17:44:02 +02:00
|
|
|
$ADMIN_HOST_ARGS \
|
2026-04-06 23:37:51 +02:00
|
|
|
-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)
|
2026-04-06 23:28:40 +02:00
|
|
|
|
2026-04-06 23:37:51 +02:00
|
|
|
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() {
|
2026-04-13 17:44:02 +02:00
|
|
|
curl -s -H "Authorization: Bearer $ADMIN_TOKEN" $ADMIN_HOST_ARGS "${LOGTO_ADMIN_ENDPOINT}${1}" 2>/dev/null || echo "[]"
|
2026-04-06 23:37:51 +02:00
|
|
|
}
|
|
|
|
|
admin_api_post() {
|
2026-04-13 17:44:02 +02:00
|
|
|
curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" $ADMIN_HOST_ARGS \
|
2026-04-06 23:37:51 +02:00
|
|
|
-d "$2" "${LOGTO_ADMIN_ENDPOINT}${1}" 2>/dev/null || true
|
|
|
|
|
}
|
2026-04-06 23:46:36 +02:00
|
|
|
admin_api_patch() {
|
2026-04-13 17:44:02 +02:00
|
|
|
curl -s -X PATCH -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" $ADMIN_HOST_ARGS \
|
2026-04-06 23:46:36 +02:00
|
|
|
-d "$2" "${LOGTO_ADMIN_ENDPOINT}${1}" 2>/dev/null || true
|
|
|
|
|
}
|
2026-04-06 23:37:51 +02:00
|
|
|
|
|
|
|
|
# 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)
|
2026-04-06 23:28:40 +02:00
|
|
|
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
|
2026-04-06 23:43:07 +02:00
|
|
|
# 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')
|
2026-04-06 23:28:40 +02:00
|
|
|
ADMIN_ROLE_ID=$(admin_api_get "/api/roles" | jq -r '.[] | select(.name == "default:admin") | .id')
|
2026-04-06 23:43:07 +02:00
|
|
|
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
|
2026-04-06 23:28:40 +02:00
|
|
|
if [ -n "$ADMIN_ROLE_ID" ] && [ "$ADMIN_ROLE_ID" != "null" ]; then
|
2026-04-06 23:43:07 +02:00
|
|
|
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)."
|
2026-04-06 23:28:40 +02:00
|
|
|
else
|
2026-04-06 23:43:07 +02:00
|
|
|
log "WARNING: admin tenant roles not found"
|
2026-04-06 23:28:40 +02:00
|
|
|
fi
|
|
|
|
|
|
2026-04-12 14:12:42 +02:00
|
|
|
# Switch sign-in mode from Register to SignIn (admin user already created)
|
2026-04-06 23:46:36 +02:00
|
|
|
admin_api_patch "/api/sign-in-exp" '{"signInMode": "SignIn"}' >/dev/null 2>&1
|
2026-04-12 14:12:42 +02:00
|
|
|
log "Set sign-in mode to SignIn."
|
2026-04-06 23:46:36 +02:00
|
|
|
|
2026-04-14 22:46:05 +02:00
|
|
|
# Register admin-console redirect URIs (Logto ships with empty URIs)
|
|
|
|
|
ADMIN_PUBLIC="${ADMIN_ENDPOINT:-${PROTO}://${HOST}:3002}"
|
|
|
|
|
admin_api_patch "/api/applications/admin-console" "{
|
|
|
|
|
\"oidcClientMetadata\": {
|
|
|
|
|
\"redirectUris\": [\"${ADMIN_PUBLIC}/console/callback\"],
|
|
|
|
|
\"postLogoutRedirectUris\": [\"${ADMIN_PUBLIC}/console\"]
|
|
|
|
|
}
|
|
|
|
|
}" >/dev/null 2>&1
|
|
|
|
|
log "Registered admin-console redirect URIs."
|
|
|
|
|
|
|
|
|
|
# Add admin user to Logto's internal organizations (required for console login)
|
|
|
|
|
for ORG_ID in t-default t-admin; do
|
|
|
|
|
admin_api_post "/api/organizations/${ORG_ID}/users" "{\"userIds\": [\"$ADMIN_TENANT_USER_ID\"]}" >/dev/null 2>&1
|
|
|
|
|
done
|
|
|
|
|
ADMIN_ORG_ROLE_ID=$(admin_api_get "/api/organization-roles" | jq -r '.[] | select(.name == "admin") | .id')
|
|
|
|
|
if [ -n "$ADMIN_ORG_ROLE_ID" ] && [ "$ADMIN_ORG_ROLE_ID" != "null" ]; then
|
|
|
|
|
for ORG_ID in t-default t-admin; do
|
|
|
|
|
admin_api_post "/api/organizations/${ORG_ID}/users/${ADMIN_TENANT_USER_ID}/roles" "{\"organizationRoleIds\": [\"$ADMIN_ORG_ROLE_ID\"]}" >/dev/null 2>&1
|
|
|
|
|
done
|
|
|
|
|
fi
|
|
|
|
|
log "Added admin to Logto console organizations."
|
|
|
|
|
|
2026-04-06 10:45:19 +02:00
|
|
|
log "SaaS admin granted Logto console access."
|
|
|
|
|
else
|
2026-04-06 23:28:40 +02:00
|
|
|
log "WARNING: Could not create admin console user"
|
2026-04-06 10:45:19 +02:00
|
|
|
fi
|
|
|
|
|
|
2026-04-06 23:37:51 +02:00
|
|
|
fi # end: ADMIN_TOKEN check
|
|
|
|
|
fi # end: M_ADMIN_SECRET check
|
|
|
|
|
|
2026-04-13 20:36:52 +02:00
|
|
|
# No viewer user — tenant users are created by the admin during tenant provisioning.
|
|
|
|
|
# No example organization — tenants are created via the admin console.
|
2026-04-10 08:24:28 +02:00
|
|
|
# No server OIDC config — each provisioned server gets OIDC from env vars.
|
2026-04-10 08:19:46 +02:00
|
|
|
ORG_ID=""
|
2026-04-05 02:50:51 +02:00
|
|
|
|
2026-04-07 10:17:04 +02:00
|
|
|
# ============================================================
|
|
|
|
|
# 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 }) => {
|
feat: 4-role model — owner, operator, viewer + vendor-seed
Redesign the role model from 3 roles (platform-admin, admin, member)
to 4 clear personas:
- owner (org role): full tenant control — billing, team, apps, deploy
- operator (org role): app lifecycle + observability, no billing/team
- viewer (org role): read-only observability
- saas-vendor (global role, hosted only): cross-tenant platform admin
Bootstrap changes:
- Rename org roles: admin→owner, member→operator, add viewer
- Remove platform-admin global role (moved to vendor-seed)
- admin user gets owner role, camel user gets viewer role
- Custom JWT maps: owner→server:admin, operator→server:operator,
viewer→server:viewer, saas-vendor→server:admin
New docker/vendor-seed.sh for hosted SaaS environments only.
Remove sidebar user/logout link (TopBar handles logout).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:49:16 +02:00
|
|
|
const roleMap = { owner: "server:admin", operator: "server:operator", viewer: "server:viewer" };
|
2026-04-07 10:17:04 +02:00
|
|
|
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) {
|
feat: 4-role model — owner, operator, viewer + vendor-seed
Redesign the role model from 3 roles (platform-admin, admin, member)
to 4 clear personas:
- owner (org role): full tenant control — billing, team, apps, deploy
- operator (org role): app lifecycle + observability, no billing/team
- viewer (org role): read-only observability
- saas-vendor (global role, hosted only): cross-tenant platform admin
Bootstrap changes:
- Rename org roles: admin→owner, member→operator, add viewer
- Remove platform-admin global role (moved to vendor-seed)
- admin user gets owner role, camel user gets viewer role
- Custom JWT maps: owner→server:admin, operator→server:operator,
viewer→server:viewer, saas-vendor→server:admin
New docker/vendor-seed.sh for hosted SaaS environments only.
Remove sidebar user/logout link (TopBar handles logout).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:49:16 +02:00
|
|
|
if (role.name === "saas-vendor") roles.add("server:admin");
|
2026-04-07 10:17:04 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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
|
|
|
|
|
|
2026-04-06 10:45:19 +02:00
|
|
|
# ============================================================
|
|
|
|
|
# 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."
|
|
|
|
|
|
2026-04-05 02:50:51 +02:00
|
|
|
# ============================================================
|
|
|
|
|
# PHASE 9: Cleanup seeded apps
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
2026-04-05 00:22:22 +02:00
|
|
|
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
|
|
|
|
|
|
2026-04-05 02:50:51 +02:00
|
|
|
# ============================================================
|
|
|
|
|
# PHASE 10: Write bootstrap results
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
2026-04-05 00:22:22 +02:00
|
|
|
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",
|
2026-04-05 02:50:51 +02:00
|
|
|
"tradAppId": "$TRAD_ID",
|
2026-04-05 12:44:27 +02:00
|
|
|
"tradAppSecret": "$TRAD_SECRET",
|
2026-04-05 01:01:32 +02:00
|
|
|
"apiResourceIndicator": "$API_RESOURCE_INDICATOR",
|
2026-04-05 02:50:51 +02:00
|
|
|
"platformAdminUser": "$SAAS_ADMIN_USER",
|
2026-04-05 12:44:27 +02:00
|
|
|
"oidcIssuerUri": "${LOGTO_ENDPOINT}/oidc",
|
|
|
|
|
"oidcAudience": "$API_RESOURCE_INDICATOR"
|
2026-04-05 00:22:22 +02:00
|
|
|
}
|
|
|
|
|
EOF
|
2026-04-06 22:25:15 +02:00
|
|
|
chmod 644 "$BOOTSTRAP_FILE"
|
2026-04-05 00:22:22 +02:00
|
|
|
|
2026-04-09 22:21:51 +02:00
|
|
|
# ============================================================
|
2026-04-13 20:36:52 +02:00
|
|
|
# Phase 12: SaaS Admin Role
|
2026-04-09 22:21:51 +02:00
|
|
|
# ============================================================
|
|
|
|
|
|
2026-04-13 20:36:52 +02:00
|
|
|
log ""
|
|
|
|
|
log "=== Phase 12: SaaS Admin Role ==="
|
|
|
|
|
|
|
|
|
|
# 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
|
2026-04-10 11:54:57 +02:00
|
|
|
|
2026-04-13 20:36:52 +02:00
|
|
|
# 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."
|
2026-04-09 22:21:51 +02:00
|
|
|
fi
|
|
|
|
|
|
2026-04-13 20:36:52 +02:00
|
|
|
log "SaaS admin role configured."
|
|
|
|
|
|
2026-04-05 02:50:51 +02:00
|
|
|
log ""
|
|
|
|
|
log "=== Bootstrap complete! ==="
|
2026-04-06 19:15:03 +02:00
|
|
|
# dev only — remove credential logging in production
|
feat: 4-role model — owner, operator, viewer + vendor-seed
Redesign the role model from 3 roles (platform-admin, admin, member)
to 4 clear personas:
- owner (org role): full tenant control — billing, team, apps, deploy
- operator (org role): app lifecycle + observability, no billing/team
- viewer (org role): read-only observability
- saas-vendor (global role, hosted only): cross-tenant platform admin
Bootstrap changes:
- Rename org roles: admin→owner, member→operator, add viewer
- Remove platform-admin global role (moved to vendor-seed)
- admin user gets owner role, camel user gets viewer role
- Custom JWT maps: owner→server:admin, operator→server:operator,
viewer→server:viewer, saas-vendor→server:admin
New docker/vendor-seed.sh for hosted SaaS environments only.
Remove sidebar user/logout link (TopBar handles logout).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:49:16 +02:00
|
|
|
log " SPA Client ID: $SPA_ID"
|
|
|
|
|
log ""
|
2026-04-13 20:36:52 +02:00
|
|
|
log " No tenants created — use the admin console to create tenants."
|
2026-04-10 08:19:46 +02:00
|
|
|
log ""
|