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

@@ -59,7 +59,8 @@ Separate Vite+React SPA replacing Logto's default sign-in page. Visually matches
- Tenant isolation enforced by `TenantIsolationInterceptor` (a single `HandlerInterceptor` on `/api/**` that resolves JWT org_id to TenantContext and validates `{tenantId}`, `{environmentId}`, `{appId}` path variables; fail-closed, platform admins bypass)
- 13 OAuth2 scopes on the Logto API resource (`https://api.cameleer.local`): 10 platform scopes + 3 server scopes (`server:admin`, `server:operator`, `server:viewer`), served to the frontend from `GET /platform/api/config`
- Server scopes map to server RBAC roles via JWT `scope` claim (SaaS platform path) or `roles` claim (server-ui OIDC login path)
- Org role `admin` gets `server:admin`, org role `member` gets `server:viewer`
- 4-role model: `saas-vendor` (global, hosted only), org `owner``server:admin`, org `operator``server:operator`, org `viewer` `server:viewer`
- `saas-vendor` global role injected via `docker/vendor-seed.sh` (not standard bootstrap) — has `platform:admin` + all tenant scopes
- Custom `JwtDecoder` in `SecurityConfig.java` — ES384 algorithm, `at+jwt` token type, split issuer-uri (string validation) / jwk-set-uri (Docker-internal fetch), audience validation (`https://api.cameleer.local`)
- Logto Custom JWT (Phase 7b in bootstrap) injects a `roles` claim into access tokens based on org roles and global roles — this makes role data available to the server without Logto-specific code
@@ -89,16 +90,16 @@ Idempotent script run via `logto-bootstrap` init container. Phases:
2. Get Management API token (reads `m-default` secret from DB)
3. Create Logto apps (SPA, Traditional with `skipConsent`, M2M with Management API role)
3b. Create API resource scopes (10 platform + 3 server scopes)
4. Create roles (platform-admin, org admin/member with API resource scope assignments)
5. Create users (SaaS admin with platform-admin role + Logto console access, tenant admin)
6. Create organization, add users with org roles
4. Create org roles (owner, operator, viewer with API resource scope assignments)
5. Create users (platform owner with Logto console access, viewer for testing read-only OIDC)
6. Create organization, add users with org roles (owner + viewer)
7. Configure cameleer3-server OIDC (`rolesClaim: "roles"`, `audience`, `defaultRoles: ["VIEWER"]`)
7b. Configure Logto Custom JWT for access tokens (maps org roles → `roles` claim: admin→server:admin, member→server:viewer)
8. Configure Logto sign-in branding (Cameleer colors `#C6820E`/`#D4941E`, logo from `/platform/logo.svg`)
9. Cleanup seeded Logto apps
10. Write bootstrap results to `/data/logto-bootstrap.json`
SaaS admin credentials (`SAAS_ADMIN_USER`/`SAAS_ADMIN_PASS`) work for both the SaaS platform and the Logto console (port 3002).
Platform owner credentials (`SAAS_ADMIN_USER`/`SAAS_ADMIN_PASS`) work for both the SaaS platform and the Logto console (port 3002). The `saas-vendor` global role (hosted only) is created separately via `docker/vendor-seed.sh`.
## Related Conventions

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."

View File

@@ -0,0 +1,32 @@
# Role Model + License Model Redesign
**Date:** 2026-04-07
**Status:** Approved
## Problem
The current role model (platform-admin, org admin, org member) doesn't map cleanly to real-world personas. The member role can deploy but can't manage apps — it's neither a proper operator nor a proper viewer. There's no read-only role. The license model assumes SaaS (per-tenant) with no on-premise consideration.
## Decision
### 4-Role Model
| Role | Logto Type | Scopes | Persona |
|------|-----------|--------|---------|
| SaaS Vendor | Global `saas-vendor` | `platform:admin` + all tenant scopes | SaaS operator (hosted only) |
| Platform Owner | Org `owner` | All 10 tenant scopes + `server:admin` | Customer admin |
| Operator | Org `operator` | `apps:manage`, `apps:deploy`, `observe:read`, `observe:debug`, `server:operator` | DevOps |
| Viewer | Org `viewer` | `observe:read`, `server:viewer` | Read-only stakeholder |
### Deployment Modes
- **SaaS:** Vendor-seed script (separate from bootstrap) creates `saas-vendor` role. Standard bootstrap creates tenants with owner/operator/viewer.
- **On-premise:** Single implicit tenant. First user is `owner`. No vendor role exists.
### License Model
No schema changes. `LicenseEntity.tenantId` works for both modes. On-prem has one tenant = one license. SaaS has per-tenant licenses managed by vendor.
### Vendor-Seed Script
`docker/vendor-seed.sh` — run once on hosted environment, not part of standard bootstrap. Creates saas-vendor global role + vendor user.

View File

@@ -67,20 +67,6 @@ function ObsIcon() {
);
}
function UserIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="8" cy="5" r="3" stroke="currentColor" strokeWidth="1.5" />
<path
d="M2 13c0-3 2.7-5 6-5s6 2 6 5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
);
}
function PlatformIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
@@ -156,13 +142,6 @@ export function Layout() {
label="View Dashboard"
onClick={() => window.open('/server/', '_blank', 'noopener')}
/>
{/* User info + logout */}
<Sidebar.FooterLink
icon={<UserIcon />}
label={username ?? 'Account'}
onClick={logout}
/>
</Sidebar.Footer>
</Sidebar>
);