Files
cameleer-saas/docs/architecture.md
hsiegeln b459a69083
All checks were successful
CI / build (push) Successful in 38s
CI / docker (push) Successful in 7s
docs: add architecture document
Comprehensive technical reference covering system topology, auth model
(Logto OIDC, scopes, token types, Spring Security pipeline), data model
(7 tables from Flyway migrations), deployment flow, agent-server protocol,
API endpoints, security boundaries, frontend architecture, and full
configuration reference. All class names, paths, and properties verified
against the codebase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:19:05 +02:00

905 lines
43 KiB
Markdown

# Cameleer SaaS Architecture
**Last updated:** 2026-04-05
**Status:** Living document -- update as the system evolves
---
## 1. System Overview
Cameleer SaaS is a multi-tenant platform that provides managed observability for
Apache Camel applications. Customers deploy their Camel JARs through the SaaS
platform and get zero-code instrumentation, execution tracing, route topology
visualization, and runtime control -- without running any observability
infrastructure themselves.
The system comprises three components:
**Cameleer Agent** (`cameleer3` repo) -- A Java agent using ByteBuddy for
zero-code bytecode instrumentation. Captures route executions, processor traces,
payloads, metrics, and route graph topology. Deployed as a `-javaagent` JAR
alongside the customer's application.
**Cameleer Server** (`cameleer3-server` repo) -- A Spring Boot observability
backend. Receives telemetry from agents via HTTP, pushes configuration and
commands to agents via SSE. Stores data in PostgreSQL and ClickHouse. Provides
a React SPA dashboard for direct observability access. JWT auth with Ed25519
config signing.
**Cameleer SaaS** (this repo) -- The multi-tenancy, deployment, and management
layer. Handles user authentication via Logto OIDC, tenant provisioning, JAR
upload and deployment, API key management, license generation, and audit
logging. Serves a React SPA that wraps the full user experience.
---
## 2. Component Topology
```
Internet / LAN
|
+-----+-----+
| Traefik | :80 / :443
| (v3) | Reverse proxy + TLS termination
+-----+-----+
|
+---------------+---------------+-------------------+
| | | |
PathPrefix(/api) PathPrefix(/) PathPrefix(/oidc) PathPrefix(/observe)
PathPrefix(/api) priority=1 PathPrefix( PathPrefix(/dashboard)
| | /interaction) |
v v v v
+--------------+ +--------------+ +-----------+ +------------------+
| cameleer-saas| | cameleer-saas| | Logto | | cameleer3-server |
| (API) | | (SPA) | | | | |
| :8080 | | :8080 | | :3001 | | :8081 |
+--------------+ +--------------+ +-----------+ +------------------+
| | |
+------+-------------------------+------------------+
| | |
+------+------+ +------+------+ +------+------+
| PostgreSQL | | PostgreSQL | | ClickHouse |
| :5432 | | (logto DB) | | :8123 |
| cameleer_ | | :5432 | | cameleer |
| saas DB | +--------------+ +-------------+
+--------------+
|
+------+------+
| Customer |
| App + Agent |
| (container) |
+-------------+
```
### Services
| Service | Image | Internal Port | Network | Purpose |
|-------------------|---------------------------------------------|---------------|----------|----------------------------------|
| traefik | `traefik:v3` | 80, 443 | cameleer | Reverse proxy, TLS, routing |
| postgres | `postgres:16-alpine` | 5432 | cameleer | Shared PostgreSQL (3 databases) |
| logto | `ghcr.io/logto-io/logto:latest` | 3001 | cameleer | OIDC identity provider |
| logto-bootstrap | `postgres:16-alpine` (ephemeral) | -- | cameleer | One-shot bootstrap script |
| cameleer-saas | `gitea.siegeln.net/cameleer/cameleer-saas` | 8080 | cameleer | SaaS API + SPA serving |
| cameleer3-server | `gitea.siegeln.net/cameleer/cameleer3-server`| 8081 | cameleer | Observability backend |
| clickhouse | `clickhouse/clickhouse-server:latest` | 8123 | cameleer | Time-series telemetry storage |
### Docker Network
All services share a single Docker bridge network named `cameleer`. Customer app
containers are also attached to this network so agents can reach the
cameleer3-server.
### Volumes
| Volume | Mounted By | Purpose |
|-----------------|---------------------|--------------------------------------------|
| `pgdata` | postgres | PostgreSQL data persistence |
| `chdata` | clickhouse | ClickHouse data persistence |
| `acme` | traefik | TLS certificate storage |
| `jardata` | cameleer-saas | Uploaded customer JAR files |
| `bootstrapdata` | logto-bootstrap, cameleer-saas | Bootstrap output JSON (shared) |
### Databases on PostgreSQL
The shared PostgreSQL instance hosts three databases:
- `cameleer_saas` -- SaaS platform tables (tenants, environments, apps, etc.)
- `logto` -- Logto identity provider data
- `cameleer3` -- cameleer3-server operational data
The `docker/init-databases.sh` init script creates all three during first start.
---
## 3. Authentication & Authorization
### 3.1 Design Principles
1. **Logto is the single identity provider** for all human users.
2. **Zero trust** -- every service validates tokens independently via JWKS or its
own signing key. No identity in HTTP headers.
3. **No custom crypto** -- standard protocols only (OAuth2, OIDC, JWT, SHA-256).
4. **API keys for agents** -- per-environment opaque secrets, exchanged for
server-issued JWTs via the bootstrap registration flow.
### 3.2 Token Types
| Token | Issuer | Algorithm | Validator | Used By |
|--------------------|-----------------|------------------|----------------------|--------------------------------|
| Logto user JWT | Logto | ES384 (asymmetric)| Any service via JWKS | SaaS UI users, server users |
| Logto M2M JWT | Logto | ES384 (asymmetric)| Any service via JWKS | SaaS platform -> server calls |
| Server internal JWT| cameleer3-server| HS256 (symmetric) | Issuing server only | Agents (after registration) |
| API key (opaque) | SaaS platform | N/A (SHA-256 hash)| cameleer3-server | Agent initial registration |
| Ed25519 signature | cameleer3-server| EdDSA | Agent | Server -> agent command signing|
### 3.3 Scope Model
The Logto API resource `https://api.cameleer.local` has 10 scopes, created by
the bootstrap script (`docker/logto-bootstrap.sh`):
| Scope | Description | Platform Admin | Org Admin | Org Member |
|--------------------|--------------------------------|:--------------:|:---------:|:----------:|
| `platform:admin` | SaaS platform administration | x | | |
| `tenant:manage` | Manage tenant settings | x | x | |
| `billing:manage` | Manage billing | x | x | |
| `team:manage` | Manage team members | x | x | |
| `apps:manage` | Create and delete apps | x | x | |
| `apps:deploy` | Deploy apps | x | x | x |
| `secrets:manage` | Manage secrets | x | x | |
| `observe:read` | View observability data | x | x | x |
| `observe:debug` | Debug and replay operations | x | x | x |
| `settings:manage` | Manage settings | x | x | |
**Role hierarchy:**
- **Global role `platform-admin`** -- All 10 scopes. Assigned to SaaS owner.
- **Organization role `admin`** -- 9 tenant-level scopes (all except `platform:admin`).
- **Organization role `member`** -- 3 scopes: `apps:deploy`, `observe:read`,
`observe:debug`.
### 3.4 Authentication Flows
**Human user -> SaaS Platform:**
```
Browser Logto cameleer-saas
| | |
|--- OIDC auth code flow ->| |
|<-- id_token, auth code --| |
| | |
|--- getAccessToken(resource, orgId) ---------------->|
| (org-scoped JWT with scope claim) |
| | |
|--- GET /api/me, Authorization: Bearer <jwt> ------->|
| | validate via JWKS |
| | extract organization_id|
| | resolve to tenant |
|<-- { userId, tenants } -----------------------------|
```
1. User authenticates with Logto (OIDC authorization code flow via `@logto/react`).
2. Frontend obtains org-scoped access token via `getAccessToken(resource, orgId)`.
3. Backend validates via Logto JWKS (Spring OAuth2 Resource Server).
4. `organization_id` claim in JWT resolves to internal tenant ID via
`TenantResolutionFilter`.
**SaaS platform -> cameleer3-server API (M2M):**
1. SaaS platform obtains Logto M2M token (`client_credentials` grant) via
`LogtoManagementClient`.
2. Calls server API with `Authorization: Bearer <logto-m2m-token>`.
3. Server validates via Logto JWKS (OIDC resource server support).
4. Server grants ADMIN role to valid M2M tokens.
**Agent -> cameleer3-server:**
1. Agent reads `CAMELEER_AUTH_TOKEN` environment variable (API key).
2. Calls `POST /api/v1/agents/register` with the key as Bearer token.
3. Server validates via `BootstrapTokenValidator` (constant-time comparison).
4. Server issues internal HMAC JWT (access + refresh) + Ed25519 public key.
5. Agent uses JWT for all subsequent requests, refreshes on expiry.
**Server -> Agent (commands):**
1. Server signs command payload with Ed25519 private key.
2. Sends via SSE with signature field.
3. Agent verifies using server's public key (received at registration).
4. Destructive commands require a nonce (replay protection).
### 3.5 Spring Security Configuration
`SecurityConfig.java` configures a single stateless filter chain:
```java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/api/config").permitAll()
.requestMatchers("/", "/index.html", "/login", "/callback",
"/environments/**", "/license", "/admin/**").permitAll()
.requestMatchers("/assets/**", "/favicon.ico").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt ->
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())))
.addFilterAfter(tenantResolutionFilter,
BearerTokenAuthenticationFilter.class);
return http.build();
}
}
```
**JWT processing pipeline:**
1. `BearerTokenAuthenticationFilter` (Spring built-in) extracts the Bearer token.
2. `JwtDecoder` validates the token signature (ES384 via Logto JWKS) and issuer.
Accepts both `JWT` and `at+jwt` token types (RFC 9068 / Logto convention).
3. `JwtAuthenticationConverter` maps the `scope` claim to Spring authorities:
`scope: "platform:admin observe:read"` becomes `SCOPE_platform:admin` and
`SCOPE_observe:read`.
4. `TenantResolutionFilter` reads `organization_id` from the JWT, resolves it
to an internal tenant UUID via `TenantService.getByLogtoOrgId()`, and stores
it on `TenantContext` (ThreadLocal).
**Authorization pattern** (used in controllers):
```java
@PreAuthorize("hasAuthority('SCOPE_platform:admin')")
public ResponseEntity<List<TenantResponse>> listAll() { ... }
```
### 3.6 Frontend Auth Architecture
**Logto SDK integration** (`main.tsx`):
The `LogtoProvider` is configured with scopes including `UserScope.Organizations`
and `UserScope.OrganizationRoles`, requesting organization-aware tokens from
Logto.
**Token management** (`TokenSync` component in `main.tsx`):
When an organization is selected, `setTokenProvider` is called with
`getAccessToken(resource, orgId)` to produce org-scoped JWTs. When no org is
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()`.
**Scope-based UI gating:**
The `useOrgStore` exposes a `scopes: Set<string>` that components check to
conditionally render UI elements. For example, admin-only controls check for
`platform:admin` in the scope set.
**Route protection** (`ProtectedRoute.tsx`):
Wraps authenticated routes. Redirects to `/login` when the user is not
authenticated. Uses a ref to avoid showing a spinner after the initial auth
check completes (the Logto SDK sets `isLoading=true` for every async method,
not just initial load).
---
## 4. Data Model
### 4.1 Entity Relationship Diagram
```
+-------------------+
| tenants |
+-------------------+
| id (PK, UUID) |
| name |
| slug (UNIQUE) |
| tier |
| status |
| logto_org_id |
| stripe_customer_id|
| stripe_sub_id |
| settings (JSONB) |
| created_at |
| updated_at |
+--------+----------+
|
+-----+-----+------------------+
| | |
v v v
+----------+ +----------+ +-----------+
| licenses | | environ- | | audit_log |
| | | ments | | |
+----------+ +----------+ +-----------+
| id (PK) | | id (PK) | | id (PK) |
| tenant_id| | tenant_id| | tenant_id |
| tier | | slug | | actor_id |
| features | | display_ | | action |
| limits | | name | | resource |
| token | | status | | result |
| issued_at| | created_ | | metadata |
| expires_ | | at | | created_at|
| at | +-----+----+ +-----------+
+----------+ |
+----+----+
| |
v v
+----------+ +-----------+
| api_keys | | apps |
+----------+ +-----------+
| id (PK) | | id (PK) |
| environ_ | | environ_ |
| ment_id | | ment_id |
| key_hash | | slug |
| key_ | | display_ |
| prefix | | name |
| status | | jar_* |
| created_ | | exposed_ |
| at | | port |
| revoked_ | | current_ |
| at | | deploy_id|
+----------+ | previous_ |
| deploy_id|
+-----+-----+
|
v
+-------------+
| deployments |
+-------------+
| id (PK) |
| app_id |
| version |
| image_ref |
| desired_ |
| status |
| observed_ |
| status |
| orchestrator|
| _metadata |
| error_msg |
| deployed_at |
| stopped_at |
| created_at |
+-------------+
```
### 4.2 Table Descriptions
**`tenants`** (V001) -- Top-level multi-tenancy entity. Each tenant maps to a
Logto organization via `logto_org_id`. The `tier` column (`LOW` default) drives
license feature gates. The `status` column tracks provisioning state
(`PROVISIONING`, `ACTIVE`, etc.). `settings` is a JSONB bag for tenant-specific
configuration. Stripe columns support future billing integration.
**`licenses`** (V002) -- Per-tenant license tokens with feature flags and usage
limits. The `token` column stores the generated license string. `features` and
`limits` are JSONB columns holding structured capability data. Licenses have
explicit expiry and optional revocation.
**`environments`** (V003) -- Logical deployment environments within a tenant
(e.g., `dev`, `staging`, `production`). Scoped by `(tenant_id, slug)` unique
constraint. Each environment gets its own set of API keys and apps.
**`api_keys`** (V004) -- Per-environment opaque API keys for agent
authentication. The plaintext is never stored -- only `key_hash` (SHA-256 hex,
64 chars) and `key_prefix` (first 12 chars of the `cmk_`-prefixed key, for
identification). Status lifecycle: `ACTIVE` -> `ROTATED` or `REVOKED`.
**`apps`** (V005) -- Customer applications within an environment. Tracks
uploaded JAR metadata (`jar_storage_path`, `jar_checksum`, `jar_size_bytes`,
`jar_original_filename`), optional `exposed_port` for inbound HTTP routing,
and deployment references (`current_deployment_id`, `previous_deployment_id`
for rollback).
**`deployments`** (V006) -- Versioned deployment records for each app. Tracks a
two-state lifecycle: `desired_status` (what the user wants: `RUNNING` or
`STOPPED`) and `observed_status` (what the system sees: `BUILDING`, `STARTING`,
`RUNNING`, `STOPPED`, `FAILED`). `orchestrator_metadata` (JSONB) stores the
Docker container ID. Versioned with `(app_id, version)` unique constraint.
**`audit_log`** (V007) -- Append-only audit trail. Records actor, tenant,
action, resource, environment, result, and optional metadata JSONB. Indexed
by `(tenant_id, created_at)`, `(actor_id, created_at)`, and
`(action, created_at)` for efficient querying.
### 4.3 Audit Actions
Defined in `AuditAction.java`:
| Category | Actions |
|---------------|----------------------------------------------------------------|
| Auth | `AUTH_REGISTER`, `AUTH_LOGIN`, `AUTH_LOGIN_FAILED`, `AUTH_LOGOUT`|
| Tenant | `TENANT_CREATE`, `TENANT_UPDATE`, `TENANT_SUSPEND`, `TENANT_REACTIVATE`, `TENANT_DELETE` |
| Environment | `ENVIRONMENT_CREATE`, `ENVIRONMENT_UPDATE`, `ENVIRONMENT_DELETE`|
| App lifecycle | `APP_CREATE`, `APP_DEPLOY`, `APP_PROMOTE`, `APP_ROLLBACK`, `APP_SCALE`, `APP_STOP`, `APP_DELETE` |
| Secrets | `SECRET_CREATE`, `SECRET_READ`, `SECRET_UPDATE`, `SECRET_DELETE`, `SECRET_ROTATE` |
| Config | `CONFIG_UPDATE` |
| Team | `TEAM_INVITE`, `TEAM_REMOVE`, `TEAM_ROLE_CHANGE` |
| License | `LICENSE_GENERATE`, `LICENSE_REVOKE` |
---
## 5. Deployment Model
### 5.1 Server-Per-Tenant
Each tenant gets a dedicated cameleer3-server instance. The SaaS platform
provisions and manages these servers. In the current Docker Compose topology, a
single shared cameleer3-server is used for the default tenant. Production
deployments will run per-tenant servers as separate containers or K8s pods.
### 5.2 Customer App Deployment Flow
The deployment lifecycle is managed by `DeploymentService`:
```
User uploads JAR Build Docker image Start container
via AppController --> from base image + --> on cameleer network
(multipart POST) uploaded JAR with agent env vars
| | |
v v v
apps.jar_storage_path deployments.image_ref deployments.orchestrator_metadata
apps.jar_checksum deployments.observed_ {"containerId": "..."}
apps.jar_size_bytes status = BUILDING
```
**Step-by-step (from `DeploymentService.deploy()`):**
1. **Validate** -- Ensure the app has an uploaded JAR.
2. **Version** -- Increment deployment version via
`deploymentRepository.findMaxVersionByAppId()`.
3. **Image ref** -- Generate `cameleer-runtime-{env}-{app}:v{n}`.
4. **Persist** -- Save deployment record with `observed_status = BUILDING`.
5. **Audit** -- Log `APP_DEPLOY` action.
6. **Async execution** (`@Async("deploymentExecutor")`):
a. Build Docker image from base image + customer JAR.
b. Stop previous container if one exists.
c. Start new container with environment variables:
| Variable | Value |
|-----------------------------|----------------------------------------|
| `CAMELEER_AUTH_TOKEN` | API key for agent registration |
| `CAMELEER_EXPORT_TYPE` | `HTTP` |
| `CAMELEER_EXPORT_ENDPOINT` | cameleer3-server internal URL |
| `CAMELEER_APPLICATION_ID` | App slug |
| `CAMELEER_ENVIRONMENT_ID` | Environment slug |
| `CAMELEER_DISPLAY_NAME` | `{tenant}-{env}-{app}` |
d. Apply resource limits (`container-memory-limit`, `container-cpu-shares`).
e. Configure Traefik labels for inbound routing if `exposed_port` is set:
`{app}.{env}.{tenant}.{domain}`.
f. Poll container health for up to `health-check-timeout` seconds.
g. Update deployment status to `RUNNING` or `FAILED`.
h. Update app's `current_deployment_id` and `previous_deployment_id`.
### 5.3 Container Resource Limits
Configured via `RuntimeConfig`:
| Property | Default | Description |
|-----------------------------------|-------------|-----------------------------|
| `cameleer.runtime.container-memory-limit` | `512m` | Docker memory limit |
| `cameleer.runtime.container-cpu-shares` | `512` | Docker CPU shares |
| `cameleer.runtime.max-jar-size` | `200MB` | Max upload size |
| `cameleer.runtime.health-check-timeout` | `60` | Seconds to wait for healthy |
| `cameleer.runtime.deployment-thread-pool-size` | `4`| Concurrent deployments |
---
## 6. Agent-Server Protocol
The agent-server protocol is defined in full in
`cameleer3/cameleer3-common/PROTOCOL.md`. This section summarizes the key
aspects relevant to the SaaS platform.
### 6.1 Agent Registration
1. Agent starts with `CAMELEER_AUTH_TOKEN` environment variable (an API key
generated by the SaaS platform, prefixed with `cmk_`).
2. Agent calls `POST /api/v1/agents/register` on the cameleer3-server with the
API key as a Bearer token.
3. Server validates the key and returns:
- HMAC JWT access token (short-lived, ~1 hour)
- HMAC JWT refresh token (longer-lived, ~7 days)
- Ed25519 public key (for verifying server commands)
4. Agent uses the access token for all subsequent API calls.
5. On access token expiry, agent uses refresh token to obtain a new pair.
6. On refresh token expiry, agent re-registers using the original API key.
### 6.2 Telemetry Ingestion
Agents send telemetry to the server via HTTP POST:
- Route executions with processor-level traces
- Payload captures (configurable granularity with redaction)
- Route graph topology (tree + graph dual representation)
- Metrics and heartbeats
### 6.3 Server-to-Agent Commands (SSE)
The server maintains an SSE (Server-Sent Events) push channel to each agent:
- Configuration changes (engine level, payload capture settings)
- Deep trace requests for specific correlation IDs
- Exchange replay commands
- Per-processor payload capture overrides
**Command signing:** All commands are signed with the server's Ed25519 private
key. The agent verifies signatures using the public key received during
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.
### 7.1 Platform Configuration
| Method | Path | Auth | Description |
|--------|-------------------|----------|--------------------------------------------|
| GET | `/api/config` | Public | Frontend config (Logto endpoint, client ID, API resource) |
| GET | `/api/health/secured` | JWT | Auth verification endpoint |
| GET | `/actuator/health`| Public | Spring Boot health check |
### 7.2 Identity
| Method | Path | Auth | Description |
|--------|-------------------|----------|--------------------------------------------|
| GET | `/api/me` | JWT | Current user info + tenant memberships |
`MeController` extracts `organization_id` from the JWT to resolve the tenant.
For non-org-scoped tokens, it falls back to `LogtoManagementClient.getUserOrganizations()`
to enumerate all organizations the user belongs to.
### 7.3 Tenants
| Method | Path | Auth | Description |
|--------|----------------------------|----------------------------------|------------------------|
| GET | `/api/tenants` | `SCOPE_platform:admin` | List all tenants |
| POST | `/api/tenants` | `SCOPE_platform:admin` | Create tenant |
| GET | `/api/tenants/{id}` | JWT | Get tenant by UUID |
| GET | `/api/tenants/by-slug/{slug}` | JWT | Get tenant by slug |
### 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 |
### 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 |
### 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 |
### 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`) |
### 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 |
### 7.9 SPA Routing
The `SpaController` forwards all non-API paths to `index.html` for client-side
routing:
```java
@GetMapping(value = {"/", "/login", "/callback", "/environments/**", "/license"})
public String spa() { return "forward:/index.html"; }
```
---
## 8. Security Model
### 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).
- 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
resource limits.
### 8.2 API Key Security
- Keys are generated with 32 bytes of `SecureRandom` entropy, prefixed with
`cmk_` and Base64url-encoded.
- Only the SHA-256 hash is stored in the database (`key_hash` column, 64 hex
chars). The `key_prefix` (first 12 chars) is stored for identification in
UI listings.
- The plaintext key is returned exactly once at creation time and never stored.
- Key lifecycle: `ACTIVE` -> `ROTATED` (old keys remain for grace period) or
`REVOKED` (immediately invalidated, `revoked_at` timestamp set).
- Validation is via SHA-256 hash comparison:
`ApiKeyService.validate(plaintext)` -> hash -> lookup by hash and status.
### 8.3 Token Lifetimes
| Token | Lifetime | Notes |
|----------------------|-------------|------------------------------------|
| Logto access token | ~1 hour | Configured in Logto, refreshed by SDK |
| Logto refresh token | ~14 days | Used by `@logto/react` for silent refresh |
| Server agent JWT | ~1 hour | cameleer3-server `CAMELEER_JWT_SECRET` |
| Server refresh token | ~7 days | Agent re-registers when expired |
### 8.4 Audit Logging
All state-changing operations are logged to the `audit_log` table via
`AuditService.log()`. Each entry records:
- `actor_id` -- UUID of the user (from JWT subject)
- `tenant_id` -- UUID of the affected tenant
- `action` -- Enum value from `AuditAction`
- `resource` -- Identifier of the affected resource (e.g., app slug)
- `environment` -- Environment slug if applicable
- `result` -- `SUCCESS` or error indicator
- `metadata` -- Optional JSONB for additional context
Audit entries are immutable (append-only, no UPDATE/DELETE operations).
### 8.5 Security Boundaries
- CSRF is disabled (stateless API, Bearer token auth only).
- Sessions are disabled (`SessionCreationPolicy.STATELESS`).
- The Docker socket is mounted read-write on cameleer-saas for container
management. This is the highest-privilege access in the system.
- Logto's admin endpoint (`:3002`) is not exposed through Traefik.
- ClickHouse has no external port exposure.
---
## 9. Frontend Architecture
### 9.1 Stack
| Technology | Purpose |
|-----------------------|-------------------------------------------|
| React 19 | UI framework |
| Vite | Build tool and dev server |
| `@logto/react` | OIDC SDK (auth code flow, token mgmt) |
| Zustand | Org/tenant state management (`useOrgStore`)|
| TanStack React Query | Server state, caching, background refresh |
| React Router (v7) | Client-side routing |
| `@cameleer/design-system` | Shared component library (Gitea npm) |
### 9.2 Component Hierarchy
```
<ThemeProvider>
<ToastProvider>
<BreadcrumbProvider>
<GlobalFilterProvider>
<CommandPaletteProvider>
<LogtoProvider>
<TokenSync /> -- Manages org-scoped token provider
<QueryClientProvider>
<BrowserRouter>
<AppRouter>
/login -- LoginPage
/callback -- CallbackPage (OIDC redirect)
<ProtectedRoute>
<OrgResolver> -- Fetches /api/me, populates org store
<Layout>
/ -- DashboardPage
/environments -- EnvironmentsPage
/environments/:envId -- EnvironmentDetailPage
/environments/:envId/apps/:appId -- AppDetailPage
/license -- LicensePage
/admin/tenants -- AdminTenantsPage
```
### 9.3 Auth Data Flow
```
LogtoProvider
|
v
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
v
Layout + pages -- Read from useOrgStore for tenant context
-- Read from useAuth() for auth state
-- Read scopes for UI gating
```
### 9.4 State Stores
**`useOrgStore`** (Zustand) -- `ui/src/auth/useOrganization.ts`:
| Field | Type | Purpose |
|------------------|------------------|------------------------------------|
| `currentOrgId` | `string | null` | Logto org ID (for token scoping) |
| `currentTenantId`| `string | null` | DB UUID (for API calls) |
| `organizations` | `OrgInfo[]` | All orgs the user belongs to |
| `scopes` | `Set<string>` | OAuth2 scopes from access token |
**`useAuth()`** hook -- `ui/src/auth/useAuth.ts`:
Combines `@logto/react` state (`isAuthenticated`, `isLoading`) with org store
state (`currentTenantId`). Provides `logout` and `signIn` callbacks.
---
## 10. Configuration Reference
### 10.1 cameleer-saas
**Spring / Database:**
| Variable | Default | Description |
|------------------------------|----------------------------------------------|----------------------------------|
| `SPRING_DATASOURCE_URL` | `jdbc:postgresql://postgres:5432/cameleer_saas` | PostgreSQL JDBC URL |
| `SPRING_DATASOURCE_USERNAME`| `cameleer` | PostgreSQL user |
| `SPRING_DATASOURCE_PASSWORD`| `cameleer_dev` | PostgreSQL password |
**Logto / OIDC:**
| Variable | Default | Description |
|---------------------------|------------|--------------------------------------------|
| `LOGTO_ENDPOINT` | (empty) | Logto internal URL (Docker-internal) |
| `LOGTO_PUBLIC_ENDPOINT` | (empty) | Logto public URL (browser-accessible) |
| `LOGTO_ISSUER_URI` | (empty) | OIDC issuer URI for JWT validation |
| `LOGTO_JWK_SET_URI` | (empty) | JWKS endpoint for JWT signature validation |
| `LOGTO_M2M_CLIENT_ID` | (empty) | M2M app client ID (from bootstrap) |
| `LOGTO_M2M_CLIENT_SECRET` | (empty) | M2M app client secret (from bootstrap) |
| `LOGTO_SPA_CLIENT_ID` | (empty) | SPA app client ID (fallback; bootstrap preferred) |
**Runtime / Deployment:**
| Variable | Default | Description |
|-----------------------------------|------------------------------------|----------------------------------|
| `CAMELEER3_SERVER_ENDPOINT` | `http://cameleer3-server:8081` | cameleer3-server internal URL |
| `CAMELEER_JAR_STORAGE_PATH` | `/data/jars` | JAR upload storage directory |
| `CAMELEER_RUNTIME_BASE_IMAGE` | `cameleer-runtime-base:latest` | Base Docker image for app builds |
| `CAMELEER_DOCKER_NETWORK` | `cameleer` | Docker network for containers |
| `CAMELEER_CONTAINER_MEMORY_LIMIT`| `512m` | Per-container memory limit |
| `CAMELEER_CONTAINER_CPU_SHARES` | `512` | Per-container CPU shares |
| `CLICKHOUSE_URL` | `jdbc:clickhouse://clickhouse:8123/cameleer` | ClickHouse JDBC URL |
| `CLICKHOUSE_ENABLED` | `true` | Enable ClickHouse integration |
| `CLICKHOUSE_USERNAME` | `default` | ClickHouse user |
| `CLICKHOUSE_PASSWORD` | (empty) | ClickHouse password |
| `DOMAIN` | `localhost` | Base domain for Traefik routing |
### 10.2 cameleer3-server
| Variable | Default | Description |
|------------------------------|----------------------------------------------|----------------------------------|
| `SPRING_DATASOURCE_URL` | `jdbc:postgresql://postgres:5432/cameleer3` | PostgreSQL JDBC URL |
| `SPRING_DATASOURCE_USERNAME`| `cameleer` | PostgreSQL user |
| `SPRING_DATASOURCE_PASSWORD`| `cameleer_dev` | PostgreSQL password |
| `CLICKHOUSE_URL` | `jdbc:clickhouse://clickhouse:8123/cameleer` | ClickHouse JDBC URL |
| `CAMELEER_AUTH_TOKEN` | `default-bootstrap-token` | Agent bootstrap token |
| `CAMELEER_JWT_SECRET` | `cameleer-dev-jwt-secret-...` | HMAC secret for internal JWTs |
| `CAMELEER_TENANT_ID` | `default` | Tenant slug for data isolation |
| `CAMELEER_OIDC_ISSUER_URI` | (empty) | Logto issuer for M2M token validation |
| `CAMELEER_OIDC_AUDIENCE` | (empty) | Expected JWT audience |
### 10.3 logto
| Variable | Default | Description |
|---------------------|--------------------------|---------------------------------|
| `LOGTO_PUBLIC_ENDPOINT` | `http://localhost:3001`| Public-facing Logto URL |
| `LOGTO_ADMIN_ENDPOINT` | `http://localhost:3002`| Admin console URL (not exposed) |
### 10.4 postgres
| Variable | Default | Description |
|---------------------|-------------------|---------------------------------|
| `POSTGRES_DB` | `cameleer_saas` | Default database name |
| `POSTGRES_USER` | `cameleer` | PostgreSQL superuser |
| `POSTGRES_PASSWORD` | `cameleer_dev` | PostgreSQL password |
### 10.5 logto-bootstrap
| Variable | Default | Description |
|----------------------|----------------------------|--------------------------------|
| `SAAS_ADMIN_USER` | `admin` | Platform admin username |
| `SAAS_ADMIN_PASS` | `admin` | Platform admin password |
| `TENANT_ADMIN_USER` | `camel` | Default tenant admin username |
| `TENANT_ADMIN_PASS` | `camel` | Default tenant admin password |
| `CAMELEER_AUTH_TOKEN`| `default-bootstrap-token` | Agent bootstrap token |
### 10.6 Bootstrap Output
The bootstrap script writes `/data/logto-bootstrap.json` containing:
```json
{
"spaClientId": "<auto-generated>",
"m2mClientId": "<auto-generated>",
"m2mClientSecret": "<auto-generated>",
"tradAppId": "<auto-generated>",
"tradAppSecret": "<auto-generated>",
"apiResourceIndicator": "https://api.cameleer.local",
"organizationId": "<auto-generated>",
"tenantName": "Example Tenant",
"tenantSlug": "default",
"bootstrapToken": "<from env>",
"platformAdminUser": "<from env>",
"tenantAdminUser": "<from env>",
"oidcIssuerUri": "http://logto:3001/oidc",
"oidcAudience": "https://api.cameleer.local"
}
```
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.
---
## Appendix: Key Source Files
| File | Purpose |
|------|---------|
| `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/TenantContext.java` | ThreadLocal tenant ID holder |
| `src/.../config/MeController.java` | User identity + tenant endpoint |
| `src/.../config/PublicConfigController.java` | SPA configuration endpoint |
| `src/.../tenant/TenantController.java` | Tenant CRUD (platform:admin gated) |
| `src/.../environment/EnvironmentController.java` | Environment CRUD |
| `src/.../app/AppController.java` | App CRUD + JAR upload |
| `src/.../deployment/DeploymentService.java` | Async deployment orchestration |
| `src/.../deployment/DeploymentController.java` | Deploy/stop/restart endpoints |
| `src/.../apikey/ApiKeyService.java` | API key generation, rotation, revocation |
| `src/.../identity/LogtoManagementClient.java` | Logto Management API client |
| `src/.../audit/AuditService.java` | Audit log writer |
| `src/.../runtime/RuntimeConfig.java` | Container runtime configuration |
| `ui/src/main.tsx` | React app entry, Logto provider setup |
| `ui/src/router.tsx` | Client-side route definitions |
| `ui/src/auth/OrgResolver.tsx` | Org + scope resolution from JWT |
| `ui/src/auth/useOrganization.ts` | Zustand org/tenant store |
| `ui/src/auth/useAuth.ts` | Auth convenience hook |
| `ui/src/auth/ProtectedRoute.tsx` | Route guard component |