Move all SaaS configuration properties under the cameleer.saas.* namespace with all-lowercase dot-separated names and mechanical env var mapping. Aligns with the server (cameleer.server.*) and agent (cameleer.agent.*) conventions. Changes: - Move cameleer.identity.* → cameleer.saas.identity.* - Move cameleer.provisioning.* → cameleer.saas.provisioning.* - Move cameleer.certs.* → cameleer.saas.certs.* - Rename kebab-case properties to concatenated lowercase - Update all env vars to CAMELEER_SAAS_* mechanical mapping - Update DockerTenantProvisioner to pass CAMELEER_SERVER_* env vars to provisioned server containers (matching server's new convention) - Spring JWT config now derives from SaaS properties via cross-reference - Clean up orphaned properties in application-local.yml - Update docker-compose.yml, docker-compose.dev.yml, .env.example - Update CLAUDE.md, HOWTO.md, architecture.md, user-manual.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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 datacameleer3-- cameleer3-server operational data
The docker/init-databases.sh init script creates all three during first start.
3. Authentication & Authorization
3.1 Design Principles
- Logto is the single identity provider for all human users.
- Zero trust -- every service validates tokens independently via JWKS or its own signing key. No identity in HTTP headers.
- No custom crypto -- standard protocols only (OAuth2, OIDC, JWT, SHA-256).
- 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 exceptplatform: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 } -----------------------------|
- User authenticates with Logto (OIDC authorization code flow via
@logto/react). - Frontend obtains org-scoped access token via
getAccessToken(resource, orgId). - Backend validates via Logto JWKS (Spring OAuth2 Resource Server).
organization_idclaim in JWT resolves to internal tenant ID viaTenantIsolationInterceptor.
SaaS platform -> cameleer3-server API (M2M):
- SaaS platform obtains Logto M2M token (
client_credentialsgrant) viaLogtoManagementClient. - Calls server API with
Authorization: Bearer <logto-m2m-token>. - Server validates via Logto JWKS (OIDC resource server support).
- Server grants ADMIN role to valid M2M tokens.
Agent -> cameleer3-server:
- Agent reads
CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKENenvironment variable (API key). - Calls
POST /api/v1/agents/registerwith the key as Bearer token. - Server validates via
BootstrapTokenValidator(constant-time comparison). - Server issues internal HMAC JWT (access + refresh) + Ed25519 public key.
- Agent uses JWT for all subsequent requests, refreshes on expiry.
Server -> Agent (commands):
- Server signs command payload with Ed25519 private key.
- Sends via SSE with signature field.
- Agent verifies using server's public key (received at registration).
- 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:
BearerTokenAuthenticationFilter(Spring built-in) extracts the Bearer token.JwtDecodervalidates the token signature (ES384 via Logto JWKS) and issuer. Accepts bothJWTandat+jwttoken types (RFC 9068 / Logto convention).JwtAuthenticationConvertermaps thescopeclaim to Spring authorities:scope: "platform:admin observe:read"becomesSCOPE_platform:adminandSCOPE_observe:read.TenantIsolationInterceptor(registered as aHandlerInterceptoron/api/**viaWebConfig) readsorganization_idfrom the JWT, resolves it to an internal tenant UUID viaTenantService.getByLogtoOrgId(), stores it onTenantContext(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]) -- CallsGET /api/meto fetch tenant memberships, maps them toOrgInfoobjects 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 viasetScopes(). A single token fetch is sufficient because Logto merges all granted scopes (including global scopes likeplatform: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()):
-
Validate -- Ensure the app has an uploaded JAR.
-
Version -- Increment deployment version via
deploymentRepository.findMaxVersionByAppId(). -
Image ref -- Generate
cameleer-runtime-{env}-{app}:v{n}. -
Persist -- Save deployment record with
observed_status = BUILDING. -
Audit -- Log
APP_DEPLOYaction. -
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_SERVER_SECURITY_BOOTSTRAPTOKENAPI key for agent registration CAMELEER_EXPORT_TYPEHTTPCAMELEER_SERVER_RUNTIME_SERVERURLcameleer3-server internal URL CAMELEER_APPLICATION_IDApp slug CAMELEER_ENVIRONMENT_IDEnvironment slug CAMELEER_DISPLAY_NAME{tenant}-{env}-{app}d. Apply resource limits (
container-memory-limit,container-cpu-shares). e. Configure Traefik labels for inbound routing ifexposed_portis set:{app}.{env}.{tenant}.{domain}. f. Poll container health for up tohealth-check-timeoutseconds. g. Update deployment status toRUNNINGorFAILED. h. Update app'scurrent_deployment_idandprevious_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
- Agent starts with
CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKENenvironment variable (an API key generated by the SaaS platform, prefixed withcmk_). - Agent calls
POST /api/v1/agents/registeron the cameleer3-server with the API key as a Bearer token. - 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)
- Agent uses the access token for all subsequent API calls.
- On access token expiry, agent uses refresh token to obtain a new pair.
- 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 itstenantIdis 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
SecureRandomentropy, prefixed withcmk_and Base64url-encoded. - Only the SHA-256 hash is stored in the database (
key_hashcolumn, 64 hex chars). Thekey_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) orREVOKED(immediately invalidated,revoked_attimestamp 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 tenantaction-- Enum value fromAuditActionresource-- Identifier of the affected resource (e.g., app slug)environment-- Environment slug if applicableresult--SUCCESSor error indicatormetadata-- 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 |
Identity / OIDC:
| Variable | Default | Description |
|---|---|---|
CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT |
(empty) | Logto internal URL (Docker-internal) |
CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT |
(empty) | Logto public URL (browser-accessible) |
CAMELEER_SAAS_IDENTITY_M2MCLIENTID |
(empty) | M2M app client ID (from bootstrap) |
CAMELEER_SAAS_IDENTITY_M2MCLIENTSECRET |
(empty) | M2M app client secret (from bootstrap) |
CAMELEER_SAAS_IDENTITY_SPACLIENTID |
(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_SERVER_SECURITY_BOOTSTRAPTOKEN |
default-bootstrap-token |
Agent bootstrap token |
CAMELEER_JWT_SECRET |
cameleer-dev-jwt-secret-... |
HMAC secret for internal JWTs |
CAMELEER_SERVER_TENANT_ID |
default |
Tenant slug for data isolation |
CAMELEER_SERVER_SECURITY_OIDCISSUERURI |
(empty) | Logto issuer for M2M token validation |
CAMELEER_SERVER_SECURITY_OIDCAUDIENCE |
(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_SERVER_SECURITY_BOOTSTRAPTOKEN |
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 |