diff --git a/docker-compose.yml b/docker-compose.yml index 751bffe..a31f08a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -80,6 +80,7 @@ services: VENDOR_SEED_ENABLED: "${VENDOR_SEED_ENABLED:-false}" VENDOR_USER: ${VENDOR_USER:-vendor} VENDOR_PASS: ${VENDOR_PASS:-vendor} + TENANT_ORG_NAME: ${TENANT_ORG_NAME:-} 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 diff --git a/docker/logto-bootstrap.sh b/docker/logto-bootstrap.sh index 0415d2b..fb8c943 100644 --- a/docker/logto-bootstrap.sh +++ b/docker/logto-bootstrap.sh @@ -409,33 +409,6 @@ else log "Created platform owner: $ADMIN_USER_ID" 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) --- log "Granting SaaS admin Logto console access..." @@ -610,84 +583,170 @@ EOF 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 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 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") + # 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 [ -n "$VENDOR_USER_ID" ]; then - log "Vendor user exists: $VENDOR_USER_ID" - else - log "Creating vendor user '$VENDOR_USER'..." - VENDOR_RESPONSE=$(api_post "/api/users" "{ - \"username\": \"$VENDOR_USER\", - \"password\": \"$VENDOR_PASS\", - \"name\": \"$VENDOR_NAME\" + 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_USER_ID=$(echo "$VENDOR_RESPONSE" | jq -r '.id') - log "Created vendor user: $VENDOR_USER_ID" + 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 saas-vendor role - 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 globally." + # 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 - # 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') + # 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") - 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" \ - -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 (admin tenant, port 3002) - 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" "{ + 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_CONSOLE_USER_ID=$(echo "$VENDOR_CONSOLE_RESPONSE" | jq -r '.id') - log "Created vendor console user: $VENDOR_CONSOLE_USER_ID" + VENDOR_USER_ID=$(echo "$VENDOR_RESPONSE" | jq -r '.id') + log "Created vendor user: $VENDOR_USER_ID" else - log "Vendor console user exists: $VENDOR_CONSOLE_USER_ID" + log "Vendor user exists: $VENDOR_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." + + # 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 - else - log "Skipping vendor console access (no admin token)." fi log "Vendor seed complete." + +elif [ -n "$TENANT_ORG_NAME" ]; then + log "" + log "=== Phase 12b: Single-Tenant Setup ===" + + # Create organization for the tenant + TENANT_SLUG=$(echo "$TENANT_ORG_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g; s/--*/-/g; s/^-//; s/-$//') + log "Creating organization '$TENANT_ORG_NAME' (slug: $TENANT_SLUG)..." + + EXISTING_ORG_ID=$(api_get "/api/organizations" | jq -r ".[] | select(.name == \"$TENANT_ORG_NAME\") | .id") + if [ -n "$EXISTING_ORG_ID" ]; then + log "Organization already exists: $EXISTING_ORG_ID" + TENANT_ORG_ID="$EXISTING_ORG_ID" + else + ORG_RESPONSE=$(api_post "/api/organizations" "{\"name\": \"$TENANT_ORG_NAME\"}") + TENANT_ORG_ID=$(echo "$ORG_RESPONSE" | jq -r '.id') + log "Created organization: $TENANT_ORG_ID" + fi + + # Add admin user to organization with owner role + if [ -n "$TENANT_ORG_ID" ] && [ "$TENANT_ORG_ID" != "null" ]; then + api_post "/api/organizations/$TENANT_ORG_ID/users" "{\"userIds\": [\"$ADMIN_USER_ID\"]}" >/dev/null 2>&1 + ORG_OWNER_ROLE_ID=$(api_get "/api/organization-roles" | jq -r '.[] | select(.name == "owner") | .id') + if [ -n "$ORG_OWNER_ROLE_ID" ] && [ "$ORG_OWNER_ROLE_ID" != "null" ]; then + curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" $HOST_ARGS \ + -d "{\"organizationRoleIds\": [\"$ORG_OWNER_ROLE_ID\"]}" \ + "${LOGTO_ENDPOINT}/api/organizations/$TENANT_ORG_ID/users/$ADMIN_USER_ID/roles" >/dev/null 2>&1 + fi + log "Added admin user to organization with owner role." + + # Register OIDC redirect URIs for the tenant + TRAD_APP=$(api_get "/api/applications" | jq -r ".[] | select(.name == \"$TRAD_APP_NAME\") | .id") + if [ -n "$TRAD_APP" ] && [ "$TRAD_APP" != "null" ]; then + EXISTING_URIS=$(api_get "/api/applications/$TRAD_APP" | jq -r '.oidcClientMetadata.redirectUris') + NEW_URI="${PROTO}://${HOST}/t/${TENANT_SLUG}/oidc/callback" + if ! echo "$EXISTING_URIS" | jq -e ".[] | select(. == \"$NEW_URI\")" >/dev/null 2>&1; then + UPDATED_URIS=$(echo "$EXISTING_URIS" | jq ". + [\"$NEW_URI\"]") + api_patch "/api/applications/$TRAD_APP" "{\"oidcClientMetadata\": {\"redirectUris\": $UPDATED_URIS}}" >/dev/null 2>&1 + log "Registered OIDC redirect URI for tenant: $NEW_URI" + fi + fi + + # 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 log "" diff --git a/installer/install.sh b/installer/install.sh index 155ee44..79be829 100644 --- a/installer/install.sh +++ b/installer/install.sh @@ -72,6 +72,7 @@ VERSION="" COMPOSE_PROJECT="" DOCKER_SOCKET="" NODE_TLS_REJECT="" +TENANT_ORG_NAME="" # --- State --- MODE="" # simple, expert, silent @@ -174,6 +175,7 @@ parse_args() { --compose-project) COMPOSE_PROJECT="$2"; shift ;; --docker-socket) DOCKER_SOCKET="$2"; shift ;; --node-tls-reject) NODE_TLS_REJECT="$2"; shift ;; + --tenant-org-name) TENANT_ORG_NAME="$2"; shift ;; --reconfigure) RERUN_ACTION="reconfigure" ;; --reinstall) RERUN_ACTION="reinstall" ;; --confirm-destroy) CONFIRM_DESTROY=true ;; @@ -258,6 +260,7 @@ load_config_file() { compose_project) [ -z "$COMPOSE_PROJECT" ] && COMPOSE_PROJECT="$value" ;; docker_socket) [ -z "$DOCKER_SOCKET" ] && DOCKER_SOCKET="$value" ;; node_tls_reject) [ -z "$NODE_TLS_REJECT" ] && NODE_TLS_REJECT="$value" ;; + tenant_org_name) [ -z "$TENANT_ORG_NAME" ] && TENANT_ORG_NAME="$value" ;; esac done < "$file" } @@ -424,6 +427,24 @@ run_simple_prompts() { echo "" 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() { @@ -623,6 +644,9 @@ VENDOR_SEED_ENABLED=${VENDOR_ENABLED} VENDOR_USER=${VENDOR_USER} VENDOR_PASS=${VENDOR_PASS:-} +# Single-tenant org (when vendor is disabled) +TENANT_ORG_NAME=${TENANT_ORG_NAME:-} + # Docker DOCKER_SOCKET=${DOCKER_SOCKET} @@ -771,6 +795,7 @@ EOF VENDOR_SEED_ENABLED: "${VENDOR_SEED_ENABLED:-false}" VENDOR_USER: ${VENDOR_USER:-vendor} VENDOR_PASS: ${VENDOR_PASS:-vendor} + TENANT_ORG_NAME: ${TENANT_ORG_NAME:-} 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 @@ -985,6 +1010,7 @@ version=${VERSION} compose_project=${COMPOSE_PROJECT} docker_socket=${DOCKER_SOCKET} node_tls_reject=${NODE_TLS_REJECT} +tenant_org_name=${TENANT_ORG_NAME} EOF log_info "Saved installer config to cameleer.conf" }