refactor: merge vendor user into saas-admin
All checks were successful
CI / build (push) Successful in 1m8s
CI / docker (push) Successful in 17s

The admin user IS the platform admin — no separate vendor user needed.
The saas-vendor role is now always assigned to the admin user during
bootstrap. Removes VENDOR_ENABLED, VENDOR_USER, VENDOR_PASS from all
config, prompts, compose templates, and bootstrap script.

In multi-tenant mode: admin logs in with saas-admin credentials, gets
platform:admin scope via saas-vendor role, manages tenants directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-13 20:36:52 +02:00
parent 4e553a6c42
commit 012c866594
3 changed files with 39 additions and 190 deletions

View File

@@ -77,9 +77,6 @@ services:
PG_DB_SAAS: ${POSTGRES_DB:-cameleer_saas}
SAAS_ADMIN_USER: ${SAAS_ADMIN_USER:-admin}
SAAS_ADMIN_PASS: ${SAAS_ADMIN_PASS:-admin}
VENDOR_SEED_ENABLED: "${VENDOR_SEED_ENABLED:-false}"
VENDOR_USER: ${VENDOR_USER:-vendor}
VENDOR_PASS: ${VENDOR_PASS:-vendor}
healthcheck:
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3001/oidc/.well-known/openid-configuration', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\" && test -f /data/logto-bootstrap.json"]
interval: 10s

View File

