feat: 4-role model — owner, operator, viewer + vendor-seed
All checks were successful
CI / build (push) Successful in 57s
CI / docker (push) Successful in 47s

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>
This commit is contained in:
hsiegeln
2026-04-07 13:49:16 +02:00
parent c96faa4f3f
commit 3d41d4a3da
5 changed files with 246 additions and 101 deletions

View File

@@ -243,9 +243,14 @@ SCOPE_SERVER_OPERATOR=$(create_scope "server:operator" "Deploy and manage apps i
SCOPE_SERVER_VIEWER=$(create_scope "server:viewer" "Read-only server observability")
# Collect scope IDs for role assignment
ALL_TENANT_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\""
ALL_SCOPE_IDS="\"$SCOPE_PLATFORM_ADMIN\",$ALL_TENANT_SCOPE_IDS"
MEMBER_SCOPE_IDS="\"$SCOPE_APPS_DEPLOY\",\"$SCOPE_OBSERVE_READ\",\"$SCOPE_OBSERVE_DEBUG\",\"$SCOPE_SERVER_VIEWER\""
# 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"
# --- M2M app ---
M2M_ID=$(echo "$EXISTING_APPS" | jq -r ".[] | select(.name == \"$M2M_APP_NAME\" and .type == \"MachineToMachine\") | .id")
@@ -301,80 +306,71 @@ fi
# PHASE 4: Create roles
# ============================================================
# --- Global platform-admin role ---
log "Creating platform-admin role..."
EXISTING_ROLES=$(api_get "/api/roles")
PA_ROLE_ID=$(echo "$EXISTING_ROLES" | jq -r '.[] | select(.name == "platform-admin" and .type == "User") | .id')
if [ -n "$PA_ROLE_ID" ]; then
log "platform-admin role exists: $PA_ROLE_ID"
# Ensure scopes are assigned (idempotent)
api_post "/api/roles/${PA_ROLE_ID}/scopes" "{\"scopeIds\": [$ALL_SCOPE_IDS]}" >/dev/null 2>&1
else
PA_RESPONSE=$(api_post "/api/roles" "{
\"name\": \"platform-admin\",
\"description\": \"SaaS platform administrator\",
\"type\": \"User\",
\"scopeIds\": [$ALL_SCOPE_IDS]
}")
PA_ROLE_ID=$(echo "$PA_RESPONSE" | jq -r '.id')
log "Created platform-admin role: $PA_ROLE_ID"
fi
# --- Organization roles ---
# --- 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.
log "Creating organization roles..."
EXISTING_ORG_ROLES=$(api_get "/api/organization-roles")
ORG_ADMIN_ROLE_ID=$(echo "$EXISTING_ORG_ROLES" | jq -r '.[] | select(.name == "admin") | .id')
if [ -n "$ORG_ADMIN_ROLE_ID" ]; then
log "Org admin role exists: $ORG_ADMIN_ROLE_ID"
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"
else
ORG_ADMIN_RESPONSE=$(api_post "/api/organization-roles" "{
\"name\": \"admin\",
\"description\": \"Tenant administrator\"
ORG_OWNER_RESPONSE=$(api_post "/api/organization-roles" "{
\"name\": \"owner\",
\"description\": \"Platform owner — full tenant control\"
}")
ORG_ADMIN_ROLE_ID=$(echo "$ORG_ADMIN_RESPONSE" | jq -r '.id')
log "Created org admin role: $ORG_ADMIN_ROLE_ID"
ORG_OWNER_ROLE_ID=$(echo "$ORG_OWNER_RESPONSE" | jq -r '.id')
log "Created org owner role: $ORG_OWNER_ROLE_ID"
fi
ORG_MEMBER_ROLE_ID=$(echo "$EXISTING_ORG_ROLES" | jq -r '.[] | select(.name == "member") | .id')
if [ -z "$ORG_MEMBER_ROLE_ID" ]; then
ORG_MEMBER_RESPONSE=$(api_post "/api/organization-roles" "{
\"name\": \"member\",
\"description\": \"Tenant member\"
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\"
}")
ORG_MEMBER_ROLE_ID=$(echo "$ORG_MEMBER_RESPONSE" | jq -r '.id')
log "Created org member role: $ORG_MEMBER_ROLE_ID"
ORG_OPERATOR_ROLE_ID=$(echo "$ORG_OPERATOR_RESPONSE" | jq -r '.id')
log "Created org operator role: $ORG_OPERATOR_ROLE_ID"
fi
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\"
}")
ORG_VIEWER_ROLE_ID=$(echo "$ORG_VIEWER_RESPONSE" | jq -r '.id')
log "Created org viewer role: $ORG_VIEWER_ROLE_ID"
fi
# Assign API resource scopes to org roles (these appear in org-scoped resource tokens)
log "Assigning API resource scopes to organization roles..."
api_put "/api/organization-roles/${ORG_ADMIN_ROLE_ID}/resource-scopes" "{\"scopeIds\": [$ALL_TENANT_SCOPE_IDS]}" >/dev/null 2>&1
api_put "/api/organization-roles/${ORG_MEMBER_ROLE_ID}/resource-scopes" "{\"scopeIds\": [$MEMBER_SCOPE_IDS]}" >/dev/null 2>&1
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
log "API resource scopes assigned to organization roles."
# ============================================================
# PHASE 5: Create users
# ============================================================
# --- SaaS Owner ---
log "Checking for SaaS owner user '$SAAS_ADMIN_USER'..."
# --- Platform Owner ---
log "Checking for platform owner user '$SAAS_ADMIN_USER'..."
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
log "SaaS owner exists: $ADMIN_USER_ID"
log "Platform owner exists: $ADMIN_USER_ID"
else
log "Creating SaaS owner '$SAAS_ADMIN_USER'..."
log "Creating platform owner '$SAAS_ADMIN_USER'..."
ADMIN_RESPONSE=$(api_post "/api/users" "{
\"username\": \"$SAAS_ADMIN_USER\",
\"password\": \"$SAAS_ADMIN_PASS\",
\"name\": \"Platform Admin\"
\"name\": \"Platform Owner\"
}")
ADMIN_USER_ID=$(echo "$ADMIN_RESPONSE" | jq -r '.id')
log "Created SaaS owner: $ADMIN_USER_ID"
# Assign platform-admin role
if [ -n "$PA_ROLE_ID" ] && [ "$PA_ROLE_ID" != "null" ]; then
api_post "/api/users/$ADMIN_USER_ID/roles" "{\"roleIds\": [\"$PA_ROLE_ID\"]}" >/dev/null
log "Assigned platform-admin role to SaaS owner."
fi
log "Created platform owner: $ADMIN_USER_ID"
# No global role assigned — owner role is org-scoped.
# SaaS vendor role is injected via docker/vendor-seed.sh on hosted environments.
fi
# --- Grant SaaS admin Logto console access (admin tenant, port 3002) ---
@@ -467,20 +463,20 @@ fi
fi # end: ADMIN_TOKEN check
fi # end: M_ADMIN_SECRET check
# --- Tenant Admin ---
log "Checking for tenant admin '$TENANT_ADMIN_USER'..."
# --- Viewer user (for testing read-only OIDC role in server) ---
log "Checking for viewer user '$TENANT_ADMIN_USER'..."
TENANT_USER_ID=$(api_get "/api/users?search=$TENANT_ADMIN_USER" | jq -r ".[] | select(.username == \"$TENANT_ADMIN_USER\") | .id")
if [ -n "$TENANT_USER_ID" ]; then
log "Tenant admin exists: $TENANT_USER_ID"
log "Viewer user exists: $TENANT_USER_ID"
else
log "Creating tenant admin '$TENANT_ADMIN_USER'..."
log "Creating viewer user '$TENANT_ADMIN_USER'..."
TENANT_RESPONSE=$(api_post "/api/users" "{
\"username\": \"$TENANT_ADMIN_USER\",
\"password\": \"$TENANT_ADMIN_PASS\",
\"name\": \"Tenant Admin\"
\"name\": \"Viewer\"
}")
TENANT_USER_ID=$(echo "$TENANT_RESPONSE" | jq -r '.id')
log "Created tenant admin: $TENANT_USER_ID"
log "Created viewer user: $TENANT_USER_ID"
fi
# ============================================================
@@ -504,18 +500,18 @@ else
fi
# Add users to organization
if [ -n "$TENANT_USER_ID" ] && [ "$TENANT_USER_ID" != "null" ]; then
log "Adding tenant user to organization..."
api_post "/api/organizations/$ORG_ID/users" "{\"userIds\": [\"$TENANT_USER_ID\"]}" >/dev/null 2>&1
api_put "/api/organizations/$ORG_ID/users/$TENANT_USER_ID/roles" "{\"organizationRoleIds\": [\"$ORG_MEMBER_ROLE_ID\"]}" >/dev/null 2>&1
log "Tenant user added to org with member role."
if [ -n "$ADMIN_USER_ID" ] && [ "$ADMIN_USER_ID" != "null" ]; then
log "Adding platform owner to organization..."
api_post "/api/organizations/$ORG_ID/users" "{\"userIds\": [\"$ADMIN_USER_ID\"]}" >/dev/null 2>&1
api_put "/api/organizations/$ORG_ID/users/$ADMIN_USER_ID/roles" "{\"organizationRoleIds\": [\"$ORG_OWNER_ROLE_ID\"]}" >/dev/null 2>&1
log "Platform owner added to org with owner role."
fi
if [ -n "$ADMIN_USER_ID" ] && [ "$ADMIN_USER_ID" != "null" ]; then
log "Adding SaaS owner to organization..."
api_post "/api/organizations/$ORG_ID/users" "{\"userIds\": [\"$ADMIN_USER_ID\"]}" >/dev/null 2>&1
api_put "/api/organizations/$ORG_ID/users/$ADMIN_USER_ID/roles" "{\"organizationRoleIds\": [\"$ORG_ADMIN_ROLE_ID\"]}" >/dev/null 2>&1
log "SaaS owner added to org with admin role."
if [ -n "$TENANT_USER_ID" ] && [ "$TENANT_USER_ID" != "null" ]; then
log "Adding viewer user to organization..."
api_post "/api/organizations/$ORG_ID/users" "{\"userIds\": [\"$TENANT_USER_ID\"]}" >/dev/null 2>&1
api_put "/api/organizations/$ORG_ID/users/$TENANT_USER_ID/roles" "{\"organizationRoleIds\": [\"$ORG_VIEWER_ROLE_ID\"]}" >/dev/null 2>&1
log "Viewer user added to org with viewer role."
fi
# ============================================================
@@ -575,7 +571,7 @@ fi
log "Configuring Logto Custom JWT for access tokens..."
CUSTOM_JWT_SCRIPT='const getCustomJwtClaims = async ({ token, context, environmentVariables }) => {
const roleMap = { admin: "server:admin", member: "server:viewer" };
const roleMap = { owner: "server:admin", operator: "server:operator", viewer: "server:viewer" };
const roles = new Set();
if (context?.user?.organizationRoles) {
for (const orgRole of context.user.organizationRoles) {
@@ -585,7 +581,7 @@ CUSTOM_JWT_SCRIPT='const getCustomJwtClaims = async ({ token, context, environme
}
if (context?.user?.roles) {
for (const role of context.user.roles) {
if (role.name === "platform-admin") roles.add("server:admin");
if (role.name === "saas-vendor") roles.add("server:admin");
}
}
return roles.size > 0 ? { roles: [...roles] } : {};
@@ -661,8 +657,10 @@ chmod 644 "$BOOTSTRAP_FILE"
log ""
log "=== Bootstrap complete! ==="
# dev only — remove credential logging in production
log " SaaS Owner: $SAAS_ADMIN_USER / $SAAS_ADMIN_PASS"
log " Tenant Admin: $TENANT_ADMIN_USER / $TENANT_ADMIN_PASS"
log " Tenant: $TENANT_NAME (slug: $TENANT_SLUG)"
log " Organization: $ORG_ID"
log " SPA Client ID: $SPA_ID"
log " Platform Owner: $SAAS_ADMIN_USER / $SAAS_ADMIN_PASS (org role: owner)"
log " Viewer: $TENANT_ADMIN_USER / $TENANT_ADMIN_PASS (org role: viewer)"
log " Tenant: $TENANT_NAME (slug: $TENANT_SLUG)"
log " Organization: $ORG_ID"
log " SPA Client ID: $SPA_ID"
log ""
log " To add SaaS Vendor role (hosted only): run docker/vendor-seed.sh"

135
docker/vendor-seed.sh Normal file
View File

@@ -0,0 +1,135 @@
#!/bin/sh
set -e
# Cameleer SaaS — Vendor Seed Script
# Creates the saas-vendor global role and vendor user.
# Run ONCE on the hosted SaaS environment AFTER standard bootstrap.
# NOT part of docker-compose.yml — invoked manually or by CI.
LOGTO_ENDPOINT="${LOGTO_ENDPOINT:-http://logto:3001}"
MGMT_API_RESOURCE="https://default.logto.app/api"
API_RESOURCE_INDICATOR="https://api.cameleer.local"
PG_HOST="${PG_HOST:-postgres}"
PG_USER="${PG_USER:-cameleer}"
PG_DB_LOGTO="logto"
# Vendor credentials (override via env vars)
VENDOR_USER="${VENDOR_USER:-vendor}"
VENDOR_PASS="${VENDOR_PASS:-vendor}"
VENDOR_NAME="${VENDOR_NAME:-SaaS Vendor}"
log() { echo "[vendor-seed] $1"; }
pgpass() { PGPASSWORD="${PG_PASSWORD:-cameleer_dev}"; export PGPASSWORD; }
# Install jq + curl
apk add --no-cache jq curl >/dev/null 2>&1
# ============================================================
# Get Management API token
# ============================================================
log "Reading M2M credentials from bootstrap file..."
BOOTSTRAP_FILE="/data/logto-bootstrap.json"
if [ ! -f "$BOOTSTRAP_FILE" ]; then
log "ERROR: Bootstrap file not found at $BOOTSTRAP_FILE — run standard bootstrap first"
exit 1
fi
M2M_ID=$(jq -r '.m2mClientId' "$BOOTSTRAP_FILE")
M2M_SECRET=$(jq -r '.m2mClientSecret' "$BOOTSTRAP_FILE")
if [ -z "$M2M_ID" ] || [ "$M2M_ID" = "null" ] || [ -z "$M2M_SECRET" ] || [ "$M2M_SECRET" = "null" ]; then
log "ERROR: M2M credentials not found in bootstrap file"
exit 1
fi
log "Getting Management API token..."
TOKEN_RESPONSE=$(curl -s -X POST "${LOGTO_ENDPOINT}/oidc/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials&client_id=${M2M_ID}&client_secret=${M2M_SECRET}&resource=${MGMT_API_RESOURCE}&scope=all")
TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token' 2>/dev/null)
[ -z "$TOKEN" ] || [ "$TOKEN" = "null" ] && { log "ERROR: Failed to get token"; exit 1; }
log "Got Management API token."
api_get() { curl -s -H "Authorization: Bearer $TOKEN" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || echo "[]"; }
api_post() { curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d "$2" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true; }
# ============================================================
# Create saas-vendor global role
# ============================================================
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_ROLE_ID" ]; then
log "saas-vendor role exists: $VENDOR_ROLE_ID"
else
# Collect all API resource scope IDs
EXISTING_RESOURCES=$(api_get "/api/resources")
API_RESOURCE_ID=$(echo "$EXISTING_RESOURCES" | jq -r ".[] | select(.indicator == \"$API_RESOURCE_INDICATOR\") | .id")
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"
fi
# ============================================================
# 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")
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\"
}")
VENDOR_USER_ID=$(echo "$VENDOR_RESPONSE" | jq -r '.id')
log "Created vendor user: $VENDOR_USER_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."
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
ORG_ID=$(echo "$ORGS" | jq -r ".[$i].id")
ORG_NAME=$(echo "$ORGS" | jq -r ".[$i].name")
api_post "/api/organizations/$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/$ORG_ID/users/$VENDOR_USER_ID/roles" >/dev/null 2>&1
fi
log " Added to org '$ORG_NAME' ($ORG_ID) with owner role."
done
log ""
log "=== Vendor seed complete! ==="
log " Vendor user: $VENDOR_USER / $VENDOR_PASS"
log " Role: saas-vendor (global) + owner (in all orgs)"
log " This user has platform:admin scope and cross-tenant access."