diff --git a/CLAUDE.md b/CLAUDE.md index 5bcfbe4..fbfea57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,11 @@ The existing cameleer3-server already has single-tenant auth (JWT, RBAC, bootstr - Proxy or federate access to tenant-specific cameleer3-server instances - Enforce usage quotas and metered billing +Auth enforcement (current state): +- All API endpoints enforce OAuth2 scopes via `@PreAuthorize("hasAuthority('SCOPE_xxx')")` annotations +- Tenant isolation enforced at two levels: `TenantResolutionFilter` (rejects cross-tenant path access) and `TenantOwnershipValidator` (verifies resource ownership at service level) +- 10 OAuth2 scopes defined on the Logto API resource (`https://api.cameleer.local`), served to the frontend from `GET /api/config` + ## Related Conventions - Gitea-hosted: `gitea.siegeln.net/cameleer/` diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 6e6c69b..6f324c3 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -15,6 +15,7 @@ services: - "8080:8080" volumes: - ./ui/dist:/app/static + - ./target/cameleer-saas-0.1.0-SNAPSHOT.jar:/app/app.jar environment: SPRING_PROFILES_ACTIVE: dev SPRING_WEB_RESOURCES_STATIC_LOCATIONS: file:/app/static/,classpath:/static/ diff --git a/docker/logto-bootstrap.sh b/docker/logto-bootstrap.sh index 4041afe..b5ecd44 100644 --- a/docker/logto-bootstrap.sh +++ b/docker/logto-bootstrap.sh @@ -201,12 +201,12 @@ create_scope() { local desc="$2" local existing_id=$(echo "$EXISTING_SCOPES" | jq -r ".[] | select(.name == \"$name\") | .id") if [ -n "$existing_id" ]; then - log " Scope '$name' exists: $existing_id" + log " Scope '$name' exists: $existing_id" >&2 echo "$existing_id" else local resp=$(api_post "/api/resources/${API_RESOURCE_ID}/scopes" "{\"name\": \"$name\", \"description\": \"$desc\"}") local new_id=$(echo "$resp" | jq -r '.id') - log " Created scope '$name': $new_id" + log " Created scope '$name': $new_id" >&2 echo "$new_id" fi } @@ -328,40 +328,11 @@ if [ -z "$ORG_MEMBER_ROLE_ID" ]; then log "Created org member role: $ORG_MEMBER_ROLE_ID" fi -# --- Organization permissions (scopes) --- -log "Creating organization permissions..." -EXISTING_ORG_SCOPES=$(api_get "/api/organization-scopes") - -create_org_scope() { - local name="$1" - local desc="$2" - local existing_id=$(echo "$EXISTING_ORG_SCOPES" | jq -r ".[] | select(.name == \"$name\") | .id") - if [ -n "$existing_id" ]; then - echo "$existing_id" - else - local resp=$(api_post "/api/organization-scopes" "{\"name\": \"$name\", \"description\": \"$desc\"}") - echo "$(echo "$resp" | jq -r '.id')" - fi -} - -ORG_SCOPE_TENANT_MANAGE=$(create_org_scope "tenant:manage" "Manage tenant settings") -ORG_SCOPE_BILLING_MANAGE=$(create_org_scope "billing:manage" "Manage billing") -ORG_SCOPE_TEAM_MANAGE=$(create_org_scope "team:manage" "Manage team members") -ORG_SCOPE_APPS_MANAGE=$(create_org_scope "apps:manage" "Create and delete apps") -ORG_SCOPE_APPS_DEPLOY=$(create_org_scope "apps:deploy" "Deploy apps") -ORG_SCOPE_SECRETS_MANAGE=$(create_org_scope "secrets:manage" "Manage secrets") -ORG_SCOPE_OBSERVE_READ=$(create_org_scope "observe:read" "View observability data") -ORG_SCOPE_OBSERVE_DEBUG=$(create_org_scope "observe:debug" "Debug and replay operations") -ORG_SCOPE_SETTINGS_MANAGE=$(create_org_scope "settings:manage" "Manage settings") - -ALL_ORG_SCOPE_IDS="\"$ORG_SCOPE_TENANT_MANAGE\",\"$ORG_SCOPE_BILLING_MANAGE\",\"$ORG_SCOPE_TEAM_MANAGE\",\"$ORG_SCOPE_APPS_MANAGE\",\"$ORG_SCOPE_APPS_DEPLOY\",\"$ORG_SCOPE_SECRETS_MANAGE\",\"$ORG_SCOPE_OBSERVE_READ\",\"$ORG_SCOPE_OBSERVE_DEBUG\",\"$ORG_SCOPE_SETTINGS_MANAGE\"" -MEMBER_ORG_SCOPE_IDS="\"$ORG_SCOPE_APPS_DEPLOY\",\"$ORG_SCOPE_OBSERVE_READ\",\"$ORG_SCOPE_OBSERVE_DEBUG\"" - -# Assign organization scopes to org roles -log "Assigning organization scopes to roles..." -api_put "/api/organization-roles/${ORG_ADMIN_ROLE_ID}/scopes" "{\"organizationScopeIds\": [$ALL_ORG_SCOPE_IDS]}" >/dev/null 2>&1 -api_put "/api/organization-roles/${ORG_MEMBER_ROLE_ID}/scopes" "{\"organizationScopeIds\": [$MEMBER_ORG_SCOPE_IDS]}" >/dev/null 2>&1 -log "Organization scopes assigned." +# 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 +log "API resource scopes assigned to organization roles." # ============================================================ # PHASE 5: Create users @@ -427,10 +398,10 @@ fi # Add users to organization if [ -n "$TENANT_USER_ID" ] && [ "$TENANT_USER_ID" != "null" ]; then - log "Adding tenant admin to organization..." + 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_ADMIN_ROLE_ID\"]}" >/dev/null 2>&1 - log "Tenant admin added to org with admin role." + 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." fi if [ -n "$ADMIN_USER_ID" ] && [ "$ADMIN_USER_ID" != "null" ]; then diff --git a/docs/architecture.md b/docs/architecture.md index df77c2f..6be26cd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -250,11 +250,25 @@ public class SecurityConfig { to an internal tenant UUID via `TenantService.getByLogtoOrgId()`, and stores it on `TenantContext` (ThreadLocal). -**Authorization pattern** (used in controllers): +**Authorization enforcement** -- Every mutating API endpoint uses Spring +`@PreAuthorize` annotations with `SCOPE_` authorities. Read-only list/get +endpoints require authentication only (no specific scope). The scope-to-endpoint +mapping: + +| Scope | Endpoints | +|------------------|--------------------------------------------------------------------------| +| `platform:admin` | `GET /api/tenants` (list all), `POST /api/tenants` (create tenant) | +| `apps:manage` | Environment create/update/delete, app create/delete | +| `apps:deploy` | JAR upload, routing patch, deploy/stop/restart | +| `billing:manage` | License generation | +| `observe:read` | Log queries, agent status, observability status | +| *(auth only)* | List/get-by-ID endpoints (environments, apps, deployments, licenses) | + +Example: ```java -@PreAuthorize("hasAuthority('SCOPE_platform:admin')") -public ResponseEntity> listAll() { ... } +@PreAuthorize("hasAuthority('SCOPE_apps:manage')") +public ResponseEntity create(...) { ... } ``` ### 3.6 Frontend Auth Architecture @@ -273,11 +287,20 @@ selected, a non-org-scoped token is used. **Organization resolution** (`OrgResolver.tsx`): -1. Calls `GET /api/me` to fetch the user's tenant memberships. -2. Populates the Zustand org store (`useOrgStore`) with org-to-tenant mappings. -3. Auto-selects the first org if the user belongs to exactly one. -4. Decodes the access token JWT to extract scopes and stores them via - `setScopes()`. +`OrgResolver` uses two separate `useEffect` hooks to keep org state and scopes +in sync: + +- **Effect 1: Org population** (depends on `[me]`) -- Calls `GET /api/me` to + fetch tenant memberships, maps them to `OrgInfo` objects in the Zustand org + store, and auto-selects the first org if the user belongs to exactly one. +- **Effect 2: Scope fetching** (depends on `[me, currentOrgId]`) -- Fetches the + API resource identifier from `/api/config`, then obtains both an org-scoped + access token (`getAccessToken(resource, orgId)`) and a global access token + (`getAccessToken(resource)`). Scopes from both tokens are decoded from the JWT + payload and merged into a single `Set` via `setScopes()`. + +The two-effect split ensures scopes are re-fetched whenever the user switches +organizations, preventing stale scope sets from a previously selected org. **Scope-based UI gating:** @@ -542,16 +565,39 @@ registration. Destructive commands include a nonce for replay protection. ## 7. API Overview All endpoints under `/api/` require authentication unless noted otherwise. -Authentication is via Logto JWT Bearer token. +Authentication is via Logto JWT Bearer token. Mutating endpoints additionally +require specific scopes via `@PreAuthorize` (see Section 3.5 for the full +mapping). The Auth column below shows `JWT` for authentication-only endpoints +and the required scope name for scope-gated endpoints. ### 7.1 Platform Configuration | Method | Path | Auth | Description | |--------|-------------------|----------|--------------------------------------------| -| GET | `/api/config` | Public | Frontend config (Logto endpoint, client ID, API resource) | +| GET | `/api/config` | Public | Frontend config (Logto endpoint, client ID, API resource, scopes) | | GET | `/api/health/secured` | JWT | Auth verification endpoint | | GET | `/actuator/health`| Public | Spring Boot health check | +`/api/config` response shape: + +```json +{ + "logtoEndpoint": "http://localhost:3001", + "logtoClientId": "", + "logtoResource": "https://api.cameleer.local", + "scopes": [ + "platform:admin", "tenant:manage", "billing:manage", "team:manage", + "apps:manage", "apps:deploy", "secrets:manage", "observe:read", + "observe:debug", "settings:manage" + ] +} +``` + +The `scopes` array is authoritative -- the frontend reads it during Logto +provider initialization to request the correct API resource scopes during +sign-in. Scopes are defined as a constant list in `PublicConfigController` +rather than being queried from Logto at runtime. + ### 7.2 Identity | Method | Path | Auth | Description | @@ -573,49 +619,49 @@ to enumerate all organizations the user belongs to. ### 7.4 Environments -| Method | Path | Auth | Description | -|--------|----------------------------------------------------|------|--------------------------| -| POST | `/api/tenants/{tenantId}/environments` | JWT | Create environment | -| GET | `/api/tenants/{tenantId}/environments` | JWT | List environments | -| GET | `/api/tenants/{tenantId}/environments/{envId}` | JWT | Get environment | -| PATCH | `/api/tenants/{tenantId}/environments/{envId}` | JWT | Update display name | -| DELETE | `/api/tenants/{tenantId}/environments/{envId}` | JWT | Delete environment | +| Method | Path | Auth | Description | +|--------|----------------------------------------------------|---------------------|--------------------------| +| POST | `/api/tenants/{tenantId}/environments` | `apps:manage` | Create environment | +| GET | `/api/tenants/{tenantId}/environments` | JWT | List environments | +| GET | `/api/tenants/{tenantId}/environments/{envId}` | JWT | Get environment | +| PATCH | `/api/tenants/{tenantId}/environments/{envId}` | `apps:manage` | Update display name | +| DELETE | `/api/tenants/{tenantId}/environments/{envId}` | `apps:manage` | Delete environment | ### 7.5 Apps -| Method | Path | Auth | Description | -|--------|----------------------------------------------------|------|------------------------| -| POST | `/api/environments/{envId}/apps` | JWT | Create app (multipart: metadata + JAR) | -| GET | `/api/environments/{envId}/apps` | JWT | List apps | -| GET | `/api/environments/{envId}/apps/{appId}` | JWT | Get app | -| PUT | `/api/environments/{envId}/apps/{appId}/jar` | JWT | Re-upload JAR | -| DELETE | `/api/environments/{envId}/apps/{appId}` | JWT | Delete app | -| PATCH | `/api/environments/{envId}/apps/{appId}/routing` | JWT | Set exposed port | +| Method | Path | Auth | Description | +|--------|----------------------------------------------------|-----------------|------------------------| +| POST | `/api/environments/{envId}/apps` | `apps:manage` | Create app (multipart: metadata + JAR) | +| GET | `/api/environments/{envId}/apps` | JWT | List apps | +| GET | `/api/environments/{envId}/apps/{appId}` | JWT | Get app | +| PUT | `/api/environments/{envId}/apps/{appId}/jar` | `apps:deploy` | Re-upload JAR | +| DELETE | `/api/environments/{envId}/apps/{appId}` | `apps:manage` | Delete app | +| PATCH | `/api/environments/{envId}/apps/{appId}/routing` | `apps:deploy` | Set exposed port | ### 7.6 Deployments -| Method | Path | Auth | Description | -|--------|----------------------------------------------------|------|--------------------------| -| POST | `/api/apps/{appId}/deploy` | JWT | Deploy app (async, 202) | -| POST | `/api/apps/{appId}/stop` | JWT | Stop running deployment | -| POST | `/api/apps/{appId}/restart` | JWT | Stop + redeploy | -| GET | `/api/apps/{appId}/deployments` | JWT | List deployment history | -| GET | `/api/apps/{appId}/deployments/{deploymentId}` | JWT | Get deployment details | +| Method | Path | Auth | Description | +|--------|----------------------------------------------------|-----------------|--------------------------| +| POST | `/api/apps/{appId}/deploy` | `apps:deploy` | Deploy app (async, 202) | +| POST | `/api/apps/{appId}/stop` | `apps:deploy` | Stop running deployment | +| POST | `/api/apps/{appId}/restart` | `apps:deploy` | Stop + redeploy | +| GET | `/api/apps/{appId}/deployments` | JWT | List deployment history | +| GET | `/api/apps/{appId}/deployments/{deploymentId}` | JWT | Get deployment details | ### 7.7 Observability -| Method | Path | Auth | Description | -|--------|--------------------------------------------------|------|---------------------------| -| GET | `/api/apps/{appId}/agent-status` | JWT | Agent connectivity status | -| GET | `/api/apps/{appId}/observability-status` | JWT | Observability data status | -| GET | `/api/apps/{appId}/logs` | JWT | Container logs (query params: `since`, `until`, `limit`, `stream`) | +| Method | Path | Auth | Description | +|--------|--------------------------------------------------|-----------------|---------------------------| +| GET | `/api/apps/{appId}/agent-status` | `observe:read` | Agent connectivity status | +| GET | `/api/apps/{appId}/observability-status` | `observe:read` | Observability data status | +| GET | `/api/apps/{appId}/logs` | `observe:read` | Container logs (query params: `since`, `until`, `limit`, `stream`) | ### 7.8 Licenses -| Method | Path | Auth | Description | -|--------|-------------------------------------------------|------|--------------------------| -| POST | `/api/tenants/{tenantId}/license` | JWT | Generate license (365d) | -| GET | `/api/tenants/{tenantId}/license` | JWT | Get active license | +| Method | Path | Auth | Description | +|--------|-------------------------------------------------|-------------------|--------------------------| +| POST | `/api/tenants/{tenantId}/license` | `billing:manage` | Generate license (365d) | +| GET | `/api/tenants/{tenantId}/license` | JWT | Get active license | ### 7.9 SPA Routing @@ -633,10 +679,39 @@ public String spa() { return "forward:/index.html"; } ### 8.1 Tenant Isolation -- Each tenant maps to a Logto organization via `logto_org_id`. -- `TenantResolutionFilter` runs after JWT authentication on every request, - extracting `organization_id` from the JWT and storing the resolved tenant UUID - in `TenantContext` (ThreadLocal). +Tenant isolation is enforced through two defense layers that operate in sequence: + +**Layer 1: Path-based validation (`TenantResolutionFilter`)** + +Runs after JWT authentication on every request. First, it resolves the JWT's +`organization_id` claim to an internal tenant UUID via `TenantService` and stores +it on `TenantContext` (ThreadLocal). Then, for `/api/tenants/{uuid}/**` paths, +it compares the path UUID against the resolved tenant ID: + +- If the path segment is a valid UUID and does not match the JWT's resolved + tenant, the filter returns **403 Forbidden** (`"Tenant mismatch"`). +- If no organization context is resolved and the user is not a platform admin, + the filter returns **403 Forbidden** (`"No organization context"`). +- Non-UUID path segments (e.g., `/api/tenants/by-slug/...`) pass through + without validation (these use slug-based lookup, not UUID matching). +- Users with `SCOPE_platform:admin` bypass both checks. + +**Layer 2: Entity-ownership validation (`TenantOwnershipValidator`)** + +A Spring `@Component` injected into `AppController`, `DeploymentController`, +`LogController`, `AgentStatusController`, and `EnvironmentController`. Provides +two methods: + +- `validateEnvironmentAccess(UUID)` -- Loads the environment by ID and confirms + its `tenantId` matches `TenantContext.getTenantId()`. Throws + `AccessDeniedException` on mismatch. +- `validateAppAccess(UUID)` -- Follows the app -> environment -> tenant chain + and confirms tenant ownership. Throws `AccessDeniedException` on mismatch. + +Platform admins (`TenantContext.getTenantId() == null`) bypass both validations. + +**Additional isolation boundaries:** + - Environment and app queries are scoped by tenant through foreign key relationships (`environments.tenant_id`). - Customer app containers run in isolated Docker containers with per-container @@ -733,21 +808,20 @@ Audit entries are immutable (append-only, no UPDATE/DELETE operations). ### 9.3 Auth Data Flow ``` - LogtoProvider + LogtoProvider -- Configured with 10 API resource scopes from /api/config | v - ProtectedRoute -- Gates on isAuthenticated, redirects to /login + ProtectedRoute -- Gates on isAuthenticated, redirects to /login | v - OrgResolver -- Calls GET /api/me - | -- Maps tenants to OrgInfo objects - | -- Stores in useOrgStore.organizations - | -- Decodes JWT to extract scopes - | -- Stores scopes in useOrgStore.scopes + OrgResolver -- Effect 1 [me]: populate org store from /api/me + | -- Effect 2 [me, currentOrgId]: fetch org-scoped + + | -- global access tokens, merge scopes into Set + | -- Re-runs Effect 2 on org switch (stale scope fix) v - Layout + pages -- Read from useOrgStore for tenant context - -- Read from useAuth() for auth state - -- Read scopes for UI gating + Layout + pages -- Read from useOrgStore for tenant context + -- Read from useAuth() for auth state + -- Read scopes for UI gating ``` ### 9.4 State Stores @@ -872,7 +946,9 @@ The bootstrap script writes `/data/logto-bootstrap.json` containing: This file is mounted read-only into cameleer-saas via the `bootstrapdata` volume. `PublicConfigController` reads it to serve SPA client IDs and the API -resource indicator without requiring environment variable configuration. +resource indicator without requiring environment variable configuration. The +controller also includes a `scopes` array (see Section 7.1) so the frontend +can request the correct API resource scopes during Logto sign-in. --- @@ -883,10 +959,11 @@ resource indicator without requiring environment variable configuration. | `docker-compose.yml` | Service topology and configuration | | `docker/logto-bootstrap.sh` | Idempotent Logto + DB bootstrap | | `src/.../config/SecurityConfig.java` | Spring Security filter chain | -| `src/.../config/TenantResolutionFilter.java` | JWT org_id -> tenant resolution | +| `src/.../config/TenantResolutionFilter.java` | JWT org_id -> tenant resolution + path-based tenant validation | +| `src/.../config/TenantOwnershipValidator.java` | Entity-level tenant ownership checks (env, app) | | `src/.../config/TenantContext.java` | ThreadLocal tenant ID holder | | `src/.../config/MeController.java` | User identity + tenant endpoint | -| `src/.../config/PublicConfigController.java` | SPA configuration endpoint | +| `src/.../config/PublicConfigController.java` | SPA configuration endpoint (Logto config + scopes) | | `src/.../tenant/TenantController.java` | Tenant CRUD (platform:admin gated) | | `src/.../environment/EnvironmentController.java` | Environment CRUD | | `src/.../app/AppController.java` | App CRUD + JAR upload | diff --git a/docs/user-manual.md b/docs/user-manual.md index e5e27d1..53257ac 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -390,6 +390,8 @@ All role and permission management happens in Logto, not in the Cameleer SaaS ap There is also a global `platform:admin` scope (separate from organization roles) that grants access to the Platform section for cross-tenant administration. +The full list of 10 scopes is also available programmatically via the `GET /api/config` endpoint, which the frontend uses to discover available scopes at runtime. + --- ## 10. Self-Hosted Setup @@ -477,11 +479,13 @@ On first boot, the `logto-bootstrap` container automatically: - **Cameleer SaaS** (SPA) -- for the management UI frontend. - **Cameleer SaaS Backend** (Machine-to-Machine) -- for server-to-Logto API calls. - **Cameleer Dashboard** (Traditional Web App) -- for cameleer3-server OIDC login. -3. Creates an API resource (`https://api.cameleer.local`) with all platform scopes. -4. Creates organization roles: `admin` (all scopes) and `member` (deploy + observe scopes). +3. Creates an API resource (`https://api.cameleer.local`) with 10 OAuth2 scopes (see Section 9). +4. Creates organization roles with **API resource scopes** (not standalone org permissions): + - `admin` -- 9 tenant scopes (all except `platform:admin`). + - `member` -- 3 scopes: `apps:deploy`, `observe:read`, `observe:debug`. 5. Creates two users: - - Platform admin (default: `admin` / `admin`) -- has the `platform:admin` role. - - Tenant admin (default: `camel` / `camel`) -- added to the default organization as admin. + - Platform admin (default: `admin` / `admin`) -- has the `admin` org role plus the global `platform-admin` role (which grants `platform:admin` scope). + - Demo user (default: `camel` / `camel`) -- added to the default organization with the `member` role. 6. Creates a Logto organization ("Example Tenant") and assigns both users. 7. Configures cameleer3-server with Logto OIDC settings for dashboard authentication. 8. Writes all generated IDs and secrets to `/data/logto-bootstrap.json` for the SaaS backend to consume. diff --git a/src/main/java/net/siegeln/cameleer/saas/app/AppController.java b/src/main/java/net/siegeln/cameleer/saas/app/AppController.java index bbe6264..8ab1fb9 100644 --- a/src/main/java/net/siegeln/cameleer/saas/app/AppController.java +++ b/src/main/java/net/siegeln/cameleer/saas/app/AppController.java @@ -3,11 +3,13 @@ package net.siegeln.cameleer.saas.app; import com.fasterxml.jackson.databind.ObjectMapper; import net.siegeln.cameleer.saas.app.dto.AppResponse; import net.siegeln.cameleer.saas.app.dto.CreateAppRequest; +import net.siegeln.cameleer.saas.config.TenantOwnershipValidator; import net.siegeln.cameleer.saas.environment.EnvironmentService; import net.siegeln.cameleer.saas.runtime.RuntimeConfig; import net.siegeln.cameleer.saas.tenant.TenantRepository; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -34,24 +36,29 @@ public class AppController { private final EnvironmentService environmentService; private final RuntimeConfig runtimeConfig; private final TenantRepository tenantRepository; + private final TenantOwnershipValidator tenantOwnershipValidator; public AppController(AppService appService, ObjectMapper objectMapper, EnvironmentService environmentService, RuntimeConfig runtimeConfig, - TenantRepository tenantRepository) { + TenantRepository tenantRepository, + TenantOwnershipValidator tenantOwnershipValidator) { this.appService = appService; this.objectMapper = objectMapper; this.environmentService = environmentService; this.runtimeConfig = runtimeConfig; this.tenantRepository = tenantRepository; + this.tenantOwnershipValidator = tenantOwnershipValidator; } @PostMapping(consumes = "multipart/form-data") + @PreAuthorize("hasAuthority('SCOPE_apps:manage')") public ResponseEntity create( @PathVariable UUID environmentId, @RequestPart("metadata") String metadataJson, @RequestPart("file") MultipartFile file, Authentication authentication) { + tenantOwnershipValidator.validateEnvironmentAccess(environmentId); try { var request = objectMapper.readValue(metadataJson, CreateAppRequest.class); UUID actorId = resolveActorId(authentication); @@ -72,6 +79,7 @@ public class AppController { @GetMapping public ResponseEntity> list(@PathVariable UUID environmentId) { + tenantOwnershipValidator.validateEnvironmentAccess(environmentId); var apps = appService.listByEnvironmentId(environmentId) .stream() .map(this::toResponse) @@ -83,17 +91,20 @@ public class AppController { public ResponseEntity getById( @PathVariable UUID environmentId, @PathVariable UUID appId) { + tenantOwnershipValidator.validateEnvironmentAccess(environmentId); return appService.getById(appId) .map(entity -> ResponseEntity.ok(toResponse(entity))) .orElse(ResponseEntity.notFound().build()); } @PutMapping(value = "/{appId}/jar", consumes = "multipart/form-data") + @PreAuthorize("hasAuthority('SCOPE_apps:deploy')") public ResponseEntity reuploadJar( @PathVariable UUID environmentId, @PathVariable UUID appId, @RequestPart("file") MultipartFile file, Authentication authentication) { + tenantOwnershipValidator.validateEnvironmentAccess(environmentId); try { UUID actorId = resolveActorId(authentication); var entity = appService.reuploadJar(appId, file, actorId); @@ -104,10 +115,12 @@ public class AppController { } @DeleteMapping("/{appId}") + @PreAuthorize("hasAuthority('SCOPE_apps:manage')") public ResponseEntity delete( @PathVariable UUID environmentId, @PathVariable UUID appId, Authentication authentication) { + tenantOwnershipValidator.validateEnvironmentAccess(environmentId); try { UUID actorId = resolveActorId(authentication); appService.delete(appId, actorId); @@ -118,11 +131,13 @@ public class AppController { } @PatchMapping("/{appId}/routing") + @PreAuthorize("hasAuthority('SCOPE_apps:deploy')") public ResponseEntity updateRouting( @PathVariable UUID environmentId, @PathVariable UUID appId, @RequestBody net.siegeln.cameleer.saas.observability.dto.UpdateRoutingRequest request, Authentication authentication) { + tenantOwnershipValidator.validateEnvironmentAccess(environmentId); try { var actorId = resolveActorId(authentication); var app = appService.updateRouting(appId, request.exposedPort(), actorId); diff --git a/src/main/java/net/siegeln/cameleer/saas/config/PublicConfigController.java b/src/main/java/net/siegeln/cameleer/saas/config/PublicConfigController.java index 7309734..bb83e16 100644 --- a/src/main/java/net/siegeln/cameleer/saas/config/PublicConfigController.java +++ b/src/main/java/net/siegeln/cameleer/saas/config/PublicConfigController.java @@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import java.io.File; +import java.util.List; import java.util.Map; @RestController @@ -25,8 +26,21 @@ public class PublicConfigController { private final ObjectMapper objectMapper = new ObjectMapper(); + private static final List SCOPES = List.of( + "platform:admin", + "tenant:manage", + "billing:manage", + "team:manage", + "apps:manage", + "apps:deploy", + "secrets:manage", + "observe:read", + "observe:debug", + "settings:manage" + ); + @GetMapping("/api/config") - public Map config() { + public Map config() { JsonNode bootstrap = readBootstrapFile(); String clientId = spaClientId; @@ -47,7 +61,8 @@ public class PublicConfigController { return Map.of( "logtoEndpoint", endpoint, "logtoClientId", clientId != null ? clientId : "", - "logtoResource", apiResource + "logtoResource", apiResource, + "scopes", SCOPES ); } diff --git a/src/main/java/net/siegeln/cameleer/saas/config/TenantOwnershipValidator.java b/src/main/java/net/siegeln/cameleer/saas/config/TenantOwnershipValidator.java new file mode 100644 index 0000000..3ae02db --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/config/TenantOwnershipValidator.java @@ -0,0 +1,42 @@ +package net.siegeln.cameleer.saas.config; + +import net.siegeln.cameleer.saas.app.AppRepository; +import net.siegeln.cameleer.saas.environment.EnvironmentRepository; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +@Component +public class TenantOwnershipValidator { + + private final EnvironmentRepository environmentRepository; + private final AppRepository appRepository; + + public TenantOwnershipValidator(EnvironmentRepository environmentRepository, AppRepository appRepository) { + this.environmentRepository = environmentRepository; + this.appRepository = appRepository; + } + + public void validateEnvironmentAccess(UUID environmentId) { + UUID currentTenantId = TenantContext.getTenantId(); + if (currentTenantId == null) return; // platform admin or no org context + environmentRepository.findById(environmentId).ifPresent(env -> { + if (!env.getTenantId().equals(currentTenantId)) { + throw new AccessDeniedException("Environment does not belong to current tenant"); + } + }); + } + + public void validateAppAccess(UUID appId) { + UUID currentTenantId = TenantContext.getTenantId(); + if (currentTenantId == null) return; + appRepository.findById(appId).ifPresent(app -> { + environmentRepository.findById(app.getEnvironmentId()).ifPresent(env -> { + if (!env.getTenantId().equals(currentTenantId)) { + throw new AccessDeniedException("App does not belong to current tenant"); + } + }); + }); + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/config/TenantResolutionFilter.java b/src/main/java/net/siegeln/cameleer/saas/config/TenantResolutionFilter.java index 90f7fb0..720c477 100644 --- a/src/main/java/net/siegeln/cameleer/saas/config/TenantResolutionFilter.java +++ b/src/main/java/net/siegeln/cameleer/saas/config/TenantResolutionFilter.java @@ -12,6 +12,7 @@ import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.util.UUID; @Component public class TenantResolutionFilter extends OncePerRequestFilter { @@ -37,6 +38,30 @@ public class TenantResolutionFilter extends OncePerRequestFilter { tenantService.getByLogtoOrgId(orgId) .ifPresent(tenant -> TenantContext.setTenantId(tenant.getId())); } + + // Path-based tenant validation for /api/tenants/{uuid}/** endpoints + String path = request.getRequestURI(); + if (path.startsWith("/api/tenants/")) { + UUID resolvedTenantId = TenantContext.getTenantId(); + String[] segments = path.split("/"); + if (segments.length >= 4) { + try { + UUID pathTenantId = UUID.fromString(segments[3]); + boolean isPlatformAdmin = jwtAuth.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("SCOPE_platform:admin")); + if (resolvedTenantId == null && !isPlatformAdmin) { + response.sendError(HttpServletResponse.SC_FORBIDDEN, "No organization context"); + return; + } + if (resolvedTenantId != null && !pathTenantId.equals(resolvedTenantId) && !isPlatformAdmin) { + response.sendError(HttpServletResponse.SC_FORBIDDEN, "Tenant mismatch"); + return; + } + } catch (IllegalArgumentException ignored) { + // Non-UUID segment like "by-slug" — allow through + } + } + } } filterChain.doFilter(request, response); diff --git a/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentController.java b/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentController.java index 8ec1518..dc19117 100644 --- a/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentController.java +++ b/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentController.java @@ -1,8 +1,10 @@ package net.siegeln.cameleer.saas.deployment; +import net.siegeln.cameleer.saas.config.TenantOwnershipValidator; import net.siegeln.cameleer.saas.deployment.dto.DeploymentResponse; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -18,15 +20,20 @@ import java.util.UUID; public class DeploymentController { private final DeploymentService deploymentService; + private final TenantOwnershipValidator tenantOwnershipValidator; - public DeploymentController(DeploymentService deploymentService) { + public DeploymentController(DeploymentService deploymentService, + TenantOwnershipValidator tenantOwnershipValidator) { this.deploymentService = deploymentService; + this.tenantOwnershipValidator = tenantOwnershipValidator; } @PostMapping("/deploy") + @PreAuthorize("hasAuthority('SCOPE_apps:deploy')") public ResponseEntity deploy( @PathVariable UUID appId, Authentication authentication) { + tenantOwnershipValidator.validateAppAccess(appId); try { UUID actorId = resolveActorId(authentication); var entity = deploymentService.deploy(appId, actorId); @@ -40,6 +47,7 @@ public class DeploymentController { @GetMapping("/deployments") public ResponseEntity> listDeployments(@PathVariable UUID appId) { + tenantOwnershipValidator.validateAppAccess(appId); var deployments = deploymentService.listByAppId(appId) .stream() .map(this::toResponse) @@ -51,15 +59,18 @@ public class DeploymentController { public ResponseEntity getDeployment( @PathVariable UUID appId, @PathVariable UUID deploymentId) { + tenantOwnershipValidator.validateAppAccess(appId); return deploymentService.getById(deploymentId) .map(entity -> ResponseEntity.ok(toResponse(entity))) .orElse(ResponseEntity.notFound().build()); } @PostMapping("/stop") + @PreAuthorize("hasAuthority('SCOPE_apps:deploy')") public ResponseEntity stop( @PathVariable UUID appId, Authentication authentication) { + tenantOwnershipValidator.validateAppAccess(appId); try { UUID actorId = resolveActorId(authentication); var entity = deploymentService.stop(appId, actorId); @@ -72,9 +83,11 @@ public class DeploymentController { } @PostMapping("/restart") + @PreAuthorize("hasAuthority('SCOPE_apps:deploy')") public ResponseEntity restart( @PathVariable UUID appId, Authentication authentication) { + tenantOwnershipValidator.validateAppAccess(appId); try { UUID actorId = resolveActorId(authentication); var entity = deploymentService.restart(appId, actorId); diff --git a/src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentController.java b/src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentController.java index 058c1f5..ce95cc1 100644 --- a/src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentController.java +++ b/src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentController.java @@ -1,11 +1,13 @@ package net.siegeln.cameleer.saas.environment; import jakarta.validation.Valid; +import net.siegeln.cameleer.saas.config.TenantOwnershipValidator; import net.siegeln.cameleer.saas.environment.dto.CreateEnvironmentRequest; import net.siegeln.cameleer.saas.environment.dto.EnvironmentResponse; import net.siegeln.cameleer.saas.environment.dto.UpdateEnvironmentRequest; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -24,12 +26,16 @@ import java.util.UUID; public class EnvironmentController { private final EnvironmentService environmentService; + private final TenantOwnershipValidator tenantOwnershipValidator; - public EnvironmentController(EnvironmentService environmentService) { + public EnvironmentController(EnvironmentService environmentService, + TenantOwnershipValidator tenantOwnershipValidator) { this.environmentService = environmentService; + this.tenantOwnershipValidator = tenantOwnershipValidator; } @PostMapping + @PreAuthorize("hasAuthority('SCOPE_apps:manage')") public ResponseEntity create( @PathVariable UUID tenantId, @Valid @RequestBody CreateEnvironmentRequest request, @@ -58,17 +64,20 @@ public class EnvironmentController { public ResponseEntity getById( @PathVariable UUID tenantId, @PathVariable UUID environmentId) { + tenantOwnershipValidator.validateEnvironmentAccess(environmentId); return environmentService.getById(environmentId) .map(entity -> ResponseEntity.ok(toResponse(entity))) .orElse(ResponseEntity.notFound().build()); } @PatchMapping("/{environmentId}") + @PreAuthorize("hasAuthority('SCOPE_apps:manage')") public ResponseEntity update( @PathVariable UUID tenantId, @PathVariable UUID environmentId, @Valid @RequestBody UpdateEnvironmentRequest request, Authentication authentication) { + tenantOwnershipValidator.validateEnvironmentAccess(environmentId); try { UUID actorId = resolveActorId(authentication); var entity = environmentService.updateDisplayName(environmentId, request.displayName(), actorId); @@ -79,10 +88,12 @@ public class EnvironmentController { } @DeleteMapping("/{environmentId}") + @PreAuthorize("hasAuthority('SCOPE_apps:manage')") public ResponseEntity delete( @PathVariable UUID tenantId, @PathVariable UUID environmentId, Authentication authentication) { + tenantOwnershipValidator.validateEnvironmentAccess(environmentId); try { UUID actorId = resolveActorId(authentication); environmentService.delete(environmentId, actorId); diff --git a/src/main/java/net/siegeln/cameleer/saas/license/LicenseController.java b/src/main/java/net/siegeln/cameleer/saas/license/LicenseController.java index e414f08..7ba1b71 100644 --- a/src/main/java/net/siegeln/cameleer/saas/license/LicenseController.java +++ b/src/main/java/net/siegeln/cameleer/saas/license/LicenseController.java @@ -4,6 +4,7 @@ import net.siegeln.cameleer.saas.license.dto.LicenseResponse; import net.siegeln.cameleer.saas.tenant.TenantService; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -27,6 +28,7 @@ public class LicenseController { } @PostMapping + @PreAuthorize("hasAuthority('SCOPE_billing:manage')") public ResponseEntity generate(@PathVariable UUID tenantId, Authentication authentication) { var tenant = tenantService.getById(tenantId).orElse(null); diff --git a/src/main/java/net/siegeln/cameleer/saas/log/LogController.java b/src/main/java/net/siegeln/cameleer/saas/log/LogController.java index 14f37b4..ff8fab1 100644 --- a/src/main/java/net/siegeln/cameleer/saas/log/LogController.java +++ b/src/main/java/net/siegeln/cameleer/saas/log/LogController.java @@ -1,8 +1,10 @@ package net.siegeln.cameleer.saas.log; +import net.siegeln.cameleer.saas.config.TenantOwnershipValidator; import net.siegeln.cameleer.saas.log.dto.LogEntry; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -18,18 +20,23 @@ import java.util.UUID; public class LogController { private final ContainerLogService containerLogService; + private final TenantOwnershipValidator tenantOwnershipValidator; - public LogController(ContainerLogService containerLogService) { + public LogController(ContainerLogService containerLogService, + TenantOwnershipValidator tenantOwnershipValidator) { this.containerLogService = containerLogService; + this.tenantOwnershipValidator = tenantOwnershipValidator; } @GetMapping + @PreAuthorize("hasAuthority('SCOPE_observe:read')") public ResponseEntity> query( @PathVariable UUID appId, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant since, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant until, @RequestParam(defaultValue = "500") int limit, @RequestParam(defaultValue = "both") String stream) { + tenantOwnershipValidator.validateAppAccess(appId); List entries = containerLogService.query(appId, since, until, limit, stream); return ResponseEntity.ok(entries); } diff --git a/src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusController.java b/src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusController.java index cb5e732..039b02e 100644 --- a/src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusController.java +++ b/src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusController.java @@ -1,8 +1,10 @@ package net.siegeln.cameleer.saas.observability; +import net.siegeln.cameleer.saas.config.TenantOwnershipValidator; import net.siegeln.cameleer.saas.observability.dto.AgentStatusResponse; import net.siegeln.cameleer.saas.observability.dto.ObservabilityStatusResponse; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import java.util.UUID; @@ -12,13 +14,18 @@ import java.util.UUID; public class AgentStatusController { private final AgentStatusService agentStatusService; + private final TenantOwnershipValidator tenantOwnershipValidator; - public AgentStatusController(AgentStatusService agentStatusService) { + public AgentStatusController(AgentStatusService agentStatusService, + TenantOwnershipValidator tenantOwnershipValidator) { this.agentStatusService = agentStatusService; + this.tenantOwnershipValidator = tenantOwnershipValidator; } @GetMapping("/agent-status") + @PreAuthorize("hasAuthority('SCOPE_observe:read')") public ResponseEntity getAgentStatus(@PathVariable UUID appId) { + tenantOwnershipValidator.validateAppAccess(appId); try { return ResponseEntity.ok(agentStatusService.getAgentStatus(appId)); } catch (IllegalArgumentException e) { @@ -27,7 +34,9 @@ public class AgentStatusController { } @GetMapping("/observability-status") + @PreAuthorize("hasAuthority('SCOPE_observe:read')") public ResponseEntity getObservabilityStatus(@PathVariable UUID appId) { + tenantOwnershipValidator.validateAppAccess(appId); try { return ResponseEntity.ok(agentStatusService.getObservabilityStatus(appId)); } catch (IllegalArgumentException e) { diff --git a/src/test/java/net/siegeln/cameleer/saas/app/AppControllerTest.java b/src/test/java/net/siegeln/cameleer/saas/app/AppControllerTest.java index da9b5df..2b0cf84 100644 --- a/src/test/java/net/siegeln/cameleer/saas/app/AppControllerTest.java +++ b/src/test/java/net/siegeln/cameleer/saas/app/AppControllerTest.java @@ -24,6 +24,8 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.UUID; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -99,7 +101,8 @@ class AppControllerTest { mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps") .file(jar) .file(metadata) - .with(jwt().jwt(j -> j.claim("sub", "test-user")))) + .with(jwt().jwt(j -> j.claim("sub", "test-user")) + .authorities(new SimpleGrantedAuthority("SCOPE_apps:manage")))) .andExpect(status().isCreated()) .andExpect(jsonPath("$.slug").value("order-svc")) .andExpect(jsonPath("$.displayName").value("Order Service")); @@ -117,7 +120,8 @@ class AppControllerTest { mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps") .file(txt) .file(metadata) - .with(jwt().jwt(j -> j.claim("sub", "test-user")))) + .with(jwt().jwt(j -> j.claim("sub", "test-user")) + .authorities(new SimpleGrantedAuthority("SCOPE_apps:manage")))) .andExpect(status().isBadRequest()); } @@ -133,7 +137,8 @@ class AppControllerTest { mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps") .file(jar) .file(metadata) - .with(jwt().jwt(j -> j.claim("sub", "test-user")))) + .with(jwt().jwt(j -> j.claim("sub", "test-user")) + .authorities(new SimpleGrantedAuthority("SCOPE_apps:manage")))) .andExpect(status().isCreated()); mockMvc.perform(get("/api/environments/" + environmentId + "/apps") @@ -154,7 +159,8 @@ class AppControllerTest { var createResult = mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps") .file(jar) .file(metadata) - .with(jwt().jwt(j -> j.claim("sub", "test-user")))) + .with(jwt().jwt(j -> j.claim("sub", "test-user")) + .authorities(new SimpleGrantedAuthority("SCOPE_apps:manage")))) .andExpect(status().isCreated()) .andReturn(); @@ -162,7 +168,8 @@ class AppControllerTest { .get("id").asText(); mockMvc.perform(delete("/api/environments/" + environmentId + "/apps/" + appId) - .with(jwt().jwt(j -> j.claim("sub", "test-user")))) + .with(jwt().jwt(j -> j.claim("sub", "test-user")) + .authorities(new SimpleGrantedAuthority("SCOPE_apps:manage")))) .andExpect(status().isNoContent()); } } diff --git a/src/test/java/net/siegeln/cameleer/saas/environment/EnvironmentControllerTest.java b/src/test/java/net/siegeln/cameleer/saas/environment/EnvironmentControllerTest.java index 9bd6482..21e7960 100644 --- a/src/test/java/net/siegeln/cameleer/saas/environment/EnvironmentControllerTest.java +++ b/src/test/java/net/siegeln/cameleer/saas/environment/EnvironmentControllerTest.java @@ -25,6 +25,8 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.UUID; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -83,7 +85,9 @@ class EnvironmentControllerTest { var request = new CreateEnvironmentRequest("prod", "Production"); mockMvc.perform(post("/api/tenants/" + tenantId + "/environments") - .with(jwt().jwt(j -> j.claim("sub", "test-user"))) + .with(jwt().jwt(j -> j.claim("sub", "test-user")) + .authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"), + new SimpleGrantedAuthority("SCOPE_platform:admin"))) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isCreated()) @@ -97,13 +101,17 @@ class EnvironmentControllerTest { var request = new CreateEnvironmentRequest("staging", "Staging"); mockMvc.perform(post("/api/tenants/" + tenantId + "/environments") - .with(jwt().jwt(j -> j.claim("sub", "test-user"))) + .with(jwt().jwt(j -> j.claim("sub", "test-user")) + .authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"), + new SimpleGrantedAuthority("SCOPE_platform:admin"))) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isCreated()); mockMvc.perform(post("/api/tenants/" + tenantId + "/environments") - .with(jwt().jwt(j -> j.claim("sub", "test-user"))) + .with(jwt().jwt(j -> j.claim("sub", "test-user")) + .authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"), + new SimpleGrantedAuthority("SCOPE_platform:admin"))) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isConflict()); @@ -114,13 +122,16 @@ class EnvironmentControllerTest { var request = new CreateEnvironmentRequest("dev", "Development"); mockMvc.perform(post("/api/tenants/" + tenantId + "/environments") - .with(jwt().jwt(j -> j.claim("sub", "test-user"))) + .with(jwt().jwt(j -> j.claim("sub", "test-user")) + .authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"), + new SimpleGrantedAuthority("SCOPE_platform:admin"))) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isCreated()); mockMvc.perform(get("/api/tenants/" + tenantId + "/environments") - .with(jwt().jwt(j -> j.claim("sub", "test-user")))) + .with(jwt().jwt(j -> j.claim("sub", "test-user")) + .authorities(new SimpleGrantedAuthority("SCOPE_platform:admin")))) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].slug").value("dev")); } @@ -130,7 +141,9 @@ class EnvironmentControllerTest { var createRequest = new CreateEnvironmentRequest("qa", "QA"); var createResult = mockMvc.perform(post("/api/tenants/" + tenantId + "/environments") - .with(jwt().jwt(j -> j.claim("sub", "test-user"))) + .with(jwt().jwt(j -> j.claim("sub", "test-user")) + .authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"), + new SimpleGrantedAuthority("SCOPE_platform:admin"))) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(createRequest))) .andExpect(status().isCreated()) @@ -142,7 +155,9 @@ class EnvironmentControllerTest { var updateRequest = new UpdateEnvironmentRequest("QA Updated"); mockMvc.perform(patch("/api/tenants/" + tenantId + "/environments/" + environmentId) - .with(jwt().jwt(j -> j.claim("sub", "test-user"))) + .with(jwt().jwt(j -> j.claim("sub", "test-user")) + .authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"), + new SimpleGrantedAuthority("SCOPE_platform:admin"))) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(updateRequest))) .andExpect(status().isOk()) @@ -154,7 +169,9 @@ class EnvironmentControllerTest { var request = new CreateEnvironmentRequest("default", "Default"); var createResult = mockMvc.perform(post("/api/tenants/" + tenantId + "/environments") - .with(jwt().jwt(j -> j.claim("sub", "test-user"))) + .with(jwt().jwt(j -> j.claim("sub", "test-user")) + .authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"), + new SimpleGrantedAuthority("SCOPE_platform:admin"))) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isCreated()) @@ -164,7 +181,9 @@ class EnvironmentControllerTest { .get("id").asText(); mockMvc.perform(delete("/api/tenants/" + tenantId + "/environments/" + environmentId) - .with(jwt().jwt(j -> j.claim("sub", "test-user")))) + .with(jwt().jwt(j -> j.claim("sub", "test-user")) + .authorities(new SimpleGrantedAuthority("SCOPE_apps:manage"), + new SimpleGrantedAuthority("SCOPE_platform:admin")))) .andExpect(status().isForbidden()); } diff --git a/src/test/java/net/siegeln/cameleer/saas/license/LicenseControllerTest.java b/src/test/java/net/siegeln/cameleer/saas/license/LicenseControllerTest.java index d7dcdc7..f6f7500 100644 --- a/src/test/java/net/siegeln/cameleer/saas/license/LicenseControllerTest.java +++ b/src/test/java/net/siegeln/cameleer/saas/license/LicenseControllerTest.java @@ -55,7 +55,9 @@ class LicenseControllerTest { String tenantId = createTenantAndGetId(); mockMvc.perform(post("/api/tenants/" + tenantId + "/license") - .with(jwt().jwt(j -> j.claim("sub", "test-user")))) + .with(jwt().jwt(j -> j.claim("sub", "test-user")) + .authorities(new SimpleGrantedAuthority("SCOPE_billing:manage"), + new SimpleGrantedAuthority("SCOPE_platform:admin")))) .andExpect(status().isCreated()) .andExpect(jsonPath("$.token").isNotEmpty()) .andExpect(jsonPath("$.tier").value("MID")) @@ -67,11 +69,14 @@ class LicenseControllerTest { String tenantId = createTenantAndGetId(); mockMvc.perform(post("/api/tenants/" + tenantId + "/license") - .with(jwt().jwt(j -> j.claim("sub", "test-user")))) + .with(jwt().jwt(j -> j.claim("sub", "test-user")) + .authorities(new SimpleGrantedAuthority("SCOPE_billing:manage"), + new SimpleGrantedAuthority("SCOPE_platform:admin")))) .andExpect(status().isCreated()); mockMvc.perform(get("/api/tenants/" + tenantId + "/license") - .with(jwt().jwt(j -> j.claim("sub", "test-user")))) + .with(jwt().jwt(j -> j.claim("sub", "test-user")) + .authorities(new SimpleGrantedAuthority("SCOPE_platform:admin")))) .andExpect(status().isOk()) .andExpect(jsonPath("$.tier").value("MID")); } @@ -81,7 +86,8 @@ class LicenseControllerTest { String tenantId = createTenantAndGetId(); mockMvc.perform(get("/api/tenants/" + tenantId + "/license") - .with(jwt().jwt(j -> j.claim("sub", "test-user")))) + .with(jwt().jwt(j -> j.claim("sub", "test-user")) + .authorities(new SimpleGrantedAuthority("SCOPE_platform:admin")))) .andExpect(status().isNotFound()); } } diff --git a/src/test/java/net/siegeln/cameleer/saas/tenant/TenantControllerTest.java b/src/test/java/net/siegeln/cameleer/saas/tenant/TenantControllerTest.java index 30e6914..de5b589 100644 --- a/src/test/java/net/siegeln/cameleer/saas/tenant/TenantControllerTest.java +++ b/src/test/java/net/siegeln/cameleer/saas/tenant/TenantControllerTest.java @@ -103,7 +103,8 @@ class TenantControllerTest { String id = objectMapper.readTree(createResult.getResponse().getContentAsString()).get("id").asText(); mockMvc.perform(get("/api/tenants/" + id) - .with(jwt().jwt(j -> j.claim("sub", "test-user")))) + .with(jwt().jwt(j -> j.claim("sub", "test-user")) + .authorities(new SimpleGrantedAuthority("SCOPE_platform:admin")))) .andExpect(status().isOk()) .andExpect(jsonPath("$.slug").value(slug)); } diff --git a/ui/src/auth/OrgResolver.tsx b/ui/src/auth/OrgResolver.tsx index 84624f4..7f21b32 100644 --- a/ui/src/auth/OrgResolver.tsx +++ b/ui/src/auth/OrgResolver.tsx @@ -15,6 +15,7 @@ export function OrgResolver({ children }: { children: React.ReactNode }) { const { getAccessToken } = useLogto(); const { setOrganizations, setCurrentOrg, setScopes, currentOrgId } = useOrgStore(); + // Effect 1: Org population — runs when /api/me data loads useEffect(() => { if (!me) return; @@ -31,22 +32,46 @@ export function OrgResolver({ children }: { children: React.ReactNode }) { if (orgEntries.length === 1 && !currentOrgId) { setCurrentOrg(orgEntries[0].id); } + }, [me]); - // Read scopes from the access token JWT payload - fetchConfig().then((config) => { + // Effect 2: Scope fetching — runs when me loads OR when currentOrgId changes + useEffect(() => { + if (!me) return; + + // Read scopes from access tokens: + // - org-scoped resource token → tenant-level scopes (apps:manage, observe:read, etc.) + // - global resource token → platform-level scopes (platform:admin) + fetchConfig().then(async (config) => { if (!config.logtoResource) return; - getAccessToken(config.logtoResource).then((token) => { - if (!token) return; + + const extractScopes = (token: string | undefined): string[] => { + if (!token) return []; try { const payload = JSON.parse(atob(token.split('.')[1])); - const scopeStr = (payload.scope as string) ?? ''; - setScopes(new Set(scopeStr.split(' ').filter(Boolean))); + return ((payload.scope as string) ?? '').split(' ').filter(Boolean); } catch { - setScopes(new Set()); + return []; } - }).catch(() => setScopes(new Set())); + }; + + try { + const [orgToken, globalToken] = await Promise.all([ + currentOrgId + ? getAccessToken(config.logtoResource, currentOrgId).catch(() => undefined) + : Promise.resolve(undefined), + getAccessToken(config.logtoResource).catch(() => undefined), + ]); + + const merged = new Set([ + ...extractScopes(orgToken), + ...extractScopes(globalToken), + ]); + setScopes(merged); + } catch { + setScopes(new Set()); + } }); - }, [me]); + }, [me, currentOrgId]); if (isLoading) { return ( diff --git a/ui/src/config.ts b/ui/src/config.ts index b60e7bf..fc866c2 100644 --- a/ui/src/config.ts +++ b/ui/src/config.ts @@ -2,6 +2,7 @@ interface AppConfig { logtoEndpoint: string; logtoClientId: string; logtoResource: string; + scopes: string[]; } let cached: AppConfig | null = null; @@ -24,6 +25,18 @@ export async function fetchConfig(): Promise { logtoEndpoint: import.meta.env.VITE_LOGTO_ENDPOINT || 'http://localhost:3001', logtoClientId: import.meta.env.VITE_LOGTO_CLIENT_ID || '', logtoResource: import.meta.env.VITE_LOGTO_RESOURCE || '', + scopes: [ + 'platform:admin', + 'tenant:manage', + 'billing:manage', + 'team:manage', + 'apps:manage', + 'apps:deploy', + 'secrets:manage', + 'observe:read', + 'observe:debug', + 'settings:manage', + ], }; return cached; } diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 45f7f2c..2797c4f 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -47,6 +47,7 @@ function App() { logtoEndpoint: string; logtoClientId: string; logtoResource: string; + scopes: string[]; } | null>(null); useEffect(() => { @@ -71,6 +72,9 @@ function App() { 'openid', 'profile', 'email', 'offline_access', UserScope.Organizations, UserScope.OrganizationRoles, + // API resource scopes — served from /api/config, must be requested + // during sign-in for Logto to include them in access tokens. + ...(config.scopes ?? []), ], }} >