From 3d41d4a3da372bef3b149f02c2cafb05416e41a4 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:49:16 +0200 Subject: [PATCH] =?UTF-8?q?feat:=204-role=20model=20=E2=80=94=20owner,=20o?= =?UTF-8?q?perator,=20viewer=20+=20vendor-seed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 11 +- docker/logto-bootstrap.sh | 148 +++++++++--------- docker/vendor-seed.sh | 135 ++++++++++++++++ .../specs/2026-04-07-role-license-redesign.md | 32 ++++ ui/src/components/Layout.tsx | 21 --- 5 files changed, 246 insertions(+), 101 deletions(-) create mode 100644 docker/vendor-seed.sh create mode 100644 docs/superpowers/specs/2026-04-07-role-license-redesign.md diff --git a/CLAUDE.md b/CLAUDE.md index 247734a..61f1562 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docker/logto-bootstrap.sh b/docker/logto-bootstrap.sh index 615bc1b..481d3ce 100644 --- a/docker/logto-bootstrap.sh +++ b/docker/logto-bootstrap.sh @@ -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" diff --git a/docker/vendor-seed.sh b/docker/vendor-seed.sh new file mode 100644 index 0000000..64025fa --- /dev/null +++ b/docker/vendor-seed.sh @@ -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." diff --git a/docs/superpowers/specs/2026-04-07-role-license-redesign.md b/docs/superpowers/specs/2026-04-07-role-license-redesign.md new file mode 100644 index 0000000..d6e0a64 --- /dev/null +++ b/docs/superpowers/specs/2026-04-07-role-license-redesign.md @@ -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. diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 06ae5ac..2ee7e36 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -67,20 +67,6 @@ function ObsIcon() { ); } -function UserIcon() { - return ( - - ); -} - function PlatformIcon() { return (