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-24 18:11:47 +02:00
AUTH = " ${ AUTH_HOST :- $HOST } "
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
2026-04-24 18:11:47 +02:00
# Logto validates Host header against its ENDPOINT, which uses AUTH_HOST
HOST_ARGS = " -H Host: ${ AUTH } "
ADMIN_HOST_ARGS = " -H Host: ${ AUTH } :3002 -H X-Forwarded-Proto:https "
2026-04-13 17:44:02 +02:00
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-25 00:21:07 +02:00
# ============================================================
# PHASE 8b: Configure SMTP email connector
# ============================================================
# Required for email verification during registration and password reset.
# Skipped if SMTP_HOST is not set (registration will not work without email delivery).
if [ -n " ${ SMTP_HOST :- } " ] && [ -n " ${ SMTP_USER :- } " ] ; then
log "Configuring SMTP email connector..."
# Discover available email connector factories
FACTORIES = $( api_get "/api/connector-factories" )
# Prefer a factory with "smtp" in the ID
SMTP_FACTORY_ID = $( echo " $FACTORIES " | jq -r '[.[] | select(.type == "Email" and (.id | test("smtp"; "i")))] | .[0].id // empty' )
if [ -z " $SMTP_FACTORY_ID " ] ; then
# Fall back to any non-demo Email factory
SMTP_FACTORY_ID = $( echo " $FACTORIES " | jq -r '[.[] | select(.type == "Email" and .isDemo != true)] | .[0].id // empty' )
fi
if [ -n " $SMTP_FACTORY_ID " ] ; then
# Build SMTP config JSON
SMTP_CONFIG = $( jq -n \
--arg host " $SMTP_HOST " \
--arg port " ${ SMTP_PORT :- 587 } " \
--arg user " $SMTP_USER " \
--arg pass " ${ SMTP_PASS :- } " \
--arg from " ${ SMTP_FROM_EMAIL :- noreply @cameleer.io } " \
' {
host: $host ,
port: ( $port | tonumber) ,
auth: { user: $user , pass: $pass } ,
fromEmail: $from ,
templates: [
{
usageType: "Register" ,
contentType: "text/html" ,
subject: "Verify your email for Cameleer" ,
content: "<div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px\"><div style=\"text-align:center;margin-bottom:24px\"><span style=\"font-size:24px;font-weight:700;color:#C6820E\">Cameleer</span></div><p style=\"color:#333;font-size:15px;line-height:1.6\">Enter this code to verify your email and create your account:</p><div style=\"text-align:center;margin:24px 0\"><span style=\"font-size:32px;font-weight:700;letter-spacing:6px;color:#C6820E\">{{code}}</span></div><p style=\"color:#666;font-size:13px\">This code expires in 10 minutes. If you did not request this, you can safely ignore this email.</p></div>"
} ,
{
usageType: "SignIn" ,
contentType: "text/html" ,
subject: "Your Cameleer sign-in code" ,
content: "<div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px\"><div style=\"text-align:center;margin-bottom:24px\"><span style=\"font-size:24px;font-weight:700;color:#C6820E\">Cameleer</span></div><p style=\"color:#333;font-size:15px;line-height:1.6\">Your sign-in verification code:</p><div style=\"text-align:center;margin:24px 0\"><span style=\"font-size:32px;font-weight:700;letter-spacing:6px;color:#C6820E\">{{code}}</span></div><p style=\"color:#666;font-size:13px\">This code expires in 10 minutes.</p></div>"
} ,
{
usageType: "ForgotPassword" ,
contentType: "text/html" ,
subject: "Reset your Cameleer password" ,
content: "<div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px\"><div style=\"text-align:center;margin-bottom:24px\"><span style=\"font-size:24px;font-weight:700;color:#C6820E\">Cameleer</span></div><p style=\"color:#333;font-size:15px;line-height:1.6\">Enter this code to reset your password:</p><div style=\"text-align:center;margin:24px 0\"><span style=\"font-size:32px;font-weight:700;letter-spacing:6px;color:#C6820E\">{{code}}</span></div><p style=\"color:#666;font-size:13px\">This code expires in 10 minutes. If you did not request a password reset, you can safely ignore this email.</p></div>"
} ,
{
usageType: "Generic" ,
contentType: "text/html" ,
subject: "Your Cameleer verification code" ,
content: "<div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px\"><div style=\"text-align:center;margin-bottom:24px\"><span style=\"font-size:24px;font-weight:700;color:#C6820E\">Cameleer</span></div><p style=\"color:#333;font-size:15px;line-height:1.6\">Your verification code:</p><div style=\"text-align:center;margin:24px 0\"><span style=\"font-size:32px;font-weight:700;letter-spacing:6px;color:#C6820E\">{{code}}</span></div><p style=\"color:#666;font-size:13px\">This code expires in 10 minutes.</p></div>"
}
]
} ' )
# Check if an email connector already exists
EXISTING_CONNECTORS = $( api_get "/api/connectors" )
EMAIL_CONNECTOR_ID = $( echo " $EXISTING_CONNECTORS " | jq -r '[.[] | select(.type == "Email")] | .[0].id // empty' )
if [ -n " $EMAIL_CONNECTOR_ID " ] ; then
api_patch " /api/connectors/ $EMAIL_CONNECTOR_ID " " {\"config\": $SMTP_CONFIG } " >/dev/null 2>& 1
log " Updated existing email connector: $EMAIL_CONNECTOR_ID "
else
CONNECTOR_RESPONSE = $( api_post "/api/connectors" " {\"connectorId\": \" $SMTP_FACTORY_ID \", \"config\": $SMTP_CONFIG } " )
CREATED_ID = $( echo " $CONNECTOR_RESPONSE " | jq -r '.id // empty' )
if [ -n " $CREATED_ID " ] ; then
log " Created SMTP email connector: $CREATED_ID (factory: $SMTP_FACTORY_ID ) "
else
log " WARNING: Failed to create SMTP connector. Response: $( echo " $CONNECTOR_RESPONSE " | head -c 300) "
fi
fi
else
log "WARNING: No email connector factory found — email delivery will not work."
log " Available factories: $( echo " $FACTORIES " | jq -c '[.[] | select(.type == "Email") | .id]' ) "
fi
else
log "SMTP not configured (SMTP_HOST/SMTP_USER not set) — email delivery disabled."
log "Set SMTP_HOST, SMTP_USER, SMTP_PASS, SMTP_FROM_EMAIL env vars to enable."
fi
# ============================================================
# PHASE 8c: Enable registration (email + password)
# ============================================================
# Configures sign-in experience to allow self-service registration with email verification.
# This runs AFTER the SMTP connector so email delivery is ready before registration opens.
log "Configuring sign-in experience for registration..."
api_patch "/api/sign-in-exp" ' {
"signInMode" : "SignInAndRegister" ,
"signUp" : {
"identifiers" : [ "email" ] ,
"password" : true,
"verify" : true
} ,
"signIn" : {
"methods" : [
{
"identifier" : "email" ,
"password" : true,
"verificationCode" : false,
"isPasswordPrimary" : true
} ,
{
"identifier" : "username" ,
"password" : true,
"verificationCode" : false,
"isPasswordPrimary" : true
}
]
}
} ' >/dev/null 2>& 1
log "Sign-in experience configured: SignInAndRegister (email + password)."
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 ""