feat: add deployment mode — vendor (multi-tenant) or single-tenant
All checks were successful
CI / build (push) Successful in 1m11s
CI / docker (push) Successful in 17s

Installer now asks deployment mode in simple mode:
- Multi-tenant vendor: creates saas-vendor role + assigns to admin
- Single tenant: asks for org name, creates Logto org + tenant record,
  assigns admin as org owner

Reverts always-create-vendor-role — role is only created when vendor
mode is selected. TENANT_ORG_NAME env var passed to bootstrap for
single-tenant org creation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-13 18:18:25 +02:00
parent b44f6338f8
commit 85eabd86ef
3 changed files with 169 additions and 83 deletions

View File

@@ -80,6 +80,7 @@ services:
VENDOR_SEED_ENABLED: "${VENDOR_SEED_ENABLED:-false}" VENDOR_SEED_ENABLED: "${VENDOR_SEED_ENABLED:-false}"
VENDOR_USER: ${VENDOR_USER:-vendor} VENDOR_USER: ${VENDOR_USER:-vendor}
VENDOR_PASS: ${VENDOR_PASS:-vendor} VENDOR_PASS: ${VENDOR_PASS:-vendor}
TENANT_ORG_NAME: ${TENANT_ORG_NAME:-}
healthcheck: 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"] 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 interval: 10s

View File

