Files
cameleer-saas/docs/architecture.md
hsiegeln 1ef8c9dceb
All checks were successful
CI / build (push) Successful in 38s
CI / docker (push) Successful in 37s
refactor: merge tenant isolation into single HandlerInterceptor
Replace TenantResolutionFilter + TenantOwnershipValidator (15 manual
calls across 5 controllers) with a single TenantIsolationInterceptor
that uses Spring HandlerMapping path variables for fail-closed tenant
isolation. New endpoints with {tenantId}, {environmentId}, or {appId}
path variables are automatically isolated without manual code.

Simplify OrgResolver from dual-token fetch to single token — Logto
merges all scopes into either token type.

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

48 KiB

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 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 <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:

@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:

@PreAuthorize("hasAuthority('SCOPE_apps:manage')")
public ResponseEntity<EnvironmentResponse> 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<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. 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:

{
  "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
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:

@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

  <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          -- 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`
currentTenantId `string null`
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:

{
  "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. 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