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:
@@ -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<List<TenantResponse>> listAll() { ... }
|
||||
@PreAuthorize("hasAuthority('SCOPE_apps:manage')")
|
||||
public ResponseEntity<EnvironmentResponse> 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<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:**
|
||||
|
||||
@@ -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": "<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
|
||||
|
||||
| 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 |
|
||||
|
||||
Reference in New Issue
Block a user