# 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 ------->| | | 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 `TenantIsolationInterceptor`. **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 `. 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()))); 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. `TenantIsolationInterceptor` (registered as a `HandlerInterceptor` on `/api/**` via `WebConfig`) reads `organization_id` from the JWT, resolves it to an internal tenant UUID via `TenantService.getByLogtoOrgId()`, stores it on `TenantContext` (ThreadLocal), and validates path variable isolation (see Section 8.1). **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_apps:manage')") public ResponseEntity create(...) { ... } ``` ### 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`): `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 an org-scoped access token (`getAccessToken(resource, orgId)`). Scopes are decoded from the JWT payload and written to the store via `setScopes()`. A single token fetch is sufficient because Logto merges all granted scopes (including global scopes like `platform:admin`) into the org-scoped token. 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:** The `useOrgStore` exposes a `scopes: Set` 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. 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, scopes) | | GET | `/api/health/secured` | JWT | Auth verification endpoint | | GET | `/actuator/health`| Public | Spring Boot health check | `/api/config` response shape: ```json { "logtoEndpoint": "http://localhost:3001", "logtoClientId": "", "logtoResource": "https://api.cameleer.local", "scopes": [ "platform:admin", "tenant:manage", "billing:manage", "team:manage", "apps:manage", "apps:deploy", "secrets:manage", "observe:read", "observe:debug", "settings:manage" ] } ``` The `scopes` array is authoritative -- the frontend reads it during Logto provider initialization to request the correct API resource scopes during sign-in. Scopes are defined as a constant list in `PublicConfigController` rather than being queried from Logto at runtime. ### 7.2 Identity | Method | Path | Auth | Description | |--------|-------------------|----------|--------------------------------------------| | 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` | `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` | `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` | `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` | `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` | `billing:manage` | 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 Tenant isolation is enforced by a single Spring `HandlerInterceptor` -- `TenantIsolationInterceptor` -- registered on `/api/**` via `WebConfig`. It handles both tenant resolution and ownership validation in one place: **Resolution (every `/api/**` request):** The interceptor's `preHandle()` reads the JWT's `organization_id` claim, resolves it to an internal tenant UUID via `TenantService.getByLogtoOrgId()`, and stores it on `TenantContext` (ThreadLocal). If no organization context is resolved and the user is not a platform admin, the interceptor returns **403 Forbidden**. **Path variable validation (automatic, fail-closed):** After resolution, the interceptor reads Spring's `HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE` to inspect path variables defined on the matched handler method. It checks three path variable names: - `{tenantId}` -- Compared directly against the resolved tenant ID. - `{environmentId}` -- The environment is loaded and its `tenantId` is compared. - `{appId}` -- The app -> environment -> tenant chain is followed and compared. If any path variable is present and the resolved tenant does not own that resource, the interceptor returns **403 Forbidden**. This is **fail-closed**: any new endpoint that uses these path variable names is automatically isolated without requiring manual validation calls. **Platform admin bypass:** Users with `SCOPE_platform:admin` bypass all isolation checks. Their `TenantContext` is left empty (null tenant ID), which downstream services interpret as unrestricted access. **Cleanup:** `TenantContext.clear()` is called in `afterCompletion()` to prevent ThreadLocal leaks regardless of whether the request succeeded or failed. **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 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 ``` -- Manages org-scoped token provider /login -- LoginPage /callback -- CallbackPage (OIDC redirect) -- Fetches /api/me, populates org store / -- DashboardPage /environments -- EnvironmentsPage /environments/:envId -- EnvironmentDetailPage /environments/:envId/apps/:appId -- AppDetailPage /license -- LicensePage /admin/tenants -- AdminTenantsPage ``` ### 9.3 Auth Data Flow ``` LogtoProvider -- Configured with 10 API resource scopes from /api/config | v ProtectedRoute -- Gates on isAuthenticated, redirects to /login | v OrgResolver -- Effect 1 [me]: populate org store from /api/me | -- Effect 2 [me, currentOrgId]: fetch org-scoped | -- access token, decode 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 ``` ### 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` | 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": "", "m2mClientId": "", "m2mClientSecret": "", "tradAppId": "", "tradAppSecret": "", "apiResourceIndicator": "https://api.cameleer.local", "organizationId": "", "tenantName": "Example Tenant", "tenantSlug": "default", "bootstrapToken": "", "platformAdminUser": "", "tenantAdminUser": "", "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. 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. --- ## 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/TenantIsolationInterceptor.java` | JWT org_id -> tenant resolution + path variable ownership validation (fail-closed) | | `src/.../config/WebConfig.java` | Registers `TenantIsolationInterceptor` on `/api/**` | | `src/.../config/TenantContext.java` | ThreadLocal tenant ID holder | | `src/.../config/MeController.java` | User identity + tenant 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 | | `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 |