feat: auth hardening — scope enforcement, tenant isolation, and docs
Add @PreAuthorize annotations to all API controllers (14 endpoints
across 6 controllers) enforcing OAuth2 scopes: apps:manage, apps:deploy,
billing:manage, observe:read, platform:admin.
Enforce tenant isolation: TenantResolutionFilter now rejects cross-tenant
access on /api/tenants/{id}/* paths. New TenantOwnershipValidator checks
environment/app ownership for paths without tenantId. Platform admins
bypass both layers.
Fix frontend: OrgResolver split into two useEffect hooks so scopes
refresh on org switch. Scopes now served from /api/config (single source
of truth). Bootstrap cleaned — standalone org permissions removed.
Update docs/architecture.md, docs/user-manual.md, and CLAUDE.md to
reflect all auth hardening changes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
- Proxy or federate access to tenant-specific cameleer3-server instances
|
||||||
- Enforce usage quotas and metered billing
|
- 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
|
## Related Conventions
|
||||||
|
|
||||||
- Gitea-hosted: `gitea.siegeln.net/cameleer/`
|
- Gitea-hosted: `gitea.siegeln.net/cameleer/`
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ services:
|
|||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- ./ui/dist:/app/static
|
- ./ui/dist:/app/static
|
||||||
|
- ./target/cameleer-saas-0.1.0-SNAPSHOT.jar:/app/app.jar
|
||||||
environment:
|
environment:
|
||||||
SPRING_PROFILES_ACTIVE: dev
|
SPRING_PROFILES_ACTIVE: dev
|
||||||
SPRING_WEB_RESOURCES_STATIC_LOCATIONS: file:/app/static/,classpath:/static/
|
SPRING_WEB_RESOURCES_STATIC_LOCATIONS: file:/app/static/,classpath:/static/
|
||||||
|
|||||||
@@ -201,12 +201,12 @@ create_scope() {
|
|||||||
local desc="$2"
|
local desc="$2"
|
||||||
local existing_id=$(echo "$EXISTING_SCOPES" | jq -r ".[] | select(.name == \"$name\") | .id")
|
local existing_id=$(echo "$EXISTING_SCOPES" | jq -r ".[] | select(.name == \"$name\") | .id")
|
||||||
if [ -n "$existing_id" ]; then
|
if [ -n "$existing_id" ]; then
|
||||||
log " Scope '$name' exists: $existing_id"
|
log " Scope '$name' exists: $existing_id" >&2
|
||||||
echo "$existing_id"
|
echo "$existing_id"
|
||||||
else
|
else
|
||||||
local resp=$(api_post "/api/resources/${API_RESOURCE_ID}/scopes" "{\"name\": \"$name\", \"description\": \"$desc\"}")
|
local resp=$(api_post "/api/resources/${API_RESOURCE_ID}/scopes" "{\"name\": \"$name\", \"description\": \"$desc\"}")
|
||||||
local new_id=$(echo "$resp" | jq -r '.id')
|
local new_id=$(echo "$resp" | jq -r '.id')
|
||||||
log " Created scope '$name': $new_id"
|
log " Created scope '$name': $new_id" >&2
|
||||||
echo "$new_id"
|
echo "$new_id"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
@@ -328,40 +328,11 @@ if [ -z "$ORG_MEMBER_ROLE_ID" ]; then
|
|||||||
log "Created org member role: $ORG_MEMBER_ROLE_ID"
|
log "Created org member role: $ORG_MEMBER_ROLE_ID"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Organization permissions (scopes) ---
|
# Assign API resource scopes to org roles (these appear in org-scoped resource tokens)
|
||||||
log "Creating organization permissions..."
|
log "Assigning API resource scopes to organization roles..."
|
||||||
EXISTING_ORG_SCOPES=$(api_get "/api/organization-scopes")
|
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
|
||||||
create_org_scope() {
|
log "API resource scopes assigned to organization roles."
|
||||||
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."
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# PHASE 5: Create users
|
# PHASE 5: Create users
|
||||||
@@ -427,10 +398,10 @@ fi
|
|||||||
|
|
||||||
# Add users to organization
|
# Add users to organization
|
||||||
if [ -n "$TENANT_USER_ID" ] && [ "$TENANT_USER_ID" != "null" ]; then
|
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_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
|
api_put "/api/organizations/$ORG_ID/users/$TENANT_USER_ID/roles" "{\"organizationRoleIds\": [\"$ORG_MEMBER_ROLE_ID\"]}" >/dev/null 2>&1
|
||||||
log "Tenant admin added to org with admin role."
|
log "Tenant user added to org with member role."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -n "$ADMIN_USER_ID" ] && [ "$ADMIN_USER_ID" != "null" ]; then
|
if [ -n "$ADMIN_USER_ID" ] && [ "$ADMIN_USER_ID" != "null" ]; then
|
||||||
|
|||||||
@@ -250,11 +250,25 @@ public class SecurityConfig {
|
|||||||
to an internal tenant UUID via `TenantService.getByLogtoOrgId()`, and stores
|
to an internal tenant UUID via `TenantService.getByLogtoOrgId()`, and stores
|
||||||
it on `TenantContext` (ThreadLocal).
|
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
|
```java
|
||||||
@PreAuthorize("hasAuthority('SCOPE_platform:admin')")
|
@PreAuthorize("hasAuthority('SCOPE_apps:manage')")
|
||||||
public ResponseEntity<List<TenantResponse>> listAll() { ... }
|
public ResponseEntity<EnvironmentResponse> create(...) { ... }
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.6 Frontend Auth Architecture
|
### 3.6 Frontend Auth Architecture
|
||||||
@@ -273,11 +287,20 @@ selected, a non-org-scoped token is used.
|
|||||||
|
|
||||||
**Organization resolution** (`OrgResolver.tsx`):
|
**Organization resolution** (`OrgResolver.tsx`):
|
||||||
|
|
||||||
1. Calls `GET /api/me` to fetch the user's tenant memberships.
|
`OrgResolver` uses two separate `useEffect` hooks to keep org state and scopes
|
||||||
2. Populates the Zustand org store (`useOrgStore`) with org-to-tenant mappings.
|
in sync:
|
||||||
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
|
- **Effect 1: Org population** (depends on `[me]`) -- Calls `GET /api/me` to
|
||||||
`setScopes()`.
|
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<string>` 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:**
|
**Scope-based UI gating:**
|
||||||
|
|
||||||
@@ -542,16 +565,39 @@ registration. Destructive commands include a nonce for replay protection.
|
|||||||
## 7. API Overview
|
## 7. API Overview
|
||||||
|
|
||||||
All endpoints under `/api/` require authentication unless noted otherwise.
|
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
|
### 7.1 Platform Configuration
|
||||||
|
|
||||||
| Method | Path | Auth | Description |
|
| 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 | `/api/health/secured` | JWT | Auth verification endpoint |
|
||||||
| GET | `/actuator/health`| Public | Spring Boot health check |
|
| GET | `/actuator/health`| Public | Spring Boot health check |
|
||||||
|
|
||||||
|
`/api/config` response shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"logtoEndpoint": "http://localhost:3001",
|
||||||
|
"logtoClientId": "<from bootstrap or env>",
|
||||||
|
"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
|
### 7.2 Identity
|
||||||
|
|
||||||
| Method | Path | Auth | Description |
|
| Method | Path | Auth | Description |
|
||||||
@@ -573,49 +619,49 @@ to enumerate all organizations the user belongs to.
|
|||||||
|
|
||||||
### 7.4 Environments
|
### 7.4 Environments
|
||||||
|
|
||||||
| Method | Path | Auth | Description |
|
| Method | Path | Auth | Description |
|
||||||
|--------|----------------------------------------------------|------|--------------------------|
|
|--------|----------------------------------------------------|---------------------|--------------------------|
|
||||||
| POST | `/api/tenants/{tenantId}/environments` | JWT | Create environment |
|
| POST | `/api/tenants/{tenantId}/environments` | `apps:manage` | Create environment |
|
||||||
| GET | `/api/tenants/{tenantId}/environments` | JWT | List environments |
|
| GET | `/api/tenants/{tenantId}/environments` | JWT | List environments |
|
||||||
| GET | `/api/tenants/{tenantId}/environments/{envId}` | JWT | Get environment |
|
| GET | `/api/tenants/{tenantId}/environments/{envId}` | JWT | Get environment |
|
||||||
| PATCH | `/api/tenants/{tenantId}/environments/{envId}` | JWT | Update display name |
|
| PATCH | `/api/tenants/{tenantId}/environments/{envId}` | `apps:manage` | Update display name |
|
||||||
| DELETE | `/api/tenants/{tenantId}/environments/{envId}` | JWT | Delete environment |
|
| DELETE | `/api/tenants/{tenantId}/environments/{envId}` | `apps:manage` | Delete environment |
|
||||||
|
|
||||||
### 7.5 Apps
|
### 7.5 Apps
|
||||||
|
|
||||||
| Method | Path | Auth | Description |
|
| Method | Path | Auth | Description |
|
||||||
|--------|----------------------------------------------------|------|------------------------|
|
|--------|----------------------------------------------------|-----------------|------------------------|
|
||||||
| POST | `/api/environments/{envId}/apps` | JWT | Create app (multipart: metadata + JAR) |
|
| 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` | JWT | List apps |
|
||||||
| GET | `/api/environments/{envId}/apps/{appId}` | JWT | Get app |
|
| GET | `/api/environments/{envId}/apps/{appId}` | JWT | Get app |
|
||||||
| PUT | `/api/environments/{envId}/apps/{appId}/jar` | JWT | Re-upload JAR |
|
| PUT | `/api/environments/{envId}/apps/{appId}/jar` | `apps:deploy` | Re-upload JAR |
|
||||||
| DELETE | `/api/environments/{envId}/apps/{appId}` | JWT | Delete app |
|
| DELETE | `/api/environments/{envId}/apps/{appId}` | `apps:manage` | Delete app |
|
||||||
| PATCH | `/api/environments/{envId}/apps/{appId}/routing` | JWT | Set exposed port |
|
| PATCH | `/api/environments/{envId}/apps/{appId}/routing` | `apps:deploy` | Set exposed port |
|
||||||
|
|
||||||
### 7.6 Deployments
|
### 7.6 Deployments
|
||||||
|
|
||||||
| Method | Path | Auth | Description |
|
| Method | Path | Auth | Description |
|
||||||
|--------|----------------------------------------------------|------|--------------------------|
|
|--------|----------------------------------------------------|-----------------|--------------------------|
|
||||||
| POST | `/api/apps/{appId}/deploy` | JWT | Deploy app (async, 202) |
|
| POST | `/api/apps/{appId}/deploy` | `apps:deploy` | Deploy app (async, 202) |
|
||||||
| POST | `/api/apps/{appId}/stop` | JWT | Stop running deployment |
|
| POST | `/api/apps/{appId}/stop` | `apps:deploy` | Stop running deployment |
|
||||||
| POST | `/api/apps/{appId}/restart` | JWT | Stop + redeploy |
|
| POST | `/api/apps/{appId}/restart` | `apps:deploy` | Stop + redeploy |
|
||||||
| GET | `/api/apps/{appId}/deployments` | JWT | List deployment history |
|
| GET | `/api/apps/{appId}/deployments` | JWT | List deployment history |
|
||||||
| GET | `/api/apps/{appId}/deployments/{deploymentId}` | JWT | Get deployment details |
|
| GET | `/api/apps/{appId}/deployments/{deploymentId}` | JWT | Get deployment details |
|
||||||
|
|
||||||
### 7.7 Observability
|
### 7.7 Observability
|
||||||
|
|
||||||
| Method | Path | Auth | Description |
|
| Method | Path | Auth | Description |
|
||||||
|--------|--------------------------------------------------|------|---------------------------|
|
|--------|--------------------------------------------------|-----------------|---------------------------|
|
||||||
| GET | `/api/apps/{appId}/agent-status` | JWT | Agent connectivity status |
|
| GET | `/api/apps/{appId}/agent-status` | `observe:read` | Agent connectivity status |
|
||||||
| GET | `/api/apps/{appId}/observability-status` | JWT | Observability data status |
|
| GET | `/api/apps/{appId}/observability-status` | `observe:read` | Observability data status |
|
||||||
| GET | `/api/apps/{appId}/logs` | JWT | Container logs (query params: `since`, `until`, `limit`, `stream`) |
|
| GET | `/api/apps/{appId}/logs` | `observe:read` | Container logs (query params: `since`, `until`, `limit`, `stream`) |
|
||||||
|
|
||||||
### 7.8 Licenses
|
### 7.8 Licenses
|
||||||
|
|
||||||
| Method | Path | Auth | Description |
|
| Method | Path | Auth | Description |
|
||||||
|--------|-------------------------------------------------|------|--------------------------|
|
|--------|-------------------------------------------------|-------------------|--------------------------|
|
||||||
| POST | `/api/tenants/{tenantId}/license` | JWT | Generate license (365d) |
|
| POST | `/api/tenants/{tenantId}/license` | `billing:manage` | Generate license (365d) |
|
||||||
| GET | `/api/tenants/{tenantId}/license` | JWT | Get active license |
|
| GET | `/api/tenants/{tenantId}/license` | JWT | Get active license |
|
||||||
|
|
||||||
### 7.9 SPA Routing
|
### 7.9 SPA Routing
|
||||||
|
|
||||||
@@ -633,10 +679,39 @@ public String spa() { return "forward:/index.html"; }
|
|||||||
|
|
||||||
### 8.1 Tenant Isolation
|
### 8.1 Tenant Isolation
|
||||||
|
|
||||||
- Each tenant maps to a Logto organization via `logto_org_id`.
|
Tenant isolation is enforced through two defense layers that operate in sequence:
|
||||||
- `TenantResolutionFilter` runs after JWT authentication on every request,
|
|
||||||
extracting `organization_id` from the JWT and storing the resolved tenant UUID
|
**Layer 1: Path-based validation (`TenantResolutionFilter`)**
|
||||||
in `TenantContext` (ThreadLocal).
|
|
||||||
|
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
|
- Environment and app queries are scoped by tenant through foreign key
|
||||||
relationships (`environments.tenant_id`).
|
relationships (`environments.tenant_id`).
|
||||||
- Customer app containers run in isolated Docker containers with per-container
|
- 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
|
### 9.3 Auth Data Flow
|
||||||
|
|
||||||
```
|
```
|
||||||
LogtoProvider
|
LogtoProvider -- Configured with 10 API resource scopes from /api/config
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
ProtectedRoute -- Gates on isAuthenticated, redirects to /login
|
ProtectedRoute -- Gates on isAuthenticated, redirects to /login
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
OrgResolver -- Calls GET /api/me
|
OrgResolver -- Effect 1 [me]: populate org store from /api/me
|
||||||
| -- Maps tenants to OrgInfo objects
|
| -- Effect 2 [me, currentOrgId]: fetch org-scoped +
|
||||||
| -- Stores in useOrgStore.organizations
|
| -- global access tokens, merge scopes into Set
|
||||||
| -- Decodes JWT to extract scopes
|
| -- Re-runs Effect 2 on org switch (stale scope fix)
|
||||||
| -- Stores scopes in useOrgStore.scopes
|
|
||||||
v
|
v
|
||||||
Layout + pages -- Read from useOrgStore for tenant context
|
Layout + pages -- Read from useOrgStore for tenant context
|
||||||
-- Read from useAuth() for auth state
|
-- Read from useAuth() for auth state
|
||||||
-- Read scopes for UI gating
|
-- Read scopes for UI gating
|
||||||
```
|
```
|
||||||
|
|
||||||
### 9.4 State Stores
|
### 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`
|
This file is mounted read-only into cameleer-saas via the `bootstrapdata`
|
||||||
volume. `PublicConfigController` reads it to serve SPA client IDs and the API
|
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-compose.yml` | Service topology and configuration |
|
||||||
| `docker/logto-bootstrap.sh` | Idempotent Logto + DB bootstrap |
|
| `docker/logto-bootstrap.sh` | Idempotent Logto + DB bootstrap |
|
||||||
| `src/.../config/SecurityConfig.java` | Spring Security filter chain |
|
| `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/TenantContext.java` | ThreadLocal tenant ID holder |
|
||||||
| `src/.../config/MeController.java` | User identity + tenant endpoint |
|
| `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/.../tenant/TenantController.java` | Tenant CRUD (platform:admin gated) |
|
||||||
| `src/.../environment/EnvironmentController.java` | Environment CRUD |
|
| `src/.../environment/EnvironmentController.java` | Environment CRUD |
|
||||||
| `src/.../app/AppController.java` | App CRUD + JAR upload |
|
| `src/.../app/AppController.java` | App CRUD + JAR upload |
|
||||||
|
|||||||
@@ -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.
|
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
|
## 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** (SPA) -- for the management UI frontend.
|
||||||
- **Cameleer SaaS Backend** (Machine-to-Machine) -- for server-to-Logto API calls.
|
- **Cameleer SaaS Backend** (Machine-to-Machine) -- for server-to-Logto API calls.
|
||||||
- **Cameleer Dashboard** (Traditional Web App) -- for cameleer3-server OIDC login.
|
- **Cameleer Dashboard** (Traditional Web App) -- for cameleer3-server OIDC login.
|
||||||
3. Creates an API resource (`https://api.cameleer.local`) with all platform scopes.
|
3. Creates an API resource (`https://api.cameleer.local`) with 10 OAuth2 scopes (see Section 9).
|
||||||
4. Creates organization roles: `admin` (all scopes) and `member` (deploy + observe scopes).
|
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:
|
5. Creates two users:
|
||||||
- Platform admin (default: `admin` / `admin`) -- has the `platform:admin` role.
|
- Platform admin (default: `admin` / `admin`) -- has the `admin` org role plus the global `platform-admin` role (which grants `platform:admin` scope).
|
||||||
- Tenant admin (default: `camel` / `camel`) -- added to the default organization as admin.
|
- 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.
|
6. Creates a Logto organization ("Example Tenant") and assigns both users.
|
||||||
7. Configures cameleer3-server with Logto OIDC settings for dashboard authentication.
|
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.
|
8. Writes all generated IDs and secrets to `/data/logto-bootstrap.json` for the SaaS backend to consume.
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ package net.siegeln.cameleer.saas.app;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import net.siegeln.cameleer.saas.app.dto.AppResponse;
|
import net.siegeln.cameleer.saas.app.dto.AppResponse;
|
||||||
import net.siegeln.cameleer.saas.app.dto.CreateAppRequest;
|
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.environment.EnvironmentService;
|
||||||
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
|
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
|
||||||
import net.siegeln.cameleer.saas.tenant.TenantRepository;
|
import net.siegeln.cameleer.saas.tenant.TenantRepository;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@@ -34,24 +36,29 @@ public class AppController {
|
|||||||
private final EnvironmentService environmentService;
|
private final EnvironmentService environmentService;
|
||||||
private final RuntimeConfig runtimeConfig;
|
private final RuntimeConfig runtimeConfig;
|
||||||
private final TenantRepository tenantRepository;
|
private final TenantRepository tenantRepository;
|
||||||
|
private final TenantOwnershipValidator tenantOwnershipValidator;
|
||||||
|
|
||||||
public AppController(AppService appService, ObjectMapper objectMapper,
|
public AppController(AppService appService, ObjectMapper objectMapper,
|
||||||
EnvironmentService environmentService,
|
EnvironmentService environmentService,
|
||||||
RuntimeConfig runtimeConfig,
|
RuntimeConfig runtimeConfig,
|
||||||
TenantRepository tenantRepository) {
|
TenantRepository tenantRepository,
|
||||||
|
TenantOwnershipValidator tenantOwnershipValidator) {
|
||||||
this.appService = appService;
|
this.appService = appService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.environmentService = environmentService;
|
this.environmentService = environmentService;
|
||||||
this.runtimeConfig = runtimeConfig;
|
this.runtimeConfig = runtimeConfig;
|
||||||
this.tenantRepository = tenantRepository;
|
this.tenantRepository = tenantRepository;
|
||||||
|
this.tenantOwnershipValidator = tenantOwnershipValidator;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(consumes = "multipart/form-data")
|
@PostMapping(consumes = "multipart/form-data")
|
||||||
|
@PreAuthorize("hasAuthority('SCOPE_apps:manage')")
|
||||||
public ResponseEntity<AppResponse> create(
|
public ResponseEntity<AppResponse> create(
|
||||||
@PathVariable UUID environmentId,
|
@PathVariable UUID environmentId,
|
||||||
@RequestPart("metadata") String metadataJson,
|
@RequestPart("metadata") String metadataJson,
|
||||||
@RequestPart("file") MultipartFile file,
|
@RequestPart("file") MultipartFile file,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
|
tenantOwnershipValidator.validateEnvironmentAccess(environmentId);
|
||||||
try {
|
try {
|
||||||
var request = objectMapper.readValue(metadataJson, CreateAppRequest.class);
|
var request = objectMapper.readValue(metadataJson, CreateAppRequest.class);
|
||||||
UUID actorId = resolveActorId(authentication);
|
UUID actorId = resolveActorId(authentication);
|
||||||
@@ -72,6 +79,7 @@ public class AppController {
|
|||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ResponseEntity<List<AppResponse>> list(@PathVariable UUID environmentId) {
|
public ResponseEntity<List<AppResponse>> list(@PathVariable UUID environmentId) {
|
||||||
|
tenantOwnershipValidator.validateEnvironmentAccess(environmentId);
|
||||||
var apps = appService.listByEnvironmentId(environmentId)
|
var apps = appService.listByEnvironmentId(environmentId)
|
||||||
.stream()
|
.stream()
|
||||||
.map(this::toResponse)
|
.map(this::toResponse)
|
||||||
@@ -83,17 +91,20 @@ public class AppController {
|
|||||||
public ResponseEntity<AppResponse> getById(
|
public ResponseEntity<AppResponse> getById(
|
||||||
@PathVariable UUID environmentId,
|
@PathVariable UUID environmentId,
|
||||||
@PathVariable UUID appId) {
|
@PathVariable UUID appId) {
|
||||||
|
tenantOwnershipValidator.validateEnvironmentAccess(environmentId);
|
||||||
return appService.getById(appId)
|
return appService.getById(appId)
|
||||||
.map(entity -> ResponseEntity.ok(toResponse(entity)))
|
.map(entity -> ResponseEntity.ok(toResponse(entity)))
|
||||||
.orElse(ResponseEntity.notFound().build());
|
.orElse(ResponseEntity.notFound().build());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping(value = "/{appId}/jar", consumes = "multipart/form-data")
|
@PutMapping(value = "/{appId}/jar", consumes = "multipart/form-data")
|
||||||
|
@PreAuthorize("hasAuthority('SCOPE_apps:deploy')")
|
||||||
public ResponseEntity<AppResponse> reuploadJar(
|
public ResponseEntity<AppResponse> reuploadJar(
|
||||||
@PathVariable UUID environmentId,
|
@PathVariable UUID environmentId,
|
||||||
@PathVariable UUID appId,
|
@PathVariable UUID appId,
|
||||||
@RequestPart("file") MultipartFile file,
|
@RequestPart("file") MultipartFile file,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
|
tenantOwnershipValidator.validateEnvironmentAccess(environmentId);
|
||||||
try {
|
try {
|
||||||
UUID actorId = resolveActorId(authentication);
|
UUID actorId = resolveActorId(authentication);
|
||||||
var entity = appService.reuploadJar(appId, file, actorId);
|
var entity = appService.reuploadJar(appId, file, actorId);
|
||||||
@@ -104,10 +115,12 @@ public class AppController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{appId}")
|
@DeleteMapping("/{appId}")
|
||||||
|
@PreAuthorize("hasAuthority('SCOPE_apps:manage')")
|
||||||
public ResponseEntity<Void> delete(
|
public ResponseEntity<Void> delete(
|
||||||
@PathVariable UUID environmentId,
|
@PathVariable UUID environmentId,
|
||||||
@PathVariable UUID appId,
|
@PathVariable UUID appId,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
|
tenantOwnershipValidator.validateEnvironmentAccess(environmentId);
|
||||||
try {
|
try {
|
||||||
UUID actorId = resolveActorId(authentication);
|
UUID actorId = resolveActorId(authentication);
|
||||||
appService.delete(appId, actorId);
|
appService.delete(appId, actorId);
|
||||||
@@ -118,11 +131,13 @@ public class AppController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PatchMapping("/{appId}/routing")
|
@PatchMapping("/{appId}/routing")
|
||||||
|
@PreAuthorize("hasAuthority('SCOPE_apps:deploy')")
|
||||||
public ResponseEntity<AppResponse> updateRouting(
|
public ResponseEntity<AppResponse> updateRouting(
|
||||||
@PathVariable UUID environmentId,
|
@PathVariable UUID environmentId,
|
||||||
@PathVariable UUID appId,
|
@PathVariable UUID appId,
|
||||||
@RequestBody net.siegeln.cameleer.saas.observability.dto.UpdateRoutingRequest request,
|
@RequestBody net.siegeln.cameleer.saas.observability.dto.UpdateRoutingRequest request,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
|
tenantOwnershipValidator.validateEnvironmentAccess(environmentId);
|
||||||
try {
|
try {
|
||||||
var actorId = resolveActorId(authentication);
|
var actorId = resolveActorId(authentication);
|
||||||
var app = appService.updateRouting(appId, request.exposedPort(), actorId);
|
var app = appService.updateRouting(appId, request.exposedPort(), actorId);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.GetMapping;
|
|||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -25,8 +26,21 @@ public class PublicConfigController {
|
|||||||
|
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
private static final List<String> 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")
|
@GetMapping("/api/config")
|
||||||
public Map<String, String> config() {
|
public Map<String, Object> config() {
|
||||||
JsonNode bootstrap = readBootstrapFile();
|
JsonNode bootstrap = readBootstrapFile();
|
||||||
|
|
||||||
String clientId = spaClientId;
|
String clientId = spaClientId;
|
||||||
@@ -47,7 +61,8 @@ public class PublicConfigController {
|
|||||||
return Map.of(
|
return Map.of(
|
||||||
"logtoEndpoint", endpoint,
|
"logtoEndpoint", endpoint,
|
||||||
"logtoClientId", clientId != null ? clientId : "",
|
"logtoClientId", clientId != null ? clientId : "",
|
||||||
"logtoResource", apiResource
|
"logtoResource", apiResource,
|
||||||
|
"scopes", SCOPES
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import org.springframework.stereotype.Component;
|
|||||||
import org.springframework.web.filter.OncePerRequestFilter;
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class TenantResolutionFilter extends OncePerRequestFilter {
|
public class TenantResolutionFilter extends OncePerRequestFilter {
|
||||||
@@ -37,6 +38,30 @@ public class TenantResolutionFilter extends OncePerRequestFilter {
|
|||||||
tenantService.getByLogtoOrgId(orgId)
|
tenantService.getByLogtoOrgId(orgId)
|
||||||
.ifPresent(tenant -> TenantContext.setTenantId(tenant.getId()));
|
.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);
|
filterChain.doFilter(request, response);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package net.siegeln.cameleer.saas.deployment;
|
package net.siegeln.cameleer.saas.deployment;
|
||||||
|
|
||||||
|
import net.siegeln.cameleer.saas.config.TenantOwnershipValidator;
|
||||||
import net.siegeln.cameleer.saas.deployment.dto.DeploymentResponse;
|
import net.siegeln.cameleer.saas.deployment.dto.DeploymentResponse;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
@@ -18,15 +20,20 @@ import java.util.UUID;
|
|||||||
public class DeploymentController {
|
public class DeploymentController {
|
||||||
|
|
||||||
private final DeploymentService deploymentService;
|
private final DeploymentService deploymentService;
|
||||||
|
private final TenantOwnershipValidator tenantOwnershipValidator;
|
||||||
|
|
||||||
public DeploymentController(DeploymentService deploymentService) {
|
public DeploymentController(DeploymentService deploymentService,
|
||||||
|
TenantOwnershipValidator tenantOwnershipValidator) {
|
||||||
this.deploymentService = deploymentService;
|
this.deploymentService = deploymentService;
|
||||||
|
this.tenantOwnershipValidator = tenantOwnershipValidator;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/deploy")
|
@PostMapping("/deploy")
|
||||||
|
@PreAuthorize("hasAuthority('SCOPE_apps:deploy')")
|
||||||
public ResponseEntity<DeploymentResponse> deploy(
|
public ResponseEntity<DeploymentResponse> deploy(
|
||||||
@PathVariable UUID appId,
|
@PathVariable UUID appId,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
|
tenantOwnershipValidator.validateAppAccess(appId);
|
||||||
try {
|
try {
|
||||||
UUID actorId = resolveActorId(authentication);
|
UUID actorId = resolveActorId(authentication);
|
||||||
var entity = deploymentService.deploy(appId, actorId);
|
var entity = deploymentService.deploy(appId, actorId);
|
||||||
@@ -40,6 +47,7 @@ public class DeploymentController {
|
|||||||
|
|
||||||
@GetMapping("/deployments")
|
@GetMapping("/deployments")
|
||||||
public ResponseEntity<List<DeploymentResponse>> listDeployments(@PathVariable UUID appId) {
|
public ResponseEntity<List<DeploymentResponse>> listDeployments(@PathVariable UUID appId) {
|
||||||
|
tenantOwnershipValidator.validateAppAccess(appId);
|
||||||
var deployments = deploymentService.listByAppId(appId)
|
var deployments = deploymentService.listByAppId(appId)
|
||||||
.stream()
|
.stream()
|
||||||
.map(this::toResponse)
|
.map(this::toResponse)
|
||||||
@@ -51,15 +59,18 @@ public class DeploymentController {
|
|||||||
public ResponseEntity<DeploymentResponse> getDeployment(
|
public ResponseEntity<DeploymentResponse> getDeployment(
|
||||||
@PathVariable UUID appId,
|
@PathVariable UUID appId,
|
||||||
@PathVariable UUID deploymentId) {
|
@PathVariable UUID deploymentId) {
|
||||||
|
tenantOwnershipValidator.validateAppAccess(appId);
|
||||||
return deploymentService.getById(deploymentId)
|
return deploymentService.getById(deploymentId)
|
||||||
.map(entity -> ResponseEntity.ok(toResponse(entity)))
|
.map(entity -> ResponseEntity.ok(toResponse(entity)))
|
||||||
.orElse(ResponseEntity.notFound().build());
|
.orElse(ResponseEntity.notFound().build());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/stop")
|
@PostMapping("/stop")
|
||||||
|
@PreAuthorize("hasAuthority('SCOPE_apps:deploy')")
|
||||||
public ResponseEntity<DeploymentResponse> stop(
|
public ResponseEntity<DeploymentResponse> stop(
|
||||||
@PathVariable UUID appId,
|
@PathVariable UUID appId,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
|
tenantOwnershipValidator.validateAppAccess(appId);
|
||||||
try {
|
try {
|
||||||
UUID actorId = resolveActorId(authentication);
|
UUID actorId = resolveActorId(authentication);
|
||||||
var entity = deploymentService.stop(appId, actorId);
|
var entity = deploymentService.stop(appId, actorId);
|
||||||
@@ -72,9 +83,11 @@ public class DeploymentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/restart")
|
@PostMapping("/restart")
|
||||||
|
@PreAuthorize("hasAuthority('SCOPE_apps:deploy')")
|
||||||
public ResponseEntity<DeploymentResponse> restart(
|
public ResponseEntity<DeploymentResponse> restart(
|
||||||
@PathVariable UUID appId,
|
@PathVariable UUID appId,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
|
tenantOwnershipValidator.validateAppAccess(appId);
|
||||||
try {
|
try {
|
||||||
UUID actorId = resolveActorId(authentication);
|
UUID actorId = resolveActorId(authentication);
|
||||||
var entity = deploymentService.restart(appId, actorId);
|
var entity = deploymentService.restart(appId, actorId);
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package net.siegeln.cameleer.saas.environment;
|
package net.siegeln.cameleer.saas.environment;
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
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.CreateEnvironmentRequest;
|
||||||
import net.siegeln.cameleer.saas.environment.dto.EnvironmentResponse;
|
import net.siegeln.cameleer.saas.environment.dto.EnvironmentResponse;
|
||||||
import net.siegeln.cameleer.saas.environment.dto.UpdateEnvironmentRequest;
|
import net.siegeln.cameleer.saas.environment.dto.UpdateEnvironmentRequest;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@@ -24,12 +26,16 @@ import java.util.UUID;
|
|||||||
public class EnvironmentController {
|
public class EnvironmentController {
|
||||||
|
|
||||||
private final EnvironmentService environmentService;
|
private final EnvironmentService environmentService;
|
||||||
|
private final TenantOwnershipValidator tenantOwnershipValidator;
|
||||||
|
|
||||||
public EnvironmentController(EnvironmentService environmentService) {
|
public EnvironmentController(EnvironmentService environmentService,
|
||||||
|
TenantOwnershipValidator tenantOwnershipValidator) {
|
||||||
this.environmentService = environmentService;
|
this.environmentService = environmentService;
|
||||||
|
this.tenantOwnershipValidator = tenantOwnershipValidator;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
|
@PreAuthorize("hasAuthority('SCOPE_apps:manage')")
|
||||||
public ResponseEntity<EnvironmentResponse> create(
|
public ResponseEntity<EnvironmentResponse> create(
|
||||||
@PathVariable UUID tenantId,
|
@PathVariable UUID tenantId,
|
||||||
@Valid @RequestBody CreateEnvironmentRequest request,
|
@Valid @RequestBody CreateEnvironmentRequest request,
|
||||||
@@ -58,17 +64,20 @@ public class EnvironmentController {
|
|||||||
public ResponseEntity<EnvironmentResponse> getById(
|
public ResponseEntity<EnvironmentResponse> getById(
|
||||||
@PathVariable UUID tenantId,
|
@PathVariable UUID tenantId,
|
||||||
@PathVariable UUID environmentId) {
|
@PathVariable UUID environmentId) {
|
||||||
|
tenantOwnershipValidator.validateEnvironmentAccess(environmentId);
|
||||||
return environmentService.getById(environmentId)
|
return environmentService.getById(environmentId)
|
||||||
.map(entity -> ResponseEntity.ok(toResponse(entity)))
|
.map(entity -> ResponseEntity.ok(toResponse(entity)))
|
||||||
.orElse(ResponseEntity.notFound().build());
|
.orElse(ResponseEntity.notFound().build());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PatchMapping("/{environmentId}")
|
@PatchMapping("/{environmentId}")
|
||||||
|
@PreAuthorize("hasAuthority('SCOPE_apps:manage')")
|
||||||
public ResponseEntity<EnvironmentResponse> update(
|
public ResponseEntity<EnvironmentResponse> update(
|
||||||
@PathVariable UUID tenantId,
|
@PathVariable UUID tenantId,
|
||||||
@PathVariable UUID environmentId,
|
@PathVariable UUID environmentId,
|
||||||
@Valid @RequestBody UpdateEnvironmentRequest request,
|
@Valid @RequestBody UpdateEnvironmentRequest request,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
|
tenantOwnershipValidator.validateEnvironmentAccess(environmentId);
|
||||||
try {
|
try {
|
||||||
UUID actorId = resolveActorId(authentication);
|
UUID actorId = resolveActorId(authentication);
|
||||||
var entity = environmentService.updateDisplayName(environmentId, request.displayName(), actorId);
|
var entity = environmentService.updateDisplayName(environmentId, request.displayName(), actorId);
|
||||||
@@ -79,10 +88,12 @@ public class EnvironmentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{environmentId}")
|
@DeleteMapping("/{environmentId}")
|
||||||
|
@PreAuthorize("hasAuthority('SCOPE_apps:manage')")
|
||||||
public ResponseEntity<Void> delete(
|
public ResponseEntity<Void> delete(
|
||||||
@PathVariable UUID tenantId,
|
@PathVariable UUID tenantId,
|
||||||
@PathVariable UUID environmentId,
|
@PathVariable UUID environmentId,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
|
tenantOwnershipValidator.validateEnvironmentAccess(environmentId);
|
||||||
try {
|
try {
|
||||||
UUID actorId = resolveActorId(authentication);
|
UUID actorId = resolveActorId(authentication);
|
||||||
environmentService.delete(environmentId, actorId);
|
environmentService.delete(environmentId, actorId);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import net.siegeln.cameleer.saas.license.dto.LicenseResponse;
|
|||||||
import net.siegeln.cameleer.saas.tenant.TenantService;
|
import net.siegeln.cameleer.saas.tenant.TenantService;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
@@ -27,6 +28,7 @@ public class LicenseController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
|
@PreAuthorize("hasAuthority('SCOPE_billing:manage')")
|
||||||
public ResponseEntity<LicenseResponse> generate(@PathVariable UUID tenantId,
|
public ResponseEntity<LicenseResponse> generate(@PathVariable UUID tenantId,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
var tenant = tenantService.getById(tenantId).orElse(null);
|
var tenant = tenantService.getById(tenantId).orElse(null);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package net.siegeln.cameleer.saas.log;
|
package net.siegeln.cameleer.saas.log;
|
||||||
|
|
||||||
|
import net.siegeln.cameleer.saas.config.TenantOwnershipValidator;
|
||||||
import net.siegeln.cameleer.saas.log.dto.LogEntry;
|
import net.siegeln.cameleer.saas.log.dto.LogEntry;
|
||||||
import org.springframework.format.annotation.DateTimeFormat;
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
import org.springframework.http.ResponseEntity;
|
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.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
@@ -18,18 +20,23 @@ import java.util.UUID;
|
|||||||
public class LogController {
|
public class LogController {
|
||||||
|
|
||||||
private final ContainerLogService containerLogService;
|
private final ContainerLogService containerLogService;
|
||||||
|
private final TenantOwnershipValidator tenantOwnershipValidator;
|
||||||
|
|
||||||
public LogController(ContainerLogService containerLogService) {
|
public LogController(ContainerLogService containerLogService,
|
||||||
|
TenantOwnershipValidator tenantOwnershipValidator) {
|
||||||
this.containerLogService = containerLogService;
|
this.containerLogService = containerLogService;
|
||||||
|
this.tenantOwnershipValidator = tenantOwnershipValidator;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
|
@PreAuthorize("hasAuthority('SCOPE_observe:read')")
|
||||||
public ResponseEntity<List<LogEntry>> query(
|
public ResponseEntity<List<LogEntry>> query(
|
||||||
@PathVariable UUID appId,
|
@PathVariable UUID appId,
|
||||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant since,
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant since,
|
||||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant until,
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant until,
|
||||||
@RequestParam(defaultValue = "500") int limit,
|
@RequestParam(defaultValue = "500") int limit,
|
||||||
@RequestParam(defaultValue = "both") String stream) {
|
@RequestParam(defaultValue = "both") String stream) {
|
||||||
|
tenantOwnershipValidator.validateAppAccess(appId);
|
||||||
List<LogEntry> entries = containerLogService.query(appId, since, until, limit, stream);
|
List<LogEntry> entries = containerLogService.query(appId, since, until, limit, stream);
|
||||||
return ResponseEntity.ok(entries);
|
return ResponseEntity.ok(entries);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package net.siegeln.cameleer.saas.observability;
|
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.AgentStatusResponse;
|
||||||
import net.siegeln.cameleer.saas.observability.dto.ObservabilityStatusResponse;
|
import net.siegeln.cameleer.saas.observability.dto.ObservabilityStatusResponse;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -12,13 +14,18 @@ import java.util.UUID;
|
|||||||
public class AgentStatusController {
|
public class AgentStatusController {
|
||||||
|
|
||||||
private final AgentStatusService agentStatusService;
|
private final AgentStatusService agentStatusService;
|
||||||
|
private final TenantOwnershipValidator tenantOwnershipValidator;
|
||||||
|
|
||||||
public AgentStatusController(AgentStatusService agentStatusService) {
|
public AgentStatusController(AgentStatusService agentStatusService,
|
||||||
|
TenantOwnershipValidator tenantOwnershipValidator) {
|
||||||
this.agentStatusService = agentStatusService;
|
this.agentStatusService = agentStatusService;
|
||||||
|
this.tenantOwnershipValidator = tenantOwnershipValidator;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/agent-status")
|
@GetMapping("/agent-status")
|
||||||
|
@PreAuthorize("hasAuthority('SCOPE_observe:read')")
|
||||||
public ResponseEntity<AgentStatusResponse> getAgentStatus(@PathVariable UUID appId) {
|
public ResponseEntity<AgentStatusResponse> getAgentStatus(@PathVariable UUID appId) {
|
||||||
|
tenantOwnershipValidator.validateAppAccess(appId);
|
||||||
try {
|
try {
|
||||||
return ResponseEntity.ok(agentStatusService.getAgentStatus(appId));
|
return ResponseEntity.ok(agentStatusService.getAgentStatus(appId));
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
@@ -27,7 +34,9 @@ public class AgentStatusController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/observability-status")
|
@GetMapping("/observability-status")
|
||||||
|
@PreAuthorize("hasAuthority('SCOPE_observe:read')")
|
||||||
public ResponseEntity<ObservabilityStatusResponse> getObservabilityStatus(@PathVariable UUID appId) {
|
public ResponseEntity<ObservabilityStatusResponse> getObservabilityStatus(@PathVariable UUID appId) {
|
||||||
|
tenantOwnershipValidator.validateAppAccess(appId);
|
||||||
try {
|
try {
|
||||||
return ResponseEntity.ok(agentStatusService.getObservabilityStatus(appId));
|
return ResponseEntity.ok(agentStatusService.getObservabilityStatus(appId));
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import java.time.Instant;
|
|||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.UUID;
|
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.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.delete;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
@@ -99,7 +101,8 @@ class AppControllerTest {
|
|||||||
mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps")
|
mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps")
|
||||||
.file(jar)
|
.file(jar)
|
||||||
.file(metadata)
|
.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(status().isCreated())
|
||||||
.andExpect(jsonPath("$.slug").value("order-svc"))
|
.andExpect(jsonPath("$.slug").value("order-svc"))
|
||||||
.andExpect(jsonPath("$.displayName").value("Order Service"));
|
.andExpect(jsonPath("$.displayName").value("Order Service"));
|
||||||
@@ -117,7 +120,8 @@ class AppControllerTest {
|
|||||||
mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps")
|
mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps")
|
||||||
.file(txt)
|
.file(txt)
|
||||||
.file(metadata)
|
.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());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +137,8 @@ class AppControllerTest {
|
|||||||
mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps")
|
mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps")
|
||||||
.file(jar)
|
.file(jar)
|
||||||
.file(metadata)
|
.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(status().isCreated());
|
||||||
|
|
||||||
mockMvc.perform(get("/api/environments/" + environmentId + "/apps")
|
mockMvc.perform(get("/api/environments/" + environmentId + "/apps")
|
||||||
@@ -154,7 +159,8 @@ class AppControllerTest {
|
|||||||
var createResult = mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps")
|
var createResult = mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps")
|
||||||
.file(jar)
|
.file(jar)
|
||||||
.file(metadata)
|
.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(status().isCreated())
|
||||||
.andReturn();
|
.andReturn();
|
||||||
|
|
||||||
@@ -162,7 +168,8 @@ class AppControllerTest {
|
|||||||
.get("id").asText();
|
.get("id").asText();
|
||||||
|
|
||||||
mockMvc.perform(delete("/api/environments/" + environmentId + "/apps/" + appId)
|
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());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import java.time.Instant;
|
|||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.UUID;
|
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.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.delete;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
@@ -83,7 +85,9 @@ class EnvironmentControllerTest {
|
|||||||
var request = new CreateEnvironmentRequest("prod", "Production");
|
var request = new CreateEnvironmentRequest("prod", "Production");
|
||||||
|
|
||||||
mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
|
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)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
@@ -97,13 +101,17 @@ class EnvironmentControllerTest {
|
|||||||
var request = new CreateEnvironmentRequest("staging", "Staging");
|
var request = new CreateEnvironmentRequest("staging", "Staging");
|
||||||
|
|
||||||
mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
|
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)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
|
|
||||||
mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
|
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)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isConflict());
|
.andExpect(status().isConflict());
|
||||||
@@ -114,13 +122,16 @@ class EnvironmentControllerTest {
|
|||||||
var request = new CreateEnvironmentRequest("dev", "Development");
|
var request = new CreateEnvironmentRequest("dev", "Development");
|
||||||
|
|
||||||
mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
|
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)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
|
|
||||||
mockMvc.perform(get("/api/tenants/" + tenantId + "/environments")
|
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(status().isOk())
|
||||||
.andExpect(jsonPath("$[0].slug").value("dev"));
|
.andExpect(jsonPath("$[0].slug").value("dev"));
|
||||||
}
|
}
|
||||||
@@ -130,7 +141,9 @@ class EnvironmentControllerTest {
|
|||||||
var createRequest = new CreateEnvironmentRequest("qa", "QA");
|
var createRequest = new CreateEnvironmentRequest("qa", "QA");
|
||||||
|
|
||||||
var createResult = mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
|
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)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(createRequest)))
|
.content(objectMapper.writeValueAsString(createRequest)))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
@@ -142,7 +155,9 @@ class EnvironmentControllerTest {
|
|||||||
var updateRequest = new UpdateEnvironmentRequest("QA Updated");
|
var updateRequest = new UpdateEnvironmentRequest("QA Updated");
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/tenants/" + tenantId + "/environments/" + environmentId)
|
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)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(updateRequest)))
|
.content(objectMapper.writeValueAsString(updateRequest)))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -154,7 +169,9 @@ class EnvironmentControllerTest {
|
|||||||
var request = new CreateEnvironmentRequest("default", "Default");
|
var request = new CreateEnvironmentRequest("default", "Default");
|
||||||
|
|
||||||
var createResult = mockMvc.perform(post("/api/tenants/" + tenantId + "/environments")
|
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)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
@@ -164,7 +181,9 @@ class EnvironmentControllerTest {
|
|||||||
.get("id").asText();
|
.get("id").asText();
|
||||||
|
|
||||||
mockMvc.perform(delete("/api/tenants/" + tenantId + "/environments/" + environmentId)
|
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());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ class LicenseControllerTest {
|
|||||||
String tenantId = createTenantAndGetId();
|
String tenantId = createTenantAndGetId();
|
||||||
|
|
||||||
mockMvc.perform(post("/api/tenants/" + tenantId + "/license")
|
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(status().isCreated())
|
||||||
.andExpect(jsonPath("$.token").isNotEmpty())
|
.andExpect(jsonPath("$.token").isNotEmpty())
|
||||||
.andExpect(jsonPath("$.tier").value("MID"))
|
.andExpect(jsonPath("$.tier").value("MID"))
|
||||||
@@ -67,11 +69,14 @@ class LicenseControllerTest {
|
|||||||
String tenantId = createTenantAndGetId();
|
String tenantId = createTenantAndGetId();
|
||||||
|
|
||||||
mockMvc.perform(post("/api/tenants/" + tenantId + "/license")
|
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(status().isCreated());
|
||||||
|
|
||||||
mockMvc.perform(get("/api/tenants/" + tenantId + "/license")
|
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(status().isOk())
|
||||||
.andExpect(jsonPath("$.tier").value("MID"));
|
.andExpect(jsonPath("$.tier").value("MID"));
|
||||||
}
|
}
|
||||||
@@ -81,7 +86,8 @@ class LicenseControllerTest {
|
|||||||
String tenantId = createTenantAndGetId();
|
String tenantId = createTenantAndGetId();
|
||||||
|
|
||||||
mockMvc.perform(get("/api/tenants/" + tenantId + "/license")
|
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());
|
.andExpect(status().isNotFound());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,8 @@ class TenantControllerTest {
|
|||||||
String id = objectMapper.readTree(createResult.getResponse().getContentAsString()).get("id").asText();
|
String id = objectMapper.readTree(createResult.getResponse().getContentAsString()).get("id").asText();
|
||||||
|
|
||||||
mockMvc.perform(get("/api/tenants/" + id)
|
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(status().isOk())
|
||||||
.andExpect(jsonPath("$.slug").value(slug));
|
.andExpect(jsonPath("$.slug").value(slug));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export function OrgResolver({ children }: { children: React.ReactNode }) {
|
|||||||
const { getAccessToken } = useLogto();
|
const { getAccessToken } = useLogto();
|
||||||
const { setOrganizations, setCurrentOrg, setScopes, currentOrgId } = useOrgStore();
|
const { setOrganizations, setCurrentOrg, setScopes, currentOrgId } = useOrgStore();
|
||||||
|
|
||||||
|
// Effect 1: Org population — runs when /api/me data loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!me) return;
|
if (!me) return;
|
||||||
|
|
||||||
@@ -31,22 +32,46 @@ export function OrgResolver({ children }: { children: React.ReactNode }) {
|
|||||||
if (orgEntries.length === 1 && !currentOrgId) {
|
if (orgEntries.length === 1 && !currentOrgId) {
|
||||||
setCurrentOrg(orgEntries[0].id);
|
setCurrentOrg(orgEntries[0].id);
|
||||||
}
|
}
|
||||||
|
}, [me]);
|
||||||
|
|
||||||
// Read scopes from the access token JWT payload
|
// Effect 2: Scope fetching — runs when me loads OR when currentOrgId changes
|
||||||
fetchConfig().then((config) => {
|
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;
|
if (!config.logtoResource) return;
|
||||||
getAccessToken(config.logtoResource).then((token) => {
|
|
||||||
if (!token) return;
|
const extractScopes = (token: string | undefined): string[] => {
|
||||||
|
if (!token) return [];
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
const scopeStr = (payload.scope as string) ?? '';
|
return ((payload.scope as string) ?? '').split(' ').filter(Boolean);
|
||||||
setScopes(new Set(scopeStr.split(' ').filter(Boolean)));
|
|
||||||
} catch {
|
} 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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ interface AppConfig {
|
|||||||
logtoEndpoint: string;
|
logtoEndpoint: string;
|
||||||
logtoClientId: string;
|
logtoClientId: string;
|
||||||
logtoResource: string;
|
logtoResource: string;
|
||||||
|
scopes: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let cached: AppConfig | null = null;
|
let cached: AppConfig | null = null;
|
||||||
@@ -24,6 +25,18 @@ export async function fetchConfig(): Promise<AppConfig> {
|
|||||||
logtoEndpoint: import.meta.env.VITE_LOGTO_ENDPOINT || 'http://localhost:3001',
|
logtoEndpoint: import.meta.env.VITE_LOGTO_ENDPOINT || 'http://localhost:3001',
|
||||||
logtoClientId: import.meta.env.VITE_LOGTO_CLIENT_ID || '',
|
logtoClientId: import.meta.env.VITE_LOGTO_CLIENT_ID || '',
|
||||||
logtoResource: import.meta.env.VITE_LOGTO_RESOURCE || '',
|
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;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ function App() {
|
|||||||
logtoEndpoint: string;
|
logtoEndpoint: string;
|
||||||
logtoClientId: string;
|
logtoClientId: string;
|
||||||
logtoResource: string;
|
logtoResource: string;
|
||||||
|
scopes: string[];
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -71,6 +72,9 @@ function App() {
|
|||||||
'openid', 'profile', 'email', 'offline_access',
|
'openid', 'profile', 'email', 'offline_access',
|
||||||
UserScope.Organizations,
|
UserScope.Organizations,
|
||||||
UserScope.OrganizationRoles,
|
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 ?? []),
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user