@@ -28,13 +28,7 @@ API_RESOURCE_NAME="Cameleer SaaS API"
SAAS_ADMIN_USER="${SAAS_ADMIN_USER:-admin}"
SAAS_ADMIN_PASS="${SAAS_ADMIN_PASS:-admin}"
# Vendor seed (optional — creates saas-vendor role + vendor user)
VENDOR_SEED_ENABLED="${VENDOR_SEED_ENABLED:-false}"
VENDOR_USER="${VENDOR_USER:-vendor}"
VENDOR_PASS="${VENDOR_PASS:-vendor}"
VENDOR_NAME="${VENDOR_NAME:-SaaS Vendor}"
# No server config — servers are provisioned dynamically by the vendor console
# No server config — servers are provisioned dynamically by the admin console
# Redirect URIs (derived from PUBLIC_HOST and PUBLIC_PROTOCOL)
HOST="${PUBLIC_HOST:-localhost}"
@@ -92,7 +86,7 @@ for i in $(seq 1 60); do
sleep 1
done
# No server wait — servers are provisioned dynamically by the vendor console
# No server wait — servers are provisioned dynamically by the admin console
# ============================================================
# PHASE 2: Get Management API token
@@ -345,8 +339,7 @@ fi
# ============================================================
# --- Organization roles: owner, operator, viewer ---
# Note: platform-admin / saas-vendor global role is NOT created here.
# It is injected via docker/vendor-seed.sh on the hosted SaaS environment only.
# Note: saas-vendor global role is created in Phase 12 and assigned to the admin user.
log "Creating organization roles..."
EXISTING_ORG_ROLES=$(api_get "/api/organization-roles")
@@ -491,8 +484,8 @@ fi
fi # end: ADMIN_TOKEN check
fi # end: M_ADMIN_SECRET check
# No viewer user — tenant users are created by the vendor during tenant provisioning.
# No example organization — tenants are created via the vendor console.
# No viewer user — tenant users are created by the admin during tenant provisioning.
# No example organization — tenants are created via the admin console.
# No server OIDC config — each provisioned server gets OIDC from env vars.
ORG_ID=""
@@ -583,118 +576,44 @@ EOF
chmod 644 "$BOOTSTRAP_FILE"
# ============================================================
# Phase 12: Vendor Seed (optional)
# Phase 12: SaaS Admin Role
# ============================================================
if [ "$VENDOR_SEED_ENABLED" = "true" ]; then
log ""
log "=== Phase 12a: Vendor Seed ==="
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')
# Create saas-vendor global role with all API scopes
log "Checking for saas-vendor role..."
EXISTING_ROLES=$(api_get "/api/roles")
VENDOR_ROLE_ID=$(echo "$EXISTING_ROLES" | jq -r '.[] | select(.name == "saas-vendor" and .type == "User") | .id')
if [ -z "$VENDOR_ROLE_ID" ]; then
ALL_SCOPE_IDS=$(api_get "/api/resources/$API_RESOURCE_ID/scopes" | jq '[.[].id]')
log "Creating saas-vendor role with all scopes..."
VENDOR_ROLE_RESPONSE=$(api_post "/api/roles" "{
\"name\": \"saas-vendor\",
\"description\": \"SaaS vendor — full platform control across all tenants\",
\"type\": \"User\",
\"scopeIds\": $ALL_SCOPE_IDS
}")
VENDOR_ROLE_ID=$(echo "$VENDOR_ROLE_RESPONSE" | jq -r '.id')
log "Created saas-vendor role: $VENDOR_ROLE_ID"
else
log "saas-vendor role exists: $VENDOR_ROLE_ID"
fi
# Assign vendor role to admin user
if [ -n "$VENDOR_ROLE_ID" ] && [ "$VENDOR_ROLE_ID" != "null" ] && [ -n "$ADMIN_USER_ID" ]; then
api_post "/api/users/$ADMIN_USER_ID/roles" "{\"roleIds\": [\"$VENDOR_ROLE_ID\"]}" >/dev/null 2>&1
log "Assigned saas-vendor role to admin user."
fi
# Create separate vendor user if credentials provided
if [ -n "$VENDOR_USER" ] && [ "$VENDOR_USER" != "$SAAS_ADMIN_USER" ]; then
log "Checking for vendor user '$VENDOR_USER'..."
VENDOR_USER_ID=$(api_get "/api/users?search=$VENDOR_USER" | jq -r ".[] | select(.username == \"$VENDOR_USER\") | .id")
if [ -z "$VENDOR_USER_ID" ]; then
log "Creating vendor user '$VENDOR_USER'..."
VENDOR_RESPONSE=$(api_post "/api/users" "{
\"username\": \"$VENDOR_USER\",
\"password\": \"$VENDOR_PASS\",
\"name\": \"$VENDOR_NAME\"
}")
VENDOR_USER_ID=$(echo "$VENDOR_RESPONSE" | jq -r '.id')
log "Created vendor user: $VENDOR_USER_ID"
else
log "Vendor user exists: $VENDOR_USER_ID"
fi
# Assign saas-vendor role to vendor user
if [ -n "$VENDOR_ROLE_ID" ] && [ "$VENDOR_ROLE_ID" != "null" ]; then
api_post "/api/users/$VENDOR_USER_ID/roles" "{\"roleIds\": [\"$VENDOR_ROLE_ID\"]}" >/dev/null 2>&1
log "Assigned saas-vendor role to vendor user."
fi
# Add vendor to all existing organizations with owner role
log "Adding vendor to all organizations..."
ORG_OWNER_ROLE_ID=$(api_get "/api/organization-roles" | jq -r '.[] | select(.name == "owner") | .id')
ORGS=$(api_get "/api/organizations")
ORG_COUNT=$(echo "$ORGS" | jq 'length')
for i in $(seq 0 $((ORG_COUNT - 1))); do
SEED_ORG_ID=$(echo "$ORGS" | jq -r ".[$i].id")
SEED_ORG_NAME=$(echo "$ORGS" | jq -r ".[$i].name")
api_post "/api/organizations/$SEED_ORG_ID/users" "{\"userIds\": [\"$VENDOR_USER_ID\"]}" >/dev/null 2>&1
if [ -n "$ORG_OWNER_ROLE_ID" ] && [ "$ORG_OWNER_ROLE_ID" != "null" ]; then
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" $HOST_ARGS \
-d "{\"organizationRoleIds\": [\"$ORG_OWNER_ROLE_ID\"]}" \
"${LOGTO_ENDPOINT}/api/organizations/$SEED_ORG_ID/users/$VENDOR_USER_ID/roles" >/dev/null 2>&1
fi
log " Added to org '$SEED_ORG_NAME' with owner role."
done
# Grant vendor user Logto console access
if [ -n "$ADMIN_TOKEN" ] && [ "$ADMIN_TOKEN" != "null" ]; then
log "Granting vendor Logto console access..."
VENDOR_CONSOLE_USER_ID=$(admin_api_get "/api/users?search=$VENDOR_USER" | jq -r ".[] | select(.username == \"$VENDOR_USER\") | .id" 2>/dev/null)
if [ -z "$VENDOR_CONSOLE_USER_ID" ] || [ "$VENDOR_CONSOLE_USER_ID" = "null" ]; then
VENDOR_CONSOLE_RESPONSE=$(admin_api_post "/api/users" "{
\"username\": \"$VENDOR_USER\",
\"password\": \"$VENDOR_PASS\",
\"name\": \"$VENDOR_NAME\"
}")
VENDOR_CONSOLE_USER_ID=$(echo "$VENDOR_CONSOLE_RESPONSE" | jq -r '.id')
log "Created vendor console user: $VENDOR_CONSOLE_USER_ID"
else
log "Vendor console user exists: $VENDOR_CONSOLE_USER_ID"
fi
if [ -n "$VENDOR_CONSOLE_USER_ID" ] && [ "$VENDOR_CONSOLE_USER_ID" != "null" ]; then
ADMIN_USER_ROLE_ID=$(admin_api_get "/api/roles" | jq -r '.[] | select(.name == "user") | .id')
ADMIN_ROLE_ID=$(admin_api_get "/api/roles" | jq -r '.[] | select(.name == "default:admin") | .id')
V_ROLE_IDS="[]"
[ -n "$ADMIN_USER_ROLE_ID" ] && [ "$ADMIN_USER_ROLE_ID" != "null" ] && V_ROLE_IDS=$(echo "$V_ROLE_IDS" | jq ". + [\"$ADMIN_USER_ROLE_ID\"]")
[ -n "$ADMIN_ROLE_ID" ] && [ "$ADMIN_ROLE_ID" != "null" ] && V_ROLE_IDS=$(echo "$V_ROLE_IDS" | jq ". + [\"$ADMIN_ROLE_ID\"]")
[ "$V_ROLE_IDS" != "[]" ] && admin_api_post "/api/users/$VENDOR_CONSOLE_USER_ID/roles" "{\"roleIds\": $V_ROLE_IDS}" >/dev/null 2>&1
log "Vendor granted Logto console access."
fi
fi
fi
log "Vendor seed complete."
if [ -z "$VENDOR_ROLE_ID" ]; then
ALL_SCOPE_IDS=$(api_get "/api/resources/$API_RESOURCE_ID/scopes" | jq '[.[].id]')
log "Creating saas-vendor role with all scopes..."
VENDOR_ROLE_RESPONSE=$(api_post "/api/roles" "{
\"name\": \"saas-vendor\",
\"description\": \"SaaS vendor — full platform control across all tenants\",
\"type\": \"User\",
\"scopeIds\": $ALL_SCOPE_IDS
}")
VENDOR_ROLE_ID=$(echo "$VENDOR_ROLE_RESPONSE" | jq -r '.id')
log "Created saas-vendor role: $VENDOR_ROLE_ID"
else
log "saas-vendor role exists: $VENDOR_ROLE_ID"
fi
# Assign vendor role to admin user
if [ -n "$VENDOR_ROLE_ID" ] && [ "$VENDOR_ROLE_ID" != "null" ] && [ -n "$ADMIN_USER_ID" ]; then
api_post "/api/users/$ADMIN_USER_ID/roles" "{\"roleIds\": [\"$VENDOR_ROLE_ID\"]}" >/dev/null 2>&1
log "Assigned saas-vendor role to admin user."
fi
log "SaaS admin role configured."
log ""
log "=== Bootstrap complete! ==="
# dev only — remove credential logging in production
log " SPA Client ID: $SPA_ID"
if [ "$VENDOR_SEED_ENABLED" = "true" ]; then
log " Vendor: $VENDOR_USER / $VENDOR_PASS (role: saas-vendor)"
fi
log ""
log " No tenants created — use the vendor console to create tenants."
log " No tenants created — use the admin console to create tenants."
log ""

View File

@@ -22,8 +22,6 @@ DEFAULT_HTTP_PORT="80"
DEFAULT_HTTPS_PORT="443"
DEFAULT_LOGTO_CONSOLE_PORT="3002"
DEFAULT_LOGTO_CONSOLE_EXPOSED="true"
DEFAULT_VENDOR_ENABLED="false"
DEFAULT_VENDOR_USER="vendor"
DEFAULT_COMPOSE_PROJECT="cameleer-saas"
DEFAULT_COMPOSE_PROJECT_STANDALONE="cameleer"
DEFAULT_DOCKER_SOCKET="/var/run/docker.sock"
@@ -42,9 +40,6 @@ _ENV_HTTP_PORT="${HTTP_PORT:-}"
_ENV_HTTPS_PORT="${HTTPS_PORT:-}"
_ENV_LOGTO_CONSOLE_PORT="${LOGTO_CONSOLE_PORT:-}"
_ENV_LOGTO_CONSOLE_EXPOSED="${LOGTO_CONSOLE_EXPOSED:-}"
_ENV_VENDOR_ENABLED="${VENDOR_ENABLED:-}"
_ENV_VENDOR_USER="${VENDOR_USER:-}"
_ENV_VENDOR_PASS="${VENDOR_PASS:-}"
_ENV_MONITORING_NETWORK="${MONITORING_NETWORK:-}"
_ENV_COMPOSE_PROJECT="${COMPOSE_PROJECT:-}"
_ENV_DOCKER_SOCKET="${DOCKER_SOCKET:-}"
@@ -66,9 +61,6 @@ HTTP_PORT=""
HTTPS_PORT=""
LOGTO_CONSOLE_PORT=""
LOGTO_CONSOLE_EXPOSED=""
VENDOR_ENABLED=""
VENDOR_USER=""
VENDOR_PASS=""
MONITORING_NETWORK=""
VERSION=""
COMPOSE_PROJECT=""
@@ -169,9 +161,6 @@ parse_args() {
--https-port) HTTPS_PORT="$2"; shift ;;
--logto-console-port) LOGTO_CONSOLE_PORT="$2"; shift ;;
--logto-console-exposed) LOGTO_CONSOLE_EXPOSED="$2"; shift ;;
--vendor-enabled) VENDOR_ENABLED="$2"; shift ;;
--vendor-user) VENDOR_USER="$2"; shift ;;
--vendor-password) VENDOR_PASS="$2"; shift ;;
--monitoring-network) MONITORING_NETWORK="$2"; shift ;;
--version) VERSION="$2"; shift ;;
--compose-project) COMPOSE_PROJECT="$2"; shift ;;
@@ -219,7 +208,6 @@ show_help() {
echo "Expert options:"
echo " --postgres-password, --clickhouse-password, --http-port,"
echo " --https-port, --logto-console-port, --logto-console-exposed,"
echo " --vendor-enabled, --vendor-user, --vendor-password,"
echo " --compose-project, --docker-socket, --node-tls-reject"
echo ""
echo "Re-run options:"
@@ -256,9 +244,6 @@ load_config_file() {
https_port) [ -z "$HTTPS_PORT" ] && HTTPS_PORT="$value" ;;
logto_console_port) [ -z "$LOGTO_CONSOLE_PORT" ] && LOGTO_CONSOLE_PORT="$value" ;;
logto_console_exposed) [ -z "$LOGTO_CONSOLE_EXPOSED" ] && LOGTO_CONSOLE_EXPOSED="$value" ;;
vendor_enabled) [ -z "$VENDOR_ENABLED" ] && VENDOR_ENABLED="$value" ;;
vendor_user) [ -z "$VENDOR_USER" ] && VENDOR_USER="$value" ;;
vendor_password) [ -z "$VENDOR_PASS" ] && VENDOR_PASS="$value" ;;
monitoring_network) [ -z "$MONITORING_NETWORK" ] && MONITORING_NETWORK="$value" ;;
version) [ -z "$VERSION" ] && VERSION="$value" ;;
compose_project) [ -z "$COMPOSE_PROJECT" ] && COMPOSE_PROJECT="$value" ;;
@@ -285,9 +270,6 @@ load_env_overrides() {
[ -z "$HTTPS_PORT" ] && HTTPS_PORT="$_ENV_HTTPS_PORT"
[ -z "$LOGTO_CONSOLE_PORT" ] && LOGTO_CONSOLE_PORT="$_ENV_LOGTO_CONSOLE_PORT"
[ -z "$LOGTO_CONSOLE_EXPOSED" ] && LOGTO_CONSOLE_EXPOSED="$_ENV_LOGTO_CONSOLE_EXPOSED"
[ -z "$VENDOR_ENABLED" ] && VENDOR_ENABLED="$_ENV_VENDOR_ENABLED"
[ -z "$VENDOR_USER" ] && VENDOR_USER="$_ENV_VENDOR_USER"
[ -z "$VENDOR_PASS" ] && VENDOR_PASS="$_ENV_VENDOR_PASS"
[ -z "$MONITORING_NETWORK" ] && MONITORING_NETWORK="$_ENV_MONITORING_NETWORK"
[ -z "$VERSION" ] && VERSION="${CAMELEER_VERSION:-}"
[ -z "$COMPOSE_PROJECT" ] && COMPOSE_PROJECT="$_ENV_COMPOSE_PROJECT"
@@ -437,7 +419,7 @@ run_simple_prompts() {
echo ""
echo " Deployment mode:"
echo " [1] Multi-tenant vendor — manage platform, provision tenants on demand"
echo " [1] Multi-tenant SaaS — manage platform, provision tenants on demand"
echo " [2] Single-tenant — one server instance, local auth, no identity provider"
echo ""
local deploy_choice
@@ -445,11 +427,9 @@ run_simple_prompts() {
case "${deploy_choice:-1}" in
2)
DEPLOYMENT_MODE="standalone"
VENDOR_ENABLED="false"
;;
*)
DEPLOYMENT_MODE="saas"
VENDOR_ENABLED="true"
;;
esac
}
@@ -470,21 +450,6 @@ run_expert_prompts() {
prompt_password CLICKHOUSE_PASSWORD "ClickHouse password" ""
fi
if [ "$DEPLOYMENT_MODE" = "saas" ]; then
echo ""
if prompt_yesno "Enable vendor account?"; then
VENDOR_ENABLED="true"
prompt VENDOR_USER "Vendor username" "${VENDOR_USER:-$DEFAULT_VENDOR_USER}"
if prompt_yesno "Auto-generate vendor password?" "y"; then
VENDOR_PASS=""
else
prompt_password VENDOR_PASS "Vendor password" ""
fi
else
VENDOR_ENABLED="false"
fi
fi
echo ""
echo -e "${BOLD} Networking:${NC}"
prompt HTTP_PORT "HTTP port" "${HTTP_PORT:-$DEFAULT_HTTP_PORT}"
@@ -523,8 +488,6 @@ merge_config() {
: "${HTTPS_PORT:=$DEFAULT_HTTPS_PORT}"
: "${LOGTO_CONSOLE_PORT:=$DEFAULT_LOGTO_CONSOLE_PORT}"
: "${LOGTO_CONSOLE_EXPOSED:=$DEFAULT_LOGTO_CONSOLE_EXPOSED}"
: "${VENDOR_ENABLED:=$DEFAULT_VENDOR_ENABLED}"
: "${VENDOR_USER:=$DEFAULT_VENDOR_USER}"
: "${VERSION:=$CAMELEER_DEFAULT_VERSION}"
: "${DOCKER_SOCKET:=$DEFAULT_DOCKER_SOCKET}"
@@ -597,10 +560,6 @@ generate_passwords() {
CLICKHOUSE_PASSWORD=$(generate_password)
log_info "Generated ClickHouse password."
fi
if [ "$VENDOR_ENABLED" = "true" ] && [ -z "$VENDOR_PASS" ]; then
VENDOR_PASS=$(generate_password)
log_info "Generated vendor password."
fi
}
# --- File generation ---
@@ -703,11 +662,6 @@ EOF
cat >> "$f" << EOF
# Vendor account
VENDOR_SEED_ENABLED=${VENDOR_ENABLED}
VENDOR_USER=${VENDOR_USER}
VENDOR_PASS=${VENDOR_PASS:-}
# Docker
DOCKER_SOCKET=${DOCKER_SOCKET}
DOCKER_GID=$(stat -c '%g' "${DOCKER_SOCKET}" 2>/dev/null || echo "0")
@@ -858,9 +812,6 @@ EOF
PG_DB_SAAS: cameleer_saas
SAAS_ADMIN_USER: ${SAAS_ADMIN_USER:-admin}
SAAS_ADMIN_PASS: ${SAAS_ADMIN_PASS:-admin}
VENDOR_SEED_ENABLED: "${VENDOR_SEED_ENABLED:-false}"
VENDOR_USER: ${VENDOR_USER:-vendor}
VENDOR_PASS: ${VENDOR_PASS:-vendor}
healthcheck:
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3001/oidc/.well-known/openid-configuration', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\" && test -f /data/logto-bootstrap.json"]
interval: 10s
@@ -1310,8 +1261,6 @@ http_port=${HTTP_PORT}
https_port=${HTTPS_PORT}
logto_console_port=${LOGTO_CONSOLE_PORT}
logto_console_exposed=${LOGTO_CONSOLE_EXPOSED}
vendor_enabled=${VENDOR_ENABLED}
vendor_user=${VENDOR_USER}
monitoring_network=${MONITORING_NETWORK}
version=${VERSION}
compose_project=${COMPOSE_PROJECT}
@@ -1365,17 +1314,6 @@ ClickHouse: default / ${CLICKHOUSE_PASSWORD}
EOF
if [ "$VENDOR_ENABLED" = "true" ]; then
cat >> "$f" << EOF
Vendor User: ${VENDOR_USER}
Vendor Password: ${VENDOR_PASS}
EOF
else
echo "Vendor User: (not enabled)" >> "$f"
echo "" >> "$f"
fi
if [ "$LOGTO_CONSOLE_EXPOSED" = "true" ]; then
echo "Logto Console: ${PUBLIC_PROTOCOL}://${PUBLIC_HOST}:${LOGTO_CONSOLE_PORT}" >> "$f"
else
@@ -1424,9 +1362,9 @@ EOF
## First Steps
1. Open the Platform UI in your browser
2. Log in with the admin credentials from `credentials.txt`
3. Create your first tenant via the Vendor console
4. The platform will provision a dedicated server instance for the tenant
2. Log in as admin with the credentials from `credentials.txt`
3. Create tenants from the admin console
4. The platform will provision a dedicated server instance for each tenant
## Architecture
@@ -1475,7 +1413,7 @@ EOF
cat >> "$f" << 'EOF'
The platform generated a self-signed certificate on first boot. To replace it:
1. Log in as admin and navigate to **Certificates** in the vendor console
1. Log in as admin and navigate to **Certificates** in the admin console
2. Upload your certificate and key via the UI
3. Activate the new certificate (zero-downtime swap)
EOF
@@ -1693,11 +1631,6 @@ print_credentials() {
echo ""
if [ "$DEPLOYMENT_MODE" = "saas" ]; then
if [ "$VENDOR_ENABLED" = "true" ]; then
echo -e " Vendor User: ${BOLD}${VENDOR_USER}${NC}"
echo -e " Vendor Password: ${BOLD}${VENDOR_PASS}${NC}"
echo ""
fi
if [ "$LOGTO_CONSOLE_EXPOSED" = "true" ]; then
echo -e " Logto Console: ${BLUE}${PUBLIC_PROTOCOL}://${PUBLIC_HOST}:${LOGTO_CONSOLE_PORT}${NC}"
echo ""