@@ -409,33 +409,6 @@ else
log "Created platform owner: $ADMIN_USER_ID" log "Created platform owner: $ADMIN_USER_ID"
fi fi
# --- Always create saas-vendor role and assign to admin user ---
# The admin user needs platform:admin to manage tenants via the vendor console.
log "Ensuring saas-vendor role exists..."
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
# --- Grant SaaS admin Logto console access (admin tenant, port 3002) --- # --- Grant SaaS admin Logto console access (admin tenant, port 3002) ---
log "Granting SaaS admin Logto console access..." log "Granting SaaS admin Logto console access..."
@@ -610,21 +583,47 @@ EOF
chmod 644 "$BOOTSTRAP_FILE" chmod 644 "$BOOTSTRAP_FILE"
# ============================================================ # ============================================================
# Phase 12: Vendor Seed (optional) # Phase 12: Deployment Mode (vendor or single-tenant)
# ============================================================ # ============================================================
TENANT_ORG_NAME="${TENANT_ORG_NAME:-}"
if [ "$VENDOR_SEED_ENABLED" = "true" ]; then if [ "$VENDOR_SEED_ENABLED" = "true" ]; then
log "" log ""
log "=== Phase 12: Vendor Seed (separate vendor user) ===" log "=== Phase 12a: Vendor Seed ==="
# saas-vendor role already created above — just create the separate vendor user # Create saas-vendor global role with all API scopes
# Create vendor user 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'..." log "Checking for vendor user '$VENDOR_USER'..."
VENDOR_USER_ID=$(api_get "/api/users?search=$VENDOR_USER" | jq -r ".[] | select(.username == \"$VENDOR_USER\") | .id") VENDOR_USER_ID=$(api_get "/api/users?search=$VENDOR_USER" | jq -r ".[] | select(.username == \"$VENDOR_USER\") | .id")
if [ -n "$VENDOR_USER_ID" ]; then if [ -z "$VENDOR_USER_ID" ]; then
log "Vendor user exists: $VENDOR_USER_ID"
else
log "Creating vendor user '$VENDOR_USER'..." log "Creating vendor user '$VENDOR_USER'..."
VENDOR_RESPONSE=$(api_post "/api/users" "{ VENDOR_RESPONSE=$(api_post "/api/users" "{
\"username\": \"$VENDOR_USER\", \"username\": \"$VENDOR_USER\",
@@ -633,12 +632,14 @@ if [ "$VENDOR_SEED_ENABLED" = "true" ]; then
}") }")
VENDOR_USER_ID=$(echo "$VENDOR_RESPONSE" | jq -r '.id') VENDOR_USER_ID=$(echo "$VENDOR_RESPONSE" | jq -r '.id')
log "Created vendor user: $VENDOR_USER_ID" log "Created vendor user: $VENDOR_USER_ID"
else
log "Vendor user exists: $VENDOR_USER_ID"
fi fi
# Assign saas-vendor role # Assign saas-vendor role to vendor user
if [ -n "$VENDOR_ROLE_ID" ] && [ "$VENDOR_ROLE_ID" != "null" ]; then 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 api_post "/api/users/$VENDOR_USER_ID/roles" "{\"roleIds\": [\"$VENDOR_ROLE_ID\"]}" >/dev/null 2>&1
log "Assigned saas-vendor role globally." log "Assigned saas-vendor role to vendor user."
fi fi
# Add vendor to all existing organizations with owner role # Add vendor to all existing organizations with owner role
@@ -652,14 +653,14 @@ if [ "$VENDOR_SEED_ENABLED" = "true" ]; then
SEED_ORG_NAME=$(echo "$ORGS" | jq -r ".[$i].name") 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 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 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" \ curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" $HOST_ARGS \
-d "{\"organizationRoleIds\": [\"$ORG_OWNER_ROLE_ID\"]}" \ -d "{\"organizationRoleIds\": [\"$ORG_OWNER_ROLE_ID\"]}" \
"${LOGTO_ENDPOINT}/api/organizations/$SEED_ORG_ID/users/$VENDOR_USER_ID/roles" >/dev/null 2>&1 "${LOGTO_ENDPOINT}/api/organizations/$SEED_ORG_ID/users/$VENDOR_USER_ID/roles" >/dev/null 2>&1
fi fi
log " Added to org '$SEED_ORG_NAME' with owner role." log " Added to org '$SEED_ORG_NAME' with owner role."
done done
# Grant vendor user Logto console access (admin tenant, port 3002) # Grant vendor user Logto console access
if [ -n "$ADMIN_TOKEN" ] && [ "$ADMIN_TOKEN" != "null" ]; then if [ -n "$ADMIN_TOKEN" ] && [ "$ADMIN_TOKEN" != "null" ]; then
log "Granting vendor Logto console access..." 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) VENDOR_CONSOLE_USER_ID=$(admin_api_get "/api/users?search=$VENDOR_USER" | jq -r ".[] | select(.username == \"$VENDOR_USER\") | .id" 2>/dev/null)
@@ -683,11 +684,69 @@ if [ "$VENDOR_SEED_ENABLED" = "true" ]; then
[ "$V_ROLE_IDS" != "[]" ] && admin_api_post "/api/users/$VENDOR_CONSOLE_USER_ID/roles" "{\"roleIds\": $V_ROLE_IDS}" >/dev/null 2>&1 [ "$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." log "Vendor granted Logto console access."
fi fi
else fi
log "Skipping vendor console access (no admin token)."
fi fi
log "Vendor seed complete." log "Vendor seed complete."
elif [ -n "$TENANT_ORG_NAME" ]; then
log ""
log "=== Phase 12b: Single-Tenant Setup ==="
# Create organization for the tenant
TENANT_SLUG=$(echo "$TENANT_ORG_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g; s/--*/-/g; s/^-//; s/-$//')
log "Creating organization '$TENANT_ORG_NAME' (slug: $TENANT_SLUG)..."
EXISTING_ORG_ID=$(api_get "/api/organizations" | jq -r ".[] | select(.name == \"$TENANT_ORG_NAME\") | .id")
if [ -n "$EXISTING_ORG_ID" ]; then
log "Organization already exists: $EXISTING_ORG_ID"
TENANT_ORG_ID="$EXISTING_ORG_ID"
else
ORG_RESPONSE=$(api_post "/api/organizations" "{\"name\": \"$TENANT_ORG_NAME\"}")
TENANT_ORG_ID=$(echo "$ORG_RESPONSE" | jq -r '.id')
log "Created organization: $TENANT_ORG_ID"
fi
# Add admin user to organization with owner role
if [ -n "$TENANT_ORG_ID" ] && [ "$TENANT_ORG_ID" != "null" ]; then
api_post "/api/organizations/$TENANT_ORG_ID/users" "{\"userIds\": [\"$ADMIN_USER_ID\"]}" >/dev/null 2>&1
ORG_OWNER_ROLE_ID=$(api_get "/api/organization-roles" | jq -r '.[] | select(.name == "owner") | .id')
if [ -n "$ORG_OWNER_ROLE_ID" ] && [ "$ORG_OWNER_ROLE_ID" != "null" ]; then
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" $HOST_ARGS \
-d "{\"organizationRoleIds\": [\"$ORG_OWNER_ROLE_ID\"]}" \
"${LOGTO_ENDPOINT}/api/organizations/$TENANT_ORG_ID/users/$ADMIN_USER_ID/roles" >/dev/null 2>&1
fi
log "Added admin user to organization with owner role."
# Register OIDC redirect URIs for the tenant
TRAD_APP=$(api_get "/api/applications" | jq -r ".[] | select(.name == \"$TRAD_APP_NAME\") | .id")
if [ -n "$TRAD_APP" ] && [ "$TRAD_APP" != "null" ]; then
EXISTING_URIS=$(api_get "/api/applications/$TRAD_APP" | jq -r '.oidcClientMetadata.redirectUris')
NEW_URI="${PROTO}://${HOST}/t/${TENANT_SLUG}/oidc/callback"
if ! echo "$EXISTING_URIS" | jq -e ".[] | select(. == \"$NEW_URI\")" >/dev/null 2>&1; then
UPDATED_URIS=$(echo "$EXISTING_URIS" | jq ". + [\"$NEW_URI\"]")
api_patch "/api/applications/$TRAD_APP" "{\"oidcClientMetadata\": {\"redirectUris\": $UPDATED_URIS}}" >/dev/null 2>&1
log "Registered OIDC redirect URI for tenant: $NEW_URI"
fi
fi
# Insert tenant record into cameleer_saas database
pgpass
EXISTING_TENANT=$(psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB_SAAS" -t -A -c \
"SELECT id FROM tenants WHERE slug = '$TENANT_SLUG';" 2>/dev/null)
if [ -z "$EXISTING_TENANT" ]; then
TENANT_UUID=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || python3 -c "import uuid; print(uuid.uuid4())" 2>/dev/null)
psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB_SAAS" -c \
"INSERT INTO tenants (id, name, slug, tier, status, logto_org_id, created_at, updated_at)
VALUES ('$TENANT_UUID', '$TENANT_ORG_NAME', '$TENANT_SLUG', 'STANDARD', 'PROVISIONING', '$TENANT_ORG_ID', NOW(), NOW());" >/dev/null 2>&1
log "Created tenant record: $TENANT_SLUG (status: PROVISIONING)"
log " The SaaS app will provision the tenant's server on next restart or via the UI."
else
log "Tenant record already exists for slug: $TENANT_SLUG"
fi
fi
log "Single-tenant setup complete."
fi fi
log "" log ""

View File

@@ -72,6 +72,7 @@ VERSION=""
COMPOSE_PROJECT="" COMPOSE_PROJECT=""
DOCKER_SOCKET="" DOCKER_SOCKET=""
NODE_TLS_REJECT="" NODE_TLS_REJECT=""
TENANT_ORG_NAME=""
# --- State --- # --- State ---
MODE="" # simple, expert, silent MODE="" # simple, expert, silent
@@ -174,6 +175,7 @@ parse_args() {
--compose-project) COMPOSE_PROJECT="$2"; shift ;; --compose-project) COMPOSE_PROJECT="$2"; shift ;;
--docker-socket) DOCKER_SOCKET="$2"; shift ;; --docker-socket) DOCKER_SOCKET="$2"; shift ;;
--node-tls-reject) NODE_TLS_REJECT="$2"; shift ;; --node-tls-reject) NODE_TLS_REJECT="$2"; shift ;;
--tenant-org-name) TENANT_ORG_NAME="$2"; shift ;;
--reconfigure) RERUN_ACTION="reconfigure" ;; --reconfigure) RERUN_ACTION="reconfigure" ;;
--reinstall) RERUN_ACTION="reinstall" ;; --reinstall) RERUN_ACTION="reinstall" ;;
--confirm-destroy) CONFIRM_DESTROY=true ;; --confirm-destroy) CONFIRM_DESTROY=true ;;
@@ -258,6 +260,7 @@ load_config_file() {
compose_project) [ -z "$COMPOSE_PROJECT" ] && COMPOSE_PROJECT="$value" ;; compose_project) [ -z "$COMPOSE_PROJECT" ] && COMPOSE_PROJECT="$value" ;;
docker_socket) [ -z "$DOCKER_SOCKET" ] && DOCKER_SOCKET="$value" ;; docker_socket) [ -z "$DOCKER_SOCKET" ] && DOCKER_SOCKET="$value" ;;
node_tls_reject) [ -z "$NODE_TLS_REJECT" ] && NODE_TLS_REJECT="$value" ;; node_tls_reject) [ -z "$NODE_TLS_REJECT" ] && NODE_TLS_REJECT="$value" ;;
tenant_org_name) [ -z "$TENANT_ORG_NAME" ] && TENANT_ORG_NAME="$value" ;;
esac esac
done < "$file" done < "$file"
} }
@@ -424,6 +427,24 @@ run_simple_prompts() {
echo "" echo ""
prompt MONITORING_NETWORK "Monitoring network name (empty = skip)" "" prompt MONITORING_NETWORK "Monitoring network name (empty = skip)" ""
echo ""
echo " Deployment mode:"
echo " [1] Multi-tenant vendor — admin manages platform, creates tenants on demand"
echo " [2] Single tenant — set up one tenant for immediate use"
echo ""
local deploy_choice
read -rp " Select mode [1]: " deploy_choice
case "${deploy_choice:-1}" in
2)
VENDOR_ENABLED="false"
prompt TENANT_ORG_NAME "Organization / tenant name" ""
;;
*)
VENDOR_ENABLED="true"
TENANT_ORG_NAME=""
;;
esac
} }
run_expert_prompts() { run_expert_prompts() {
@@ -623,6 +644,9 @@ VENDOR_SEED_ENABLED=${VENDOR_ENABLED}
VENDOR_USER=${VENDOR_USER} VENDOR_USER=${VENDOR_USER}
VENDOR_PASS=${VENDOR_PASS:-} VENDOR_PASS=${VENDOR_PASS:-}
# Single-tenant org (when vendor is disabled)
TENANT_ORG_NAME=${TENANT_ORG_NAME:-}
# Docker # Docker
DOCKER_SOCKET=${DOCKER_SOCKET} DOCKER_SOCKET=${DOCKER_SOCKET}
@@ -771,6 +795,7 @@ EOF
VENDOR_SEED_ENABLED: "${VENDOR_SEED_ENABLED:-false}" VENDOR_SEED_ENABLED: "${VENDOR_SEED_ENABLED:-false}"
VENDOR_USER: ${VENDOR_USER:-vendor} VENDOR_USER: ${VENDOR_USER:-vendor}
VENDOR_PASS: ${VENDOR_PASS:-vendor} VENDOR_PASS: ${VENDOR_PASS:-vendor}
TENANT_ORG_NAME: ${TENANT_ORG_NAME:-}
healthcheck: 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"] 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 interval: 10s
@@ -985,6 +1010,7 @@ version=${VERSION}
compose_project=${COMPOSE_PROJECT} compose_project=${COMPOSE_PROJECT}
docker_socket=${DOCKER_SOCKET} docker_socket=${DOCKER_SOCKET}
node_tls_reject=${NODE_TLS_REJECT} node_tls_reject=${NODE_TLS_REJECT}
tenant_org_name=${TENANT_ORG_NAME}
EOF EOF
log_info "Saved installer config to cameleer.conf" log_info "Saved installer config to cameleer.conf"
} }