217 Commits

Author SHA1 Message Date
hsiegeln
af7abc3eac fix: add confirmation dialog before tenant context switch
All checks were successful
CI / build (push) Successful in 1m18s
CI / build (pull_request) Successful in 1m19s
CI / docker (pull_request) Has been skipped
CI / docker (push) Successful in 1m1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 19:51:59 +02:00
hsiegeln
ce1655bba6 fix: add error states to OrgResolver, DashboardPage, AdminTenantsPage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 19:51:28 +02:00
hsiegeln
798ec4850d fix: replace raw button with DS Button, add token copy-to-clipboard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 19:51:03 +02:00
hsiegeln
7d4126ad4e fix: unify tier color mapping, fix feature badge colors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 19:50:28 +02:00
hsiegeln
e3d9a3bd18 fix: sidebar active state, breadcrumbs, collapse, username fallback, lucide icons
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 19:49:42 +02:00
hsiegeln
7c7d574aa7 fix: replace hardcoded text-white with DS variables, fix label/value layout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 19:49:32 +02:00
hsiegeln
f9b1628e14 fix: add password visibility toggle and fix branding to 'Cameleer'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 19:49:14 +02:00
hsiegeln
e84e53f835 Add SaaS platform UX polish implementation plan (8 tasks)
Detailed step-by-step plan covering layout fixes (label/value collision,
DS variable adoption), header/navigation (sidebar active state,
breadcrumbs, collapse), error handling, DS component adoption, sign-in
improvements, and polish (tier colors, badges, confirmations).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 19:46:44 +02:00
hsiegeln
1133763520 Add SaaS platform UX polish design spec with audit findings
Playwright audit (22 screenshots) + source code audit covering all
platform pages. Spec defines 4 batches: layout fixes (label/value
collision, hardcoded colors), header/navigation (hide server controls,
sidebar active state), error handling & components (DS adoption,
copy-to-clipboard, error states), and polish (tier colors, badges).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 19:39:33 +02:00
hsiegeln
5c4a84e64c fix: platform label/value spacing and neutral license badge colors
Disabled features on the license page now show 'Not included' with a
neutral (auto) badge color instead of 'Disabled' in error red, which
looked like an actionable error rather than a plan tier indicator.

Label/value spacing on DashboardPage already used flex justify-between
correctly — no change needed there.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 18:46:09 +02:00
hsiegeln
538591989c docs: mark Plan 3 (runtime management port) as completed
All checks were successful
CI / build (push) Successful in 1m25s
CI / docker (push) Successful in 11s
Verified 2026-04-09: all runtime management fully ported to
cameleer3-server with enhancements beyond the original plan.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 08:58:15 +02:00
hsiegeln
63e6c6b1b5 docs: update CLAUDE.md with key classes, network topology, and runtime env vars
All checks were successful
CI / build (push) Successful in 1m18s
CI / docker (push) Successful in 19s
SonarQube Analysis / sonarqube (push) Successful in 1m12s
Add key class locations for Java backend and React frontend, document
cameleer-traefik network topology with DNS alias, add server runtime
env vars table, update deployment pipeline to 7-stage flow, add
database migration reference.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:11:03 +02:00
hsiegeln
4a7351d48e fix: add cameleer-traefik network so deployed apps can reach server
All checks were successful
CI / build (push) Successful in 53s
CI / docker (push) Successful in 10s
Deployed app containers are put on the cameleer-traefik network by the
orchestrator, but the server and Traefik were only on the compose-internal
network. This caused UnresolvedAddressException when apps tried to connect
to cameleer3-server:8081 for agent registration and SSE.

- Add cameleer-traefik network with fixed name (no compose project prefix)
- Attach server to cameleer-traefik with DNS alias "cameleer3-server"
- Attach Traefik to cameleer-traefik for routing to deployed apps
- Add dev overrides for Docker orchestration (socket, volumes, env vars)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:37:51 +02:00
hsiegeln
1d6c0cf451 docs: update documentation for Docker orchestration and env var rename
All checks were successful
CI / build (push) Successful in 1m0s
CI / docker (push) Successful in 18s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 22:09:19 +02:00
hsiegeln
cc792ae336 refactor: rename CAMELEER_EXPORT_ENDPOINT to CAMELEER_SERVER_URL in runtime-base
Some checks failed
CI / build (push) Successful in 1m7s
CI / docker (push) Has been cancelled
Standardize env var naming. The agent reads CAMELEER_SERVER_URL
to configure -Dcameleer.export.endpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:06:48 +02:00
hsiegeln
bb8c68a5ca feat: seed claim mapping rules in bootstrap after OIDC config
All checks were successful
CI / build (push) Successful in 53s
CI / docker (push) Successful in 14s
After configuring the server's OIDC settings, the bootstrap now seeds
claim mapping rules so Logto roles (server:admin, server:operator) map
to server RBAC roles (ADMIN, OPERATOR) automatically. Rules are
idempotent — existing mappings are checked by matchValue before creating.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:21:28 +02:00
hsiegeln
cfc7842e18 ci: retry build (transient npm network error)
All checks were successful
CI / build (push) Successful in 49s
CI / docker (push) Successful in 1m16s
2026-04-08 08:56:44 +02:00
hsiegeln
3fa062b92c docs: add architecture review spec and implementation plans
Some checks failed
CI / build (push) Failing after 25s
CI / docker (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 08:53:22 +02:00
hsiegeln
5938643632 feat: strip SaaS UI to vendor management dashboard
- Delete EnvironmentsPage, EnvironmentDetailPage, AppDetailPage
- Delete EnvironmentTree and DeploymentStatusBadge components
- Simplify DashboardPage to show tenant info, license status, server link
- Remove environment/app/deployment routes from router
- Remove environment section from sidebar, keep dashboard/license/platform
- Strip API hooks to tenant/license/me only
- Remove environment/app/deployment/observability types

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 00:03:01 +02:00
hsiegeln
de5821dddb feat: remove Docker socket dependency from SaaS layer
- Remove docker-java-core and docker-java-transport-zerodep from pom.xml
- Remove Docker socket mount, group_add, jardata volume from docker-compose.yml
- Remove CAMELEER_DOCKER_NETWORK and CLICKHOUSE_URL env vars from SaaS service
- Remove jardata volume definition

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 00:01:15 +02:00
hsiegeln
bad78e26a1 feat: add migration to drop migrated tables from SaaS database
- V010: drop deployments, apps, environments, api_keys tables
- Tables have been migrated to cameleer3-server

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 00:00:32 +02:00
hsiegeln
c254fbf723 feat: remove migrated environment/app/deployment/runtime code from SaaS
- Delete environment/, app/, deployment/, runtime/ packages (source + tests)
- Delete apikey/ package (tied to environments, table will be dropped)
- Strip AsyncConfig to empty @EnableAsync (no more deploymentExecutor bean)
- Remove EnvironmentService dependency from TenantService
- Remove environment/app isolation from TenantIsolationInterceptor
- Remove environment seeding from BootstrapDataSeeder
- Refactor ServerApiClient to use LogtoConfig instead of RuntimeConfig
- Add server-endpoint property to LogtoConfig (was in RuntimeConfig)
- Remove runtime config section and multipart config from application.yml

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:59:53 +02:00
hsiegeln
160a989f9f feat: remove all ClickHouse dependencies from SaaS layer
- Delete log/ package (ClickHouseConfig, ContainerLogService, LogController)
- Delete observability/ package (AgentStatusService, AgentStatusController)
- Remove clickhouse-jdbc dependency from pom.xml
- Remove cameleer.clickhouse config section from application.yml
- Delete associated test files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:56:21 +02:00
hsiegeln
30aaacb5b5 fix: correct protocol version header, disable SQL logging, document deployment pipeline
All checks were successful
CI / build (push) Successful in 1m9s
CI / docker (push) Successful in 1m43s
SonarQube Analysis / sonarqube (push) Successful in 1m20s
- ServerApiClient: use X-Cameleer-Protocol-Version: 1 (server expects "1", not "2")
- Disable Hibernate show-sql in dev profile (too verbose)
- CLAUDE.md: document deployment pipeline architecture, M2M server role in bootstrap,
  runtime-base image in CI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:58:27 +02:00
hsiegeln
617785baa7 fix: use full registry path for runtime base image default
All checks were successful
CI / build (push) Successful in 50s
CI / docker (push) Successful in 35s
The default cameleer-runtime-base:latest has no registry prefix, so
Docker can't pull it. Use the full gitea.siegeln.net/cameleer/ path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:23:26 +02:00
hsiegeln
f14affcc1e fix: verify Management API readiness before proceeding in bootstrap
All checks were successful
CI / build (push) Successful in 48s
CI / docker (push) Successful in 14s
Logto's OIDC endpoint may respond before the Management API is fully
initialized. Add a retry loop that checks GET /api/roles returns valid
JSON before making any API calls. Fixes intermittent bootstrap failure
on cold starts with 'Cannot index string with string "name"'.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:18:08 +02:00
hsiegeln
d6f488199c fix: rename async executor bean to avoid clash with DeploymentExecutor
All checks were successful
CI / build (push) Successful in 49s
CI / docker (push) Successful in 36s
The @Bean named 'deploymentExecutor' (ThreadPoolTaskExecutor) collided
with the @Service class DeploymentExecutor. Rename the bean to
'deploymentTaskExecutor'.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:06:10 +02:00
hsiegeln
dade9cefe2 fix: use sed instead of grep -P for BusyBox compatibility in CI
All checks were successful
CI / build (push) Successful in 1m7s
CI / docker (push) Successful in 10s
The Alpine-based docker builder uses BusyBox grep which doesn't
support Perl regex (-P). Switch to sed for extracting the agent
version from Maven metadata XML.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:57:43 +02:00
hsiegeln
3f0a27c96e fix: add lenient mock strictness to AgentStatusServiceTest
Some checks failed
CI / build (push) Successful in 1m46s
CI / docker (push) Failing after 45s
The serverApiClient.isAvailable() stubbing in setUp() is unused by
the observability test, causing UnnecessaryStubbingException in CI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:48:08 +02:00
hsiegeln
5d04a154f9 refactor: deployment infrastructure cleanup (4 fixes)
Some checks failed
CI / build (push) Failing after 46s
CI / docker (push) Has been skipped
1. Docker socket security: remove root group from Dockerfile, use
   group_add in docker-compose.yml for runtime-only socket access

2. M2M server communication: create ServerApiClient using Logto
   client_credentials grant with API resource scope. Add M2M server
   role in bootstrap. Replace hacky admin/admin login in
   AgentStatusService.

3. Async deployment: extract DeploymentExecutor as separate @Service
   so Spring's @Async proxy works (self-invocation bypasses proxy).
   Deploy now returns immediately, health check runs in background.

4. Bootstrap: M2M server role (cameleer-m2m-server) with server:admin
   scope, idempotent creation outside the M2M app creation block.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:08:37 +02:00
hsiegeln
8407d8b3c0 fix: deployment health check, container cleanup, and status reporting
Three fixes for the deployment pipeline:
1. Health check path: /health -> /cameleer/health (matches agent)
2. Container cleanup: stop AND remove old container before starting
   new one, plus orphan cleanup by container name to prevent conflicts
3. Container status: read health.status instead of state.status so
   waitForHealthy correctly detects the "healthy" state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:20:33 +02:00
hsiegeln
35276f66e9 fix: use compose-prefixed Docker network name for deployments
Docker Compose prefixes network names with the project name, so the
actual network is cameleer-saas_cameleer, not just cameleer. Pass
CAMELEER_DOCKER_NETWORK env var using COMPOSE_PROJECT_NAME.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:56:01 +02:00
hsiegeln
ea04eeb6dc fix: switch to zerodep Docker transport for Unix socket support
The httpclient5 transport needs junixsocket for Unix domain sockets.
Switch to docker-java-transport-zerodep which has built-in Unix socket
support with zero external dependencies.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:53:39 +02:00
hsiegeln
ca6e8ce35a fix: add cameleer user to root group for Docker socket access
The mounted /var/run/docker.sock is owned by root:root with rw-rw----
permissions. The cameleer user needs to be in the root group to
read/write the socket for building images and managing containers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:48:22 +02:00
hsiegeln
9c6ab77b72 fix: configure Docker client to use Unix socket
Default docker-java config resolved to localhost:2375 (TCP) inside the
container. Explicitly set docker host to unix:///var/run/docker.sock
which is volume-mounted from the host.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:40:57 +02:00
hsiegeln
a5c881a4d0 fix: skip bootstrap on subsequent restarts if already complete
Check for spaClientId and m2mClientSecret in the cached bootstrap
file. If both exist, exit immediately instead of re-running all
phases. Delete /data/logto-bootstrap.json to force a re-run.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:36:43 +02:00
hsiegeln
00a3f2fd3f feat: runtime base image CI, bootstrap token, and deploy plumbing
Add CI step to build cameleer-runtime-base image by downloading the
agent shaded JAR from Gitea Maven registry and pushing the image.
Wire CAMELEER_AUTH_TOKEN from docker-compose into RuntimeConfig so
deployed containers authenticate with cameleer3-server. Add agent.jar
to gitignore for local builds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:32:42 +02:00
hsiegeln
1a0f1e07be fix: JAR upload — increase multipart limit and fix storage permissions
Spring Boot defaults to 1MB max file size which rejected all JAR
uploads. Set to 200MB to match the configured max-jar-size. Also
create /data/jars with cameleer user ownership in the Dockerfile
so the non-root process can write uploaded JARs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:10:35 +02:00
hsiegeln
8febdba533 feat: per-app resource limits, auto-slug, and polished create dialogs
Add per-app memory limit and CPU shares (stored on AppEntity, used by
DeploymentService with fallback to global defaults). JAR upload is now
optional at creation time. Both create modals show the computed slug in
the dialog title and use consistent Cancel-left/Action-right button
layout with inline styles to avoid Modal CSS conflicts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:53:57 +02:00
hsiegeln
3d41d4a3da feat: 4-role model — owner, operator, viewer + vendor-seed
All checks were successful
CI / build (push) Successful in 57s
CI / docker (push) Successful in 47s
Redesign the role model from 3 roles (platform-admin, admin, member)
to 4 clear personas:

- owner (org role): full tenant control — billing, team, apps, deploy
- operator (org role): app lifecycle + observability, no billing/team
- viewer (org role): read-only observability
- saas-vendor (global role, hosted only): cross-tenant platform admin

Bootstrap changes:
- Rename org roles: admin→owner, member→operator, add viewer
- Remove platform-admin global role (moved to vendor-seed)
- admin user gets owner role, camel user gets viewer role
- Custom JWT maps: owner→server:admin, operator→server:operator,
  viewer→server:viewer, saas-vendor→server:admin

New docker/vendor-seed.sh for hosted SaaS environments only.
Remove sidebar user/logout link (TopBar handles logout).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:49:16 +02:00
hsiegeln
c96faa4f3f fix: display username in UI, fix license limits key mismatch
All checks were successful
CI / build (push) Successful in 1m0s
CI / docker (push) Successful in 55s
- Read user profile from Logto ID token in OrgResolver, store in
  Zustand org store, display in sidebar footer and TopBar avatar
- Fix license limits showing "—" by aligning frontend LIMIT_LABELS
  keys with backend snake_case convention (max_agents, retention_days,
  max_environments)
- Bump @cameleer/design-system to v0.1.38 (font-size floor)
- Add dev volume mount for local UI hot-reload without image rebuild

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:20:40 +02:00
hsiegeln
bab9714efc docs: document Custom JWT, server OIDC role paths, and bootstrap Phase 7b
All checks were successful
CI / build (push) Successful in 1m40s
CI / docker (push) Successful in 19s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:55:02 +02:00
hsiegeln
67b35a25d6 feat: configure Logto Custom JWT and update server OIDC rolesClaim
All checks were successful
CI / build (push) Successful in 1m17s
CI / docker (push) Successful in 14s
- Change rolesClaim from "scope" to "roles" to match the custom claim
  injected by the Logto Custom JWT script
- Add Phase 7b: configure Logto Custom JWT for access tokens that maps
  org roles (admin→server:admin, member→server:viewer) and global roles
  (platform-admin→server:admin) into a standard "roles" claim
- Add additionalScopes field to OIDC config

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:17:04 +02:00
hsiegeln
b7aed1afb1 fix: explicitly set service=logto on default tenant router
All checks were successful
CI / build (push) Successful in 48s
CI / docker (push) Successful in 7s
SonarQube Analysis / sonarqube (push) Successful in 1m16s
Traefik couldn't auto-link the logto router when two services
(logto, logto-console) exist on the same container. This broke
ALL default tenant routing (sign-in, OIDC, API).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:56:10 +02:00
hsiegeln
6f57e19c2a fix: add CORS middleware for admin console origin on default tenant
All checks were successful
CI / build (push) Successful in 47s
CI / docker (push) Successful in 9s
The admin console (port 3002) calls the Management API on the
default tenant (port 443). Add Traefik CORS headers to allow
cross-origin requests from the admin console origin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:54:09 +02:00
hsiegeln
c32a606a91 fix: add X-Forwarded-Proto to admin-tenant bootstrap calls only
All checks were successful
CI / build (push) Successful in 48s
CI / docker (push) Successful in 8s
ADMIN_ENDPOINT is now HTTPS so admin-tenant calls need the
forwarded proto header. Default-tenant calls stay unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:48:11 +02:00
hsiegeln
e0e65bb62c feat: HTTPS admin console via Traefik with NODE_TLS_REJECT_UNAUTHORIZED
All checks were successful
CI / build (push) Successful in 47s
CI / docker (push) Successful in 8s
ADMIN_ENDPOINT set to HTTPS so OIDC issuer matches browser URL.
NODE_TLS_REJECT_UNAUTHORIZED=0 lets Logto's internal ky-based
OIDC self-discovery accept the self-signed cert through Traefik.
Remove in production with real certs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:44:33 +02:00
hsiegeln
0e5016cdcc revert: restore to working state (774db7b)
All checks were successful
CI / build (push) Successful in 46s
CI / docker (push) Successful in 8s
Admin console HTTPS via Traefik conflicts with Logto's
ADMIN_ENDPOINT self-discovery. Parking this for now.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:26:33 +02:00
hsiegeln
49fda95f15 fix: use localhost for ADMIN_ENDPOINT, rely on TRUST_PROXY_HEADER
All checks were successful
CI / build (push) Successful in 46s
CI / docker (push) Successful in 7s
ADMIN_ENDPOINT=http://localhost:3002 for Logto self-calls.
TRUST_PROXY_HEADER makes Logto use X-Forwarded-Proto from Traefik
to generate HTTPS URLs for browser-facing OIDC flows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:25:18 +02:00
hsiegeln
ca40536fd3 fix: add Docker network alias for Logto self-discovery with TLS
All checks were successful
CI / build (push) Successful in 48s
CI / docker (push) Successful in 9s
Add PUBLIC_HOST as network alias on the logto container so its
internal ADMIN_ENDPOINT calls (http://PUBLIC_HOST:3002) resolve
inside Docker directly, bypassing Traefik. Browser traffic goes
through Traefik on host port 3002 with TLS termination.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:22:51 +02:00
hsiegeln
fdca4911ae fix: admin console via Traefik port 3002 without forced TLS
All checks were successful
CI / build (push) Successful in 47s
CI / docker (push) Successful in 9s
Remove tls=true from the logto-console router so the entrypoint
accepts plain HTTP. Logto's internal self-calls via ADMIN_ENDPOINT
use HTTP and pass through Traefik transparently. Browsers can
access via HTTP on port 3002.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:21:12 +02:00
hsiegeln
6497b59c55 feat: HTTPS admin console on port 3443 via Traefik
All checks were successful
CI / build (push) Successful in 48s
CI / docker (push) Successful in 7s
Use separate port 3443 for TLS-terminated admin console access.
Port 3002 stays directly mapped from logto in dev for Logto's
internal OIDC self-discovery via ADMIN_ENDPOINT.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:18:16 +02:00
hsiegeln
04a2b41326 feat: expose admin console on HTTPS via Traefik port 3002
All checks were successful
CI / build (push) Successful in 48s
CI / docker (push) Successful in 9s
Traefik-only change: new entrypoint + router for TLS termination.
No changes to Logto ADMIN_ENDPOINT or bootstrap script.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:09:42 +02:00
hsiegeln
774db7ba53 revert: restore to last working state (b3ac8a6)
All checks were successful
CI / build (push) Successful in 47s
CI / docker (push) Successful in 9s
Revert all Traefik port 3002 and ADMIN_ENDPOINT changes that broke
bootstrap. Admin console HTTPS access needs a different approach.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:07:17 +02:00
hsiegeln
a2119b8bfd fix: remove Host header from admin tenant bootstrap calls
All checks were successful
CI / build (push) Successful in 48s
CI / docker (push) Successful in 9s
ADMIN_ENDPOINT is http://localhost:3002, but bootstrap sent
Host: PUBLIC_HOST:3002 which didn't match. Let curl use the
default Host from LOGTO_ADMIN_ENDPOINT (logto:3002) which Logto
resolves to the admin tenant internally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:05:33 +02:00
hsiegeln
1dfa4d9f32 fix: use localhost for Logto ADMIN_ENDPOINT
All checks were successful
CI / build (push) Successful in 47s
CI / docker (push) Successful in 7s
Logto calls ADMIN_ENDPOINT internally for OIDC discovery. Using
PUBLIC_HOST resolved to the host machine where Traefik now owns
port 3002, causing a routing loop. localhost resolves inside the
container directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:02:51 +02:00
hsiegeln
f276953b03 fix: revert ADMIN_ENDPOINT to HTTP, remove X-Forwarded-Proto
All checks were successful
CI / build (push) Successful in 50s
CI / docker (push) Successful in 25s
Internal Docker traffic is HTTP. Traefik handles TLS termination
for external access. TRUST_PROXY_HEADER lets Logto detect HTTPS
from Traefik's forwarded headers automatically.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:59:49 +02:00
hsiegeln
c8ec1da328 fix: only use X-Forwarded-Proto on admin tenant calls (port 3002)
All checks were successful
CI / build (push) Successful in 48s
CI / docker (push) Successful in 9s
Default tenant (port 3001) works without it — adding it caused
Internal server error. Only the admin tenant needs it because
ADMIN_ENDPOINT changed to HTTPS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:58:16 +02:00
hsiegeln
a3af667f76 debug: log api_get response for bootstrap troubleshooting
All checks were successful
CI / build (push) Successful in 48s
CI / docker (push) Successful in 7s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:56:08 +02:00
hsiegeln
251d8eb8e1 fix: add X-Forwarded-Proto to all bootstrap API helpers
All checks were successful
CI / build (push) Successful in 48s
CI / docker (push) Successful in 9s
All Logto endpoints are configured with HTTPS but bootstrap calls
internal HTTP. Every curl call needs the forwarded proto header.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:54:51 +02:00
hsiegeln
5f560e9f33 fix: add X-Forwarded-Proto to bootstrap admin endpoint calls
All checks were successful
CI / build (push) Successful in 46s
CI / docker (push) Successful in 9s
Logto's ADMIN_ENDPOINT is now HTTPS but bootstrap calls the internal
HTTP endpoint directly. TRUST_PROXY_HEADER needs X-Forwarded-Proto
to resolve the correct scheme.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:53:29 +02:00
hsiegeln
73388e15e2 feat: expose Logto admin console on HTTPS via Traefik port 3002
All checks were successful
CI / build (push) Successful in 47s
CI / docker (push) Successful in 9s
Add admin-console entrypoint to Traefik with TLS termination.
Route port 3002 through Traefik to logto:3002. Update Logto
ADMIN_ENDPOINT to use HTTPS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:49:39 +02:00
hsiegeln
b3ac8a6bcc fix: set admin tenant sign-in mode to SignIn after user creation
All checks were successful
CI / build (push) Successful in 47s
CI / docker (push) Successful in 8s
Admin tenant defaults to Register mode (onboarding flow). Since we
create the admin user via API, we need to switch to SignIn mode so
the custom sign-in UI can authenticate against the admin console.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:46:36 +02:00
hsiegeln
c354d2e74f fix: assign 'user' base role for admin console access
All checks were successful
CI / build (push) Successful in 58s
CI / docker (push) Successful in 11s
The admin tenant requires both the 'user' role (base access) and
'default:admin' role (Management API). Missing the 'user' role
causes a 403 at the identification step.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:43:07 +02:00
hsiegeln
9dbdda62ce fix: use m-admin token for admin tenant console user creation
All checks were successful
CI / build (push) Successful in 47s
CI / docker (push) Successful in 16s
The m-default token has audience https://default.logto.app/api which
is rejected by port 3002's admin tenant API. Use m-admin client with
audience https://admin.logto.app/api instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:37:51 +02:00
hsiegeln
65d2c7c764 debug: log admin tenant API response for bootstrap troubleshooting
All checks were successful
CI / build (push) Successful in 48s
CI / docker (push) Successful in 1m3s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:35:26 +02:00
hsiegeln
8adf5daab9 chore: bump @cameleer/design-system to 0.1.37
Some checks failed
CI / build (push) Successful in 49s
CI / docker (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:34:35 +02:00
hsiegeln
bc42fa7172 fix: create admin console user on Logto admin tenant (port 3002)
All checks were successful
CI / build (push) Successful in 58s
CI / docker (push) Successful in 9s
The admin console runs on a separate tenant with its own user store.
Previous approach tried to assign a non-existent 'admin:admin' role
on the default tenant. Now creates the user on the admin tenant via
port 3002, assigns 'default:admin' role for Management API access,
and adds to t-default organization.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:28:40 +02:00
hsiegeln
e478427a29 fix: restore registry-based Docker layer caching in CI
All checks were successful
CI / build (push) Successful in 58s
CI / docker (push) Successful in 26s
Replace --no-cache with --cache-from/--cache-to registry caching,
matching the cameleer3-server CI pattern. The ephemeral CI runner
destroys BuildKit local cache after each job, so only registry
caching persists between runs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:10:55 +02:00
hsiegeln
2f7d4bd71c feat: use cameleer3-logo.svg from design-system v0.1.36 everywhere
All checks were successful
CI / build (push) Successful in 1m7s
CI / docker (push) Successful in 3m34s
- Sidebar, sign-in page, and favicons all use the single SVG
- Postinstall copies SVG for SaaS HTML favicon (gitignored)
- Sign-in favicon committed (baked into Logto Docker image)
- Remove old PNG favicon references

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:03:18 +02:00
hsiegeln
93a2f7d900 fix: skip postinstall favicon copy when public/ absent (Docker build)
All checks were successful
CI / build (push) Successful in 59s
CI / docker (push) Successful in 2m50s
The Dockerfile copies package.json before ui/ contents, so public/
doesn't exist during npm ci. Skip the copy gracefully.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:50:44 +02:00
hsiegeln
c9ecebdd92 chore: bump @cameleer/design-system to 0.1.34
Some checks failed
CI / build (push) Successful in 58s
CI / docker (push) Failing after 17s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:46:19 +02:00
hsiegeln
2e87667734 refactor: import brand icons directly from design-system package
Some checks failed
CI / build (push) Failing after 20s
CI / docker (push) Has been skipped
- Sidebar and sign-in logos use Vite import from @cameleer/design-system
- HTML favicons copied by postinstall script (gitignored)
- Remove manually copied PNGs from repo
- Clean up SecurityConfig permitAll (bundled assets under /_app/**)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:39:29 +02:00
hsiegeln
1ca0e960fb fix: update brand icons to transparent-background versions from v0.1.33
All checks were successful
CI / build (push) Successful in 48s
CI / docker (push) Successful in 2m52s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:35:42 +02:00
hsiegeln
3a33324b2a fix: permit cameleer-logo-48.png without auth
Some checks failed
CI / build (push) Successful in 48s
CI / docker (push) Has been cancelled
Browser img tags don't send Bearer tokens, so the sidebar logo
needs to be in the permitAll list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:32:16 +02:00
hsiegeln
3ca13b6b88 perf: add BuildKit cache mounts for Maven and npm in Docker builds
Some checks failed
CI / build (push) Successful in 49s
CI / docker (push) Has been cancelled
Maven .m2 and npm caches persist across --no-cache builds, avoiding
full dependency re-downloads on every CI run.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:29:14 +02:00
hsiegeln
ea3723958e fix: bootstrap file permission denied, use PNG favicon
All checks were successful
CI / build (push) Successful in 47s
CI / docker (push) Successful in 2m45s
- Change chmod 600 to 644 on bootstrap JSON (cameleer user needs read)
- Use PNG favicon instead of SVG (currentColor invisible in browser tab)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:25:15 +02:00
hsiegeln
d8b9ca6cfe chore: bump @cameleer/design-system to 0.1.33
All checks were successful
CI / build (push) Successful in 1m13s
CI / docker (push) Successful in 3m11s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:17:55 +02:00
hsiegeln
29daf51ee3 feat: replace icons with brand assets from design-system v0.1.32
All checks were successful
CI / build (push) Successful in 1m29s
CI / docker (push) Successful in 3m22s
- Replace favicon SVG with official camel-logo.svg from design-system
- Add PNG favicons (32px, 192px) with proper link tags in index.html
- Replace sidebar logo with 48px brand icon (cameleer-logo-48.png)
- Replace sign-in page logo with 48px brand icon
- Permit favicon PNGs in SecurityConfig

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:09:13 +02:00
hsiegeln
3dedfb1eb7 chore: bump @cameleer/design-system to 0.1.32
All checks were successful
CI / build (push) Successful in 1m0s
CI / docker (push) Successful in 2m49s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:00:57 +02:00
hsiegeln
f81cd740b7 fix: security hardening — remove dead routes, add JWT audience validation
All checks were successful
CI / build (push) Successful in 48s
CI / docker (push) Successful in 2m49s
- Remove broken observe/dashboard Traefik routes (server accessed via /server only)
- Remove unused acme volume
- Add JWT audience claim validation (https://api.cameleer.local) in SecurityConfig
- Secure bootstrap output file with chmod 600
- Add dev-only comments on TLS_SKIP_VERIFY and credential logging

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 19:15:03 +02:00
hsiegeln
7d6e78afa3 fix: add /server/login?local to Traditional app post-logout redirect URIs
All checks were successful
CI / build (push) Successful in 54s
CI / docker (push) Successful in 2m48s
The server-ui logout redirects to /server/login?local but this URI was
not whitelisted in Logto, causing the post-logout redirect to fail.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:47:12 +02:00
hsiegeln
edbb66b056 docs: update architecture for custom sign-in UI and CI pipeline
All checks were successful
CI / build (push) Successful in 1m15s
CI / docker (push) Successful in 2m52s
- CLAUDE.md: add custom sign-in UI section, update routing table,
  document auto-redirect, CI-built images, no local builds, dev
  override without volume mounts
- Design spec: reflect final implementation — custom Logto image,
  no CUSTOM_UI_PATH, no init containers, bundled favicon

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:29:37 +02:00
hsiegeln
194004f8f9 fix: remove local bind mounts from dev override
All checks were successful
CI / build (push) Successful in 52s
CI / docker (push) Successful in 2m46s
The dev override was mounting local ui/dist and target/*.jar over
the image contents, serving stale local builds instead of the
CI-built artifacts. Remove these mounts — the image has everything.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:18:39 +02:00
hsiegeln
82163144e7 ci: use --no-cache for Docker builds, remove registry cache
All checks were successful
CI / build (push) Successful in 57s
CI / docker (push) Successful in 2m49s
Local registry makes cache overhead unnecessary. Ensures fresh
builds with no stale layer reuse.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:59:29 +02:00
hsiegeln
3fcbc431fb fix: restore multi-stage Dockerfiles, use cameleer-docker-builder
All checks were successful
CI / build (push) Successful in 1m8s
CI / docker (push) Successful in 39s
Follow cameleer3-server CI pattern: docker job uses
cameleer-docker-builder:1 (has Docker CLI), Dockerfiles contain
multi-stage builds (self-contained, no external toolchain needed).

- Dockerfile: restore frontend + maven + runtime stages
- ui/sign-in/Dockerfile: add node build stage + Logto base
- ci.yml: docker job reverts to cameleer-docker-builder:1,
  passes REGISTRY_TOKEN as build-arg, adds build cache

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:51:59 +02:00
hsiegeln
ad97a552f6 refactor: no builds in Dockerfiles, CI builds all artifacts
Some checks failed
CI / build (push) Successful in 59s
CI / docker (push) Failing after 11s
Dockerfiles now only COPY pre-built artifacts:
- Dockerfile (SaaS): just COPY target/*.jar, no multi-stage build
- ui/sign-in/Dockerfile (Logto): just FROM logto + COPY dist/
- Removed docker/logto.Dockerfile (had node build stage)

CI pipeline builds everything:
- docker job: builds frontend, JAR, sign-in UI, then packages
  into images using the simple Dockerfiles
- Uses cameleer-build:1 (has node + maven + docker)
- build job: also builds sign-in UI for testing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:39:19 +02:00
hsiegeln
983b861d20 fix: bundle favicon.svg in sign-in UI instead of cross-service fetch
All checks were successful
CI / build (push) Successful in 40s
CI / docker (push) Successful in 22s
The sign-in page is served by Logto, not the SaaS app. Referencing
/platform/favicon.svg required SecurityConfig permitAll and cross-service
routing. Instead, bundle favicon.svg directly in the sign-in UI dist
so Logto serves it at /favicon.svg.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:35:23 +02:00
hsiegeln
2375cb9111 ci: build and push custom Logto image in CI pipeline
All checks were successful
CI / build (push) Successful in 39s
CI / docker (push) Successful in 41s
- Add "Build and push Logto image" step to docker job
- Remove build: directive from logto service in docker-compose
- docker-compose now only pulls pre-built images, no local builds

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:17:55 +02:00
hsiegeln
972f9b5f38 feat: custom Logto image + auto-redirect to sign-in
All checks were successful
CI / build (push) Successful in 39s
CI / docker (push) Successful in 40s
- Add docker/logto.Dockerfile: builds custom Logto image with sign-in
  UI baked into /etc/logto/packages/experience/dist/
- Remove sign-in-ui init container, signinui volume, CUSTOM_UI_PATH
  (CUSTOM_UI_PATH is Logto Cloud only, not available in OSS)
- Remove sign-in build stage from SaaS Dockerfile (now in logto.Dockerfile)
- Remove docker/saas-entrypoint.sh (no longer needed)
- LoginPage auto-redirects to Logto OIDC on mount instead of showing
  "Sign in with Logto" button — seamless sign-in experience

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:12:11 +02:00
hsiegeln
9013740b83 fix: mount custom sign-in UI over Logto experience dist
All checks were successful
CI / build (push) Successful in 39s
CI / docker (push) Successful in 33s
CUSTOM_UI_PATH is a Logto Cloud feature, not available in OSS.
The correct approach for self-hosted Logto is to volume-mount
over /etc/logto/packages/experience/dist/.

- Use init container (sign-in-ui) to copy dist to shared volume
  as root (fixes permission denied with cameleer user)
- Logto mounts signinui volume at experience/dist path
- Logto depends on sign-in-ui init container completion
- Remove saas-entrypoint.sh approach (no longer needed)
- Revert Dockerfile entrypoint to direct java -jar
- Permit /favicon.svg in SecurityConfig for sign-in page logo

Tested: full OIDC flow works end-to-end via Playwright.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:24:33 +02:00
hsiegeln
df220bc5f3 feat: custom Logto sign-in UI with Cameleer branding
All checks were successful
CI / build (push) Successful in 40s
CI / docker (push) Successful in 50s
Replace Logto's default sign-in page with a custom React SPA that
matches the cameleer3-server login page using @cameleer/design-system.

- New Vite+React app at ui/sign-in/ with Experience API integration
- 4-step auth flow: init → verify password → identify → submit
- Design-system components: Card, Input, Button, FormField, Alert
- Same witty random subtitles as cameleer3-server LoginPage
- Dockerfile: add sign-in-frontend build stage, copy dist to image
- docker-compose: CUSTOM_UI_PATH on Logto, shared signinui volume
- SaaS entrypoint copies sign-in dist to shared volume on startup
- Add .gitattributes for LF line endings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:43:22 +02:00
hsiegeln
b1c2832245 docs: update architecture with bootstrap phases, scopes, branding
All checks were successful
CI / build (push) Successful in 40s
CI / docker (push) Successful in 11s
- CLAUDE.md: add bootstrap phase listing, document 13 scopes (10
  platform + 3 server), server role mapping via scope claim, admin
  console access, sign-in branding
- Mark server-role-mapping and logto-admin-branding specs as implemented

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:46:39 +02:00
hsiegeln
51cdca95c4 feat: server role mapping, Logto admin access, sign-in branding
Some checks failed
CI / build (push) Successful in 38s
CI / docker (push) Has been cancelled
- Add server:admin/operator/viewer scopes to bootstrap and org roles
- Grant SaaS admin Logto console access via admin:admin role
- Configure sign-in experience with Cameleer branding (colors + logos)
- Add rolesClaim and audience to server OIDC config
- Add server scopes to PublicConfigController for token inclusion
- Permit logo SVGs in SecurityConfig (fix 401 on /platform/logo.svg)
- Add cameleer3 logo SVGs (light + dark) to ui/public/

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:45:19 +02:00
hsiegeln
edd1d45a1a docs: Logto admin credentials + branding design spec
All checks were successful
CI / build (push) Successful in 47s
CI / docker (push) Successful in 8s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:24:52 +02:00
hsiegeln
574c719148 docs: server role mapping design spec
All checks were successful
CI / build (push) Successful in 1m6s
CI / docker (push) Successful in 10s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:05:12 +02:00
hsiegeln
0082576063 docs: update architecture docs for single-domain /platform routing
All checks were successful
CI / build (push) Successful in 38s
CI / docker (push) Successful in 10s
Reflects current state: path-based routing, SaaS at /platform,
Logto catch-all, TLS init container, server integration env vars,
custom JwtDecoder for ES384, skip consent for SSO.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 09:43:14 +02:00
hsiegeln
5a8d38a946 fix: enable skip consent on Traditional app for first-party SSO
All checks were successful
CI / build (push) Successful in 1m3s
CI / docker (push) Successful in 8s
SonarQube Analysis / sonarqube (push) Successful in 1m24s
Without this, Logto returns consent_required when the server tries
SSO because the scopes were never explicitly granted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 01:30:25 +02:00
hsiegeln
d74aafc7b3 fix: update Traditional app redirect URIs for Traefik routing
All checks were successful
CI / build (push) Successful in 46s
CI / docker (push) Successful in 8s
Server OIDC callback is at /oidc/callback (without /server/ prefix due
to strip-prefix). Register both variants until server reads
X-Forwarded-Prefix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 01:10:40 +02:00
hsiegeln
329f5b80df feat: add CORS allowed origins for server behind reverse proxy
All checks were successful
CI / build (push) Successful in 45s
CI / docker (push) Successful in 7s
Browser sends Origin header on fetch calls even same-origin. Server
needs the public host in its CORS allowlist. Derived from PUBLIC_HOST.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:40:00 +02:00
hsiegeln
e16094d83f feat: enable OIDC TLS skip-verify for server in Docker dev
All checks were successful
CI / build (push) Successful in 46s
CI / docker (push) Successful in 7s
Self-signed certs cause PKIX errors when the server fetches OIDC
discovery. CAMELEER_OIDC_TLS_SKIP_VERIFY=true disables cert
verification for OIDC calls only (server-team feature, pending build).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:24:22 +02:00
hsiegeln
730ead38a0 fix: add strip-prefix back for server-ui asset serving
All checks were successful
CI / build (push) Successful in 46s
CI / docker (push) Successful in 6s
Nginx needs to see /assets/... not /server/assets/... to find the files.
Strip-prefix + BASE_PATH=/server now works correctly with the fixed image.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:59:16 +02:00
hsiegeln
5ded08cace fix: remove patched entrypoint, use server team's fixed image
Some checks failed
CI / build (push) Successful in 50s
CI / docker (push) Has been cancelled
Server team fixed the BASE_PATH sed ordering bug. Remove our entrypoint
override and let the image's own entrypoint handle it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:58:20 +02:00
hsiegeln
5981a3db71 fix: patch server-ui entrypoint to fix sed ordering bug
All checks were successful
CI / build (push) Successful in 1m19s
CI / docker (push) Successful in 9s
The server-ui's entrypoint inserts <base href> THEN rewrites all
href="/" — including the just-inserted base tag, causing doubling.
Patched entrypoint rewrites asset paths first, then inserts <base>.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:53:03 +02:00
hsiegeln
4c6625efaa fix: restore BASE_PATH=/server, remove strip-prefix
Server-ui needs BASE_PATH for React Router basename. Without strip-prefix,
no X-Forwarded-Prefix doubling. Server-ui handles full /server/ path itself.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:40:11 +02:00
hsiegeln
9bd8ddfad5 fix: remove BASE_PATH, let X-Forwarded-Prefix from strip-prefix handle it
All checks were successful
CI / build (push) Successful in 41s
CI / docker (push) Successful in 6s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:38:20 +02:00
hsiegeln
a700d3a8ed fix: add strip-prefix back to server-ui route
All checks were successful
CI / build (push) Successful in 41s
CI / docker (push) Successful in 6s
Server-ui injects BASE_PATH=/server/ into <base href>. Without strip-prefix,
Traefik forwards /server/ path AND server-ui adds /server/ again = double prefix.
Strip /server before forwarding so server-ui sees / and produces correct <base href="/server/">.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:33:57 +02:00
hsiegeln
1b2c962261 fix: root → /platform/ redirect via Traefik file config
All checks were successful
CI / build (push) Successful in 40s
CI / docker (push) Successful in 5s
Docker-compose label escaping mangles regex patterns. Use a separate
Traefik dynamic config file instead — clean regex, proper 302 redirect.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:30:38 +02:00
hsiegeln
43967dcf2e fix: add /platform prefix to signIn/signOut redirect URIs
All checks were successful
CI / build (push) Successful in 42s
CI / docker (push) Successful in 41s
LoginPage and useAuth used window.location.origin without the /platform
base path, causing redirect_uri mismatch with Logto's registered URIs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:25:53 +02:00
hsiegeln
5a847e075c fix: remove root redirect, /platform/ is the entry point
All checks were successful
CI / build (push) Successful in 42s
CI / docker (push) Successful in 5s
Server-side path rewrite breaks React Router (browser URL stays at /
but basename is /platform). The SPA entry point is /platform/ — users
bookmark that. Root / goes to Logto catch-all which is correct for
direct OIDC flows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:21:53 +02:00
hsiegeln
bbace4698f fix: use replacepathregex (path-only) instead of redirectregex (full URL)
All checks were successful
CI / build (push) Successful in 41s
CI / docker (push) Successful in 5s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:19:28 +02:00
hsiegeln
e5836bb9d5 fix: Go regexp replacement syntax ($1 not ${1})
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:18:04 +02:00
hsiegeln
8a59c23266 fix: capture group in redirectregex for root redirect
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:17:14 +02:00
hsiegeln
4f4d9777ce fix: use replacepath middleware for root → /platform/ rewrite
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:16:26 +02:00
hsiegeln
e3921576e5 fix: add explicit priority and broader regex for root redirect
All checks were successful
CI / build (push) Successful in 42s
CI / docker (push) Successful in 5s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:15:02 +02:00
hsiegeln
d32a03bb7b fix: redirect root / to /platform/ for better UX
All checks were successful
CI / build (push) Successful in 42s
CI / docker (push) Successful in 6s
Users hitting the root URL now get redirected to the SaaS app instead
of seeing Logto's unknown-session page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:13:20 +02:00
hsiegeln
4997f7a6a9 feat: move SaaS app to /platform base path, Logto becomes catch-all
All checks were successful
CI / build (push) Successful in 48s
CI / docker (push) Successful in 41s
Eliminates all Logto path enumeration in Traefik. Routing is now:
- /platform/* → cameleer-saas (SPA + API)
- /server/* → server-ui
- /* (catch-all) → Logto (sign-in, OIDC, assets, everything)

Spring context-path handles backend prefix transparently. No changes
needed in controllers, SecurityConfig, or interceptors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:06:41 +02:00
hsiegeln
4ab72425ae fix: route Logto experience API paths (/api/interaction, /api/experience)
All checks were successful
CI / build (push) Successful in 41s
CI / docker (push) Successful in 6s
Logto's sign-in form calls /api/interaction/* and /api/experience/* to
submit credentials, but these were routed to cameleer-saas by the /api
catch-all. Added explicit Logto API paths with higher Traefik priority.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 22:52:12 +02:00
hsiegeln
191be6ab40 fix: route Logto sign-in experience paths through Traefik
All checks were successful
CI / build (push) Successful in 40s
CI / docker (push) Successful in 6s
Logto uses root-level paths for its sign-in UI (/sign-in, /register,
/consent, /single-sign-on, /social, /unknown-session) that were falling
through to the SPA catch-all and getting 401.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 22:47:39 +02:00
hsiegeln
bc384a6d2d fix: permit /_app/** static assets in SecurityConfig
All checks were successful
CI / build (push) Successful in 40s
CI / docker (push) Successful in 31s
SPA assets moved from /assets/ to /_app/ for single-domain routing,
but SecurityConfig still permitted the old path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 22:40:41 +02:00
hsiegeln
28a90f5fc7 fix: add BASE_PATH=/server/ to server-ui, remove strip prefix
All checks were successful
CI / build (push) Successful in 41s
CI / docker (push) Successful in 6s
Server-ui now handles base path natively via BASE_PATH env var.
Traefik forwards full path without stripping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 22:38:37 +02:00
hsiegeln
9568e7f127 feat: single-domain path-based routing (no subdomains required)
All checks were successful
CI / build (push) Successful in 46s
CI / docker (push) Successful in 41s
Move SPA assets from /assets/ to /_app/ (Vite assetsDir config) so
Traefik can route /assets/* to Logto without conflict. All services
on one hostname with path-based routing:

- /oidc/*, /interaction/*, /assets/* → Logto
- /server/* → server-ui (prefix stripped)
- /api/* → cameleer-saas
- /* (catch-all) → cameleer-saas SPA

Customer needs only 1 DNS record. Server gets OIDC_JWK_SET_URI for
Docker-internal JWK fetch (standard Spring split config).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 21:10:03 +02:00
hsiegeln
9a8881c4cc docs: single-domain routing design spec
Path-based routing on one hostname. SPA assets move to /_app/,
Logto gets /assets/ + /oidc/ + /interaction/. Server-ui at /server/.
Includes requirements for server team (split JWK/issuer, BASE_PATH).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 20:46:00 +02:00
hsiegeln
e167d5475e feat: production-ready TLS with self-signed cert init container
All checks were successful
CI / build (push) Successful in 39s
CI / docker (push) Successful in 40s
Standard OIDC architecture: subdomain routing (auth.HOST, server.HOST),
TLS via Traefik, self-signed cert auto-generated on first boot.

- Add traefik-certs init container (generates wildcard self-signed cert)
- Enable TLS on all Traefik routers (websecure entrypoint)
- HTTP→HTTPS redirect in traefik.yml
- Host-based routing for all services (no more path conflicts)
- PUBLIC_PROTOCOL env var (https default, configurable)
- Protocol-aware redirect URIs in bootstrap
- Protocol-aware UI fallbacks

Customer bootstrap: set PUBLIC_HOST + DNS records + docker compose up.
For production TLS, configure Traefik ACME (Let's Encrypt).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 18:14:25 +02:00
hsiegeln
3694d4a7d6 fix: use server.localhost subdomain for server-ui (same /assets conflict)
All checks were successful
CI / build (push) Successful in 37s
CI / docker (push) Successful in 39s
Server UI assets also use absolute /assets/* paths that conflict with
the SPA catch-all. Same fix as Logto: Host-based routing at
server.localhost gives it its own namespace.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:48:11 +02:00
hsiegeln
0472528cd6 fix: move Logto to auth.localhost subdomain to avoid /assets path conflict
All checks were successful
CI / build (push) Successful in 39s
CI / docker (push) Successful in 38s
Logto's login page references /assets/* which conflicts with the SPA's
assets at the same path. Using Host-based routing (auth.localhost) gives
Logto its own namespace - all paths on that subdomain go to Logto,
eliminating the conflict. *.localhost resolves to 127.0.0.1 and is a
secure context.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:42:17 +02:00
hsiegeln
c58ca34b2c fix: route all public traffic through Traefik at localhost:80
All checks were successful
CI / build (push) Successful in 38s
CI / docker (push) Successful in 39s
Logto ENDPOINT now points at Traefik (http://localhost) instead of
directly at port 3001. All services share the same base URL, eliminating
OIDC issuer mismatches and crypto.subtle secure context issues.

- Remove :3001 from all public-facing Logto URLs
- Add cameleer3-server-ui to Traefik at /server/ with prefix strip
- Dashboard link uses /server/ path instead of port 8082
- Bootstrap Host headers match Logto ENDPOINT (no port)
- Redirect URIs simplified (Traefik handles port 80)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:32:36 +02:00
hsiegeln
3a93b68ea5 fix: split JWK fetch (Docker-internal) from issuer validation (localhost)
All checks were successful
CI / build (push) Successful in 39s
CI / docker (push) Successful in 7s
crypto.subtle requires a secure context, so the browser must access
everything via localhost. The custom JwtDecoder already supports this
split: jwk-set-uri uses Docker-internal logto:3001 for network fetch,
while issuer-uri uses localhost:3001 for string-only claim validation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:16:04 +02:00
hsiegeln
e90ca29920 fix: centralize public hostname into single PUBLIC_HOST env var
All checks were successful
CI / build (push) Successful in 39s
CI / docker (push) Successful in 36s
All public-facing URLs (Logto OIDC, redirect URIs, dashboard links) now
derive from PUBLIC_HOST in .env instead of scattered localhost references.
Resolves Docker networking ambiguity where localhost inside containers
doesn't reach the host machine.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:07:20 +02:00
hsiegeln
423803b303 fix: use Docker-internal URL for server OIDC issuer in bootstrap
All checks were successful
CI / build (push) Successful in 38s
CI / docker (push) Successful in 5s
Bootstrap was sending LOGTO_PUBLIC_ENDPOINT (http://localhost:3001)
as the OIDC issuer URI to the server. Inside Docker, localhost is
unreachable. Changed to LOGTO_ENDPOINT (http://logto:3001).

Also: .env must set LOGTO_ISSUER_URI=http://logto:3001/oidc (not
localhost) since this env var feeds cameleer3-server's OIDC decoder.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:38:02 +02:00
hsiegeln
cfb16d5048 fix: bootstrap OIDC config — add retry and null guard
All checks were successful
CI / build (push) Successful in 37s
CI / docker (push) Successful in 7s
Phase 7 server health check failed intermittently due to timing.
Add 3-attempt retry loop with 2s sleep. Guard against jq returning
literal "null" string for TRAD_SECRET. Add debug logging for both
preconditions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:31:41 +02:00
hsiegeln
45b60a0aee feat: add cameleer3-server-ui container to Docker Compose
All checks were successful
CI / build (push) Successful in 39s
CI / docker (push) Successful in 38s
The cameleer3-server deploys backend and UI as separate containers.
Add the cameleer3-server-ui image (nginx SPA + API reverse proxy)
to the Compose stack, exposed on port 8082 in dev. Update sidebar
"View Dashboard" link to point to the UI container.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:23:48 +02:00
hsiegeln
9b77f810c1 fix: use correct health endpoint and HTTP method for server integration
All checks were successful
CI / build (push) Successful in 40s
CI / docker (push) Successful in 31s
ConnectivityHealthCheck: /actuator/health → /api/v1/health (actuator
now requires auth on cameleer3-server after OIDC was added).

Bootstrap: POST → PUT for /api/v1/admin/oidc (server expects PUT,
POST returned 405 causing OIDC config to silently fail).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:59:24 +02:00
hsiegeln
1ef8c9dceb refactor: merge tenant isolation into single HandlerInterceptor
All checks were successful
CI / build (push) Successful in 38s
CI / docker (push) Successful in 37s
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
hsiegeln
051f7fdae9 feat: auth hardening — scope enforcement, tenant isolation, and docs
All checks were successful
CI / build (push) Successful in 38s
CI / docker (push) Successful in 39s
Add @PreAuthorize annotations to all API controllers (14 endpoints
across 6 controllers) enforcing OAuth2 scopes: apps:manage, apps:deploy,
billing:manage, observe:read, platform:admin.

Enforce tenant isolation: TenantResolutionFilter now rejects cross-tenant
access on /api/tenants/{id}/* paths. New TenantOwnershipValidator checks
environment/app ownership for paths without tenantId. Platform admins
bypass both layers.

Fix frontend: OrgResolver split into two useEffect hooks so scopes
refresh on org switch. Scopes now served from /api/config (single source
of truth). Bootstrap cleaned — standalone org permissions removed.

Update docs/architecture.md, docs/user-manual.md, and CLAUDE.md to
reflect all auth hardening changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:32:53 +02:00
hsiegeln
b459a69083 docs: add architecture document
All checks were successful
CI / build (push) Successful in 38s
CI / docker (push) Successful in 7s
Comprehensive technical reference covering system topology, auth model
(Logto OIDC, scopes, token types, Spring Security pipeline), data model
(7 tables from Flyway migrations), deployment flow, agent-server protocol,
API endpoints, security boundaries, frontend architecture, and full
configuration reference. All class names, paths, and properties verified
against the codebase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:19:05 +02:00
hsiegeln
c5596d8ea4 docs: add user manual
Task-oriented guide for SaaS customers and self-hosted operators
covering login, environments, applications, deployments, observability,
licenses, platform admin, roles, self-hosted setup, and troubleshooting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:15:40 +02:00
hsiegeln
e3baaeee84 fix: replace stale permission prop with scope in EnvironmentDetailPage
All checks were successful
CI / build (push) Successful in 39s
CI / docker (push) Successful in 38s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:13:37 +02:00
hsiegeln
298f6e3e71 feat: scope-based authorization — read standard scope claim, remove custom roles extraction
Some checks failed
CI / build (push) Failing after 18s
CI / docker (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:04:16 +02:00
hsiegeln
9c2a1d27b7 feat: replace hardcoded permission map with direct OAuth2 scope checks
Remove role-to-permission mapping (usePermissions, RequirePermission) and replace
with direct scope reads from the Logto access token JWT. OrgResolver decodes the
scope claim after /api/me resolves and stores scopes in Zustand. RequireScope and
useScopes replace the old hooks/components across all pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:04:06 +02:00
hsiegeln
277d5ea638 feat: define OAuth2 scopes on API resource and assign to Logto roles
Creates 10 API resource scopes (platform:admin + 9 tenant-level) on the
Cameleer SaaS API resource, assigns all to platform-admin role, creates
matching organization scopes, and wires them declaratively to org roles
(admin gets all, member gets deploy/observe). All operations are idempotent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:01:43 +02:00
hsiegeln
6ccf7f3fcb fix: ProtectedRoute spinner fix, TokenSync cleanup, dev hot-reload
All checks were successful
CI / build (push) Successful in 37s
CI / docker (push) Successful in 38s
- ProtectedRoute: only gate on initial auth load, not every async op
- TokenSync: OrgResolver is sole source of org data, remove fetchUserInfo
- docker-compose.dev: mount ui/dist for hot-reload

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:11:44 +02:00
hsiegeln
cfa989bd5e fix: allow JwtDecoder bean override in test context
- Add @Primary + @ConditionalOnMissingBean so TestSecurityConfig.jwtDecoder()
  wins over SecurityConfig.jwtDecoder() without needing a real OIDC endpoint
- Add spring.main.allow-bean-definition-overriding=true and
  cameleer.clickhouse.enabled=false to src/test/resources/application-test.yml
  so Testcontainers @ServiceConnection can supply the datasource
- Disable ClickHouse in test profile (src/main/resources/application-test.yml)
  so the explicit ClickHouseConfig DataSource bean is not created, allowing
  @ServiceConnection to wire the Testcontainers Postgres datasource
- Fix TenantControllerTest and LicenseControllerTest to explicitly grant
  ROLE_platform-admin authority via .authorities() on the test JWT, since
  spring-security-test does not run the custom JwtAuthenticationConverter
- Fix EnvironmentService.createDefaultForTenant() to use an internal
  bootstrap path that skips license enforcement (chicken-and-egg: no license
  exists at tenant creation time yet)
- Remove now-unnecessary license stub from EnvironmentServiceTest

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 13:06:31 +02:00
hsiegeln
4da9cf23cb infra: add OIDC config to bootstrap output, stop reading Logto DB for secrets
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:44:27 +02:00
hsiegeln
9e6440d97c infra: remove ForwardAuth, keys mount, add OIDC env vars for server
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:44:04 +02:00
hsiegeln
5326102443 feat: remove bootstrap_token from EnvironmentEntity — API keys managed separately
Remove bootstrapToken field/getter/setter from EnvironmentEntity and drop
the RuntimeConfig dependency from EnvironmentService. DeploymentService and
AgentStatusService now use a TODO-api-key placeholder until the ApiKeyService
wiring is complete. All test references to setBootstrapToken removed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:42:47 +02:00
hsiegeln
ec1ec2e65f feat: rewrite frontend auth — roles from org store, Logto org role names
Replace ID-token claim reads with org store lookups in useAuth and
usePermissions; add currentOrgRoles to useOrgStore; update role names
to Logto org role conventions (admin/member); remove username from
Layout (no longer derived from token claims).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:42:26 +02:00
hsiegeln
5f43394b00 refactor: remove getUserRoles from LogtoManagementClient — roles come from JWT 2026-04-05 12:40:58 +02:00
hsiegeln
bd2a6a601b test: update TestSecurityConfig with org and role claims for Logto tokens 2026-04-05 12:40:49 +02:00
hsiegeln
4b5a1cf2a2 feat: add API key entity, repository, and service with SHA-256 hashing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:39:50 +02:00
hsiegeln
b8b0c686e8 feat: replace manual Logto role check with @PreAuthorize in TenantController
Remove LogtoManagementClient dependency from TenantController; gate
listAll and create with @PreAuthorize("hasRole('platform-admin')"),
relying on the JWT roles claim already mapped by JwtAuthenticationConverter.
Update TenantControllerTest to supply the platform-admin role via jwt()
on all POST requests that expect 201/409.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:39:40 +02:00
hsiegeln
d4408634a6 feat: rewrite MeController — read from JWT claims, Management API only for cold start
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:38:39 +02:00
hsiegeln
48a5035a2c fix: remove Ed25519 license signing — replace with UUID token placeholder
Drop JwtConfig dependency from LicenseService; generate license tokens as
random UUIDs instead. Add findByToken to LicenseRepository and update
verifyLicenseToken to do a DB lookup. Update LicenseServiceTest to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:37:32 +02:00
hsiegeln
396c00749e feat: rewrite SecurityConfig — single filter chain, Logto OAuth2 Resource Server
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:35:45 +02:00
hsiegeln
f89be09e04 chore: greenfield migrations — remove user/role tables, add api_keys, drop bootstrap_token
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:33:52 +02:00
hsiegeln
3929bbb95e chore: delete dead auth code — users/roles/JWTs/ForwardAuth live in Logto now
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 12:32:18 +02:00
hsiegeln
1397267be5 docs: add auth overhaul implementation plan
16 tasks across 3 phases: server OIDC support, SaaS auth rewrite,
infrastructure updates. TDD, complete code, greenfield migrations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:26:47 +02:00
hsiegeln
c61c59a441 docs: update auth spec for greenfield approach
Remove migration/backward-compat hedging. Delete legacy user/role/permission
tables entirely, remove bootstrap_token column in favor of api_keys table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:19:09 +02:00
hsiegeln
fc4c1f94cd docs: add auth overhaul design spec
Comprehensive design for replacing the incoherent three-system auth
with Logto-centric architecture: OAuth2 Resource Server for humans,
API keys for agents, zero trust (no header identity), server-per-tenant.
Covers cameleer-saas (large), cameleer3-server (small), agent (none).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:13:19 +02:00
hsiegeln
1b42bd585d fix: re-resolve tenantId when org list is enriched by OrgResolver
All checks were successful
CI / build (push) Successful in 38s
CI / docker (push) Successful in 37s
TokenSync sets currentOrgId from fetchUserInfo (no tenantId).
OrgResolver later calls setOrganizations with enriched data including
tenantId. Now setOrganizations auto-resolves currentTenantId when
the org is already selected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 10:29:03 +02:00
hsiegeln
51c73d64a4 fix: read M2M credentials from bootstrap JSON when env vars empty
All checks were successful
CI / build (push) Successful in 39s
CI / docker (push) Successful in 31s
The bootstrap dynamically creates the M2M app and writes credentials
to the JSON file. LogtoConfig now falls back to the bootstrap file
when LOGTO_M2M_CLIENT_ID/SECRET env vars are not set.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 10:23:02 +02:00
hsiegeln
34aadd1e25 fix: accept Logto at+jwt token type in Spring Security
All checks were successful
CI / build (push) Successful in 39s
CI / docker (push) Successful in 30s
Logto issues access tokens with typ "at+jwt" (RFC 9068) but Spring
Security's default NimbusJwtDecoder only allows "JWT". Custom decoder
accepts any type. Also removed hard 401 redirect from API client.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 10:17:25 +02:00
hsiegeln
1abf0f827b fix: remove 401 hard redirect, let React Query retry
All checks were successful
CI / build (push) Successful in 40s
CI / docker (push) Successful in 41s
The /api/me call races with TokenSync — fires before the token
provider is set. Removed the hard window.location redirect on 401
from the API client. React Query retries with backoff instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 03:02:32 +02:00
hsiegeln
00ee8876c1 fix: move DB seeding from bootstrap script to Java ApplicationRunner
All checks were successful
CI / build (push) Successful in 40s
CI / docker (push) Successful in 33s
SonarQube Analysis / sonarqube (push) Successful in 1m21s
The bootstrap script runs before cameleer-saas (Flyway), so tenant
tables don't exist yet. Moved DB seeding to BootstrapDataSeeder
ApplicationRunner which runs after Flyway migrations complete.
Reads bootstrap JSON and creates tenant/environment/license if missing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 02:55:43 +02:00
hsiegeln
827e388349 feat: bootstrap 2 users, tenant, org-scoped tokens, platform admin UI
All checks were successful
CI / build (push) Successful in 40s
CI / docker (push) Successful in 39s
Bootstrap script now creates:
- SaaS Owner (admin/admin) with platform-admin role
- Tenant Admin (camel/camel) in Example Tenant org
- Traditional Web App for cameleer3-server OIDC
- DB records: tenant, default environment, license
- Configures cameleer3-server OIDC via its admin API
All credentials configurable via env vars.

Backend:
- Fix LogtoManagementClient resource URL (https://default.logto.app/api)
- Add getUserRoles/getUserOrganizations to LogtoManagementClient
- Add GET /api/me endpoint (user info, platform admin status, tenants)
- Add GET /api/tenants list-all for platform admins
- Remove insecure X-header forwarding from Traefik

Frontend:
- Org-scoped tokens: getAccessToken(resource, orgId) for tenant context
- OrgResolver component populates org store from /api/me
- useOrganization Zustand store (currentOrgId + currentTenantId)
- Platform admin sidebar section + AdminTenantsPage
- View Dashboard link points to cameleer3-server on port 8081

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 02:50:51 +02:00
hsiegeln
b83cfdcd49 fix: add all design-system providers (GlobalFilter + CommandPalette)
All checks were successful
CI / build (push) Successful in 41s
CI / docker (push) Successful in 38s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 01:30:18 +02:00
hsiegeln
a7dd026225 fix: add GlobalFilterProvider required by design-system DataTable
All checks were successful
CI / build (push) Successful in 39s
CI / docker (push) Successful in 41s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 01:23:36 +02:00
hsiegeln
0843a33383 refactor: replace hand-rolled OIDC with @logto/react SDK
All checks were successful
CI / build (push) Successful in 38s
CI / docker (push) Successful in 48s
The hand-rolled OIDC flow (manual PKCE, token exchange, URL
construction) was fragile and accumulated multiple bugs. Replaced
with the official @logto/react SDK which handles PKCE, token
exchange, storage, and refresh automatically.

- Add @logto/react SDK dependency
- Add LogtoProvider with runtime config in main.tsx
- Add TokenSync component bridging SDK tokens to API client
- Add useAuth hook replacing Zustand auth store
- Simplify LoginPage to signIn(), CallbackPage to useHandleSignInCallback()
- Delete pkce.ts and auth-store.ts (replaced by SDK)
- Fix react-router-dom → react-router imports in page files
- All 17 React Query hooks unchanged (token provider pattern)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 01:17:47 +02:00
hsiegeln
84667170f1 fix: register API resource in Logto for JWT access tokens
All checks were successful
CI / build (push) Successful in 38s
CI / docker (push) Successful in 39s
Logto returns opaque access tokens when no resource is specified.
Added API resource creation to bootstrap, included resource indicator
in /api/config, and SPA now passes resource parameter in auth request.
Also fixed issuer-uri to match Logto's public endpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 01:01:32 +02:00
hsiegeln
6764f981d2 fix: add PKCE support for Logto auth and fix Traefik routing
All checks were successful
CI / build (push) Successful in 39s
CI / docker (push) Successful in 39s
Logto requires PKCE (Proof Key for Code Exchange) for SPA auth.
Added code_challenge/code_verifier to login and callback flow.

Also fixed Traefik router-service linking — when a container defines
multiple routers, each needs an explicit service binding or Traefik
v3 refuses to auto-link them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 00:48:21 +02:00
hsiegeln
537c2bbaf2 fix: use LOGTO_PUBLIC_ENDPOINT for Logto ENDPOINT config
All checks were successful
CI / build (push) Successful in 39s
CI / docker (push) Successful in 6s
Logto's ENDPOINT must be the browser-accessible URL (not Docker
internal). When .env sets LOGTO_ENDPOINT=http://logto:3001, it was
overriding the default and causing Logto to redirect browsers to
an unreachable hostname.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 00:44:24 +02:00
hsiegeln
beb3442c07 fix: return browser-accessible Logto URL from /api/config
All checks were successful
CI / build (push) Successful in 39s
CI / docker (push) Successful in 31s
Separate LOGTO_PUBLIC_ENDPOINT (browser-facing, defaults to
http://localhost:3001) from LOGTO_ENDPOINT (Docker-internal).
Also fix bootstrap M2M verification by using correct Host header
for default tenant token endpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 00:33:43 +02:00
hsiegeln
a20d36df38 fix: bootstrap script use curl with Host header for Logto tenant routing
All checks were successful
CI / build (push) Successful in 38s
CI / docker (push) Successful in 6s
Logto routes requests by Host header to determine tenant. Inside Docker,
requests to logto:3001/3002 need Host: localhost:3001/3002 to match the
configured ENDPOINT/ADMIN_ENDPOINT.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 00:28:23 +02:00
hsiegeln
021b056bce feat: zero-config first-run experience with Logto bootstrap
All checks were successful
CI / build (push) Successful in 39s
CI / docker (push) Successful in 37s
- logto-bootstrap.sh: API-driven init script that creates SPA app,
  M2M app, and default user (camel/camel) via Logto Management API.
  Reads m-default secret from DB, then removes seeded apps with
  known secrets (security hardening). Idempotent.
- PublicConfigController: /api/config public endpoint serves Logto
  client ID from bootstrap output file (runtime, not build-time)
- Frontend: LoginPage + CallbackPage fetch config from /api/config
  instead of import.meta.env (fixes Vite build-time baking issue)
- Docker Compose: logto-bootstrap init service with health-gated
  dependency chain, shared volume for bootstrap config
- SecurityConfig: permit /api/config without auth

Flow: docker compose up → bootstrap creates apps/user → SPA fetches
config → login page shows → sign in with Logto → camel/camel

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 00:22:22 +02:00
hsiegeln
cda7dfbaa7 fix: permit SPA routes and static assets in Spring Security
All checks were successful
CI / build (push) Successful in 38s
CI / docker (push) Successful in 32s
The SPA (index.html, /login, /callback, /assets/*) must be accessible
without authentication. API routes remain protected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:49:43 +02:00
hsiegeln
ad6805e447 fix: use standard dist/ output for Vite, copy to static/ explicitly
All checks were successful
CI / build (push) Successful in 39s
CI / docker (push) Successful in 37s
The relative outDir '../src/main/resources/static' resolved
unpredictably in Docker. Use standard 'dist/' output, then:
- Dockerfile: COPY --from=frontend /ui/dist/ to static/
- CI: cp -r dist/ to src/main/resources/static/

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:44:50 +02:00
hsiegeln
e5e14fbe32 fix: add CAMELEER_JWT_SECRET for cameleer3-server
All checks were successful
CI / build (push) Successful in 39s
CI / docker (push) Successful in 7s
The server needs this to derive its Ed25519 signing key. Without it,
startup fails with 'Empty key'.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:42:37 +02:00
hsiegeln
e10f80c298 fix: allow ClickHouse connections from Docker network
All checks were successful
CI / build (push) Successful in 39s
CI / docker (push) Successful in 6s
The default ClickHouse image restricts the 'default' user to localhost
only. Override with clickhouse-users.xml to allow connections from any
IP (needed for inter-container communication on the Docker network).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:41:14 +02:00
hsiegeln
16acd145a3 fix: pg_isready healthcheck must specify database name
All checks were successful
CI / build (push) Successful in 38s
CI / docker (push) Successful in 5s
Without -d, pg_isready connects to database matching the username
('cameleer'), which doesn't exist. Specify $POSTGRES_DB explicitly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:38:02 +02:00
hsiegeln
d0fd2c49be fix: Docker Compose database initialization
Some checks failed
CI / build (push) Successful in 38s
CI / docker (push) Has been cancelled
- init-databases.sh: create cameleer3 DB for cameleer3-server, connect
  to $POSTGRES_DB explicitly (avoids 'database cameleer does not exist')
- clickhouse-init.sql: auto-create cameleer database on first start
- docker-compose.yml: fix cameleer3-server datasource to cameleer3 DB,
  add ClickHouse init script volume mount, pass credentials

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:37:19 +02:00
hsiegeln
567d92ca34 fix: let Flyway inherit datasource connection instead of separate URL
All checks were successful
CI / build (push) Successful in 38s
CI / docker (push) Successful in 31s
Explicit spring.flyway.url/user/password used SPRING_DATASOURCE_URL env
var but Flyway resolves its own defaults independently, falling back to
localhost when the env var mapping doesn't match. Removing the explicit
Flyway connection config lets it inherit from the datasource, which is
correctly configured.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:20:04 +02:00
hsiegeln
fb4e1f57e5 docs: add Phase 9 Frontend React Shell implementation plan
All checks were successful
CI / build (push) Successful in 40s
CI / docker (push) Successful in 5s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:10:25 +02:00
hsiegeln
032db410c7 fix: refactor ClickHouse config to match cameleer3-server pattern
All checks were successful
CI / build (push) Successful in 38s
CI / docker (push) Successful in 32s
- Add ClickHouseProperties with @ConfigurationProperties
- @ConditionalOnProperty to toggle ClickHouse
- @Primary DataSource + JdbcTemplate for PostgreSQL (prevents Spring
  Boot from routing JPA/Flyway to ClickHouse)
- HikariDataSource for ClickHouse with explicit credentials
- Remove separate DataSourceConfig.java (merged into ClickHouseConfig)
- Remove database-platform override (no longer needed with @Primary)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:09:01 +02:00
hsiegeln
be4c882ef8 fix: configure Flyway to use explicit PostgreSQL datasource
All checks were successful
CI / build (push) Successful in 39s
CI / docker (push) Successful in 31s
With two DataSource beans (PostgreSQL + ClickHouse), Flyway was picking
up the ClickHouse DataSource and failing with auth errors. Explicitly
configure Flyway's url/user/password to target PostgreSQL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:58:54 +02:00
hsiegeln
64a5edac78 fix: add explicit datasource defaults to application.yml
All checks were successful
CI / build (push) Successful in 39s
CI / docker (push) Successful in 31s
Without an explicit spring.datasource.url, Spring Boot falls back to
jdbc:postgresql://localhost:5432 when the SPRING_DATASOURCE_URL env var
is missing or not picked up. Default now points to the docker-compose
service name (postgres:5432/cameleer_saas).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:54:18 +02:00
hsiegeln
806895fbd0 fix: separate dev and local profiles for Docker vs bare-metal
All checks were successful
CI / build (push) Successful in 37s
CI / docker (push) Successful in 29s
dev profile: activated inside Docker containers (show-sql only)
local profile: for running Maven outside Docker (localhost overrides)

Usage: mvn spring-boot:run -Dspring-boot.run.profiles=dev,local

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:45:07 +02:00
hsiegeln
c0e189a5c8 fix: add ClickHouse and cameleer3-server localhost overrides to dev profile
All checks were successful
CI / build (push) Successful in 39s
CI / docker (push) Successful in 3m1s
Without these, `mvn spring-boot:run -Dspring-boot.run.profiles=dev` fails
because ClickHouse URL defaults to docker hostname 'clickhouse'.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:34:02 +02:00
hsiegeln
aaa4af40c5 fix: use BUILDPLATFORM for native cross-compilation, remove broken cache mounts
Some checks failed
CI / build (push) Successful in 42s
CI / docker (push) Has been cancelled
Build and frontend stages now use --platform=$BUILDPLATFORM so Maven and
Node run natively on the ARM64 CI runner instead of under QEMU emulation.
Only the final JRE runtime stage targets amd64. Removed --mount=type=cache
which doesn't persist across CI runs with buildx --push; the registry layer
cache (--cache-from/--cache-to in CI) handles caching the dependency layer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:31:22 +02:00
hsiegeln
c4a4c9d2fc fix: cross-compile Docker image for amd64 and add npm registry auth
Some checks failed
CI / build (push) Successful in 40s
CI / docker (push) Has been cancelled
- CI docker job: QEMU + buildx + --platform linux/amd64 (runners are arm64)
- Dockerfile: REGISTRY_TOKEN build arg for @cameleer/design-system npm auth
- CI build job: npm auth token for frontend build step
- Registry cache for faster builds

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:21:44 +02:00
050ff61e7a Merge pull request 'feat: Phase 9 — Frontend React Shell' (#35) from feat/phase-9-frontend-react-shell into main
All checks were successful
CI / build (push) Successful in 41s
CI / docker (push) Successful in 4s
Reviewed-on: #35
2026-04-04 22:12:53 +02:00
hsiegeln
e325c4d2c0 fix: correct Dockerfile frontend build output path
All checks were successful
CI / build (push) Successful in 1m10s
CI / build (pull_request) Successful in 1m9s
CI / docker (pull_request) Has been skipped
CI / docker (push) Successful in 23s
Vite's outDir is '../src/main/resources/static' (relative to ui/),
which resolves to /src/main/resources/static/ in the Docker build.
The COPY was looking at /ui/dist/ which doesn't exist.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:10:42 +02:00
hsiegeln
4c8c8efbe5 feat: add SPA controller, Traefik route, CI frontend build, and HOWTO update
Some checks failed
CI / build (push) Successful in 49s
CI / docker (push) Failing after 38s
CI / build (pull_request) Successful in 1m2s
CI / docker (pull_request) Has been skipped
- SpaController catch-all forwards non-API routes to index.html
- Traefik SPA route at priority=1 catches all unmatched paths
- CI pipeline builds frontend before Maven
- Dockerfile adds multi-stage frontend build
- HOWTO.md documents frontend development workflow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:06:36 +02:00
hsiegeln
f6d3627abc feat: add license page with tier features and limits
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:04:55 +02:00
hsiegeln
fe786790e1 feat: add app detail page with deploy, logs, and status
Full AppDetailPage with tabbed layout (Overview / Deployments / Logs),
current deployment status with auto-poll, action bar (deploy/stop/restart/re-upload/delete),
agent status card, routing card with edit modal, deployment history table,
and container log viewer with stream filter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:03:29 +02:00
hsiegeln
5eac48ad72 feat: add environments list and environment detail pages
Implements EnvironmentsPage with DataTable, create modal, and row navigation,
and EnvironmentDetailPage with app list, inline rename, new app form with JAR
upload, and delete confirmation — all gated by RBAC permissions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:00:14 +02:00
hsiegeln
02019e9347 feat: add dashboard page with tenant overview and KPI stats
Replaces placeholder DashboardPage with a full implementation: tenant
name + tier badge, KPI strip (environments, total apps, running, stopped),
environment list with per-env app counts, and a recent deployments
placeholder. Uses EnvApps helper component to fetch per-environment app
data without violating hook rules.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 21:57:53 +02:00
hsiegeln
91a4235223 feat: add sidebar layout, environment tree, and router
Wires up AppShell + Sidebar compound component, a per-environment
SidebarTree that lazy-fetches apps, React Router nested routes, and
provider-wrapped main.tsx with ThemeProvider/ToastProvider/BreadcrumbProvider.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 21:55:21 +02:00
hsiegeln
e725669aef feat: add RBAC hooks and permission-gated components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 21:51:38 +02:00
hsiegeln
d572926010 feat: add API client with auth middleware and React Query hooks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 21:50:19 +02:00
hsiegeln
e33818cc74 feat: add auth store, login, callback, and protected route
Adds Zustand auth store with JWT parsing (sub, roles, organization_id),
Logto OIDC login page, authorization code callback handler, and
ProtectedRoute guard. Also adds vite-env.d.ts for import.meta.env types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 21:48:56 +02:00
hsiegeln
146dbccc6e feat: scaffold React SPA with Vite, design system, and TypeScript types
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 21:47:01 +02:00
hsiegeln
600985c913 docs: add Phase 9 Frontend React Shell spec
All checks were successful
CI / build (push) Successful in 28s
CI / docker (push) Successful in 4s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:36:45 +02:00
7aa331d73c Merge pull request 'feat: Phase 4 — Observability Pipeline + Inbound Routing' (#34) from feat/phase-4-observability-pipeline into main
All checks were successful
CI / build (push) Successful in 28s
CI / docker (push) Successful in 3s
Reviewed-on: #34
2026-04-04 21:20:46 +02:00
hsiegeln
9b1643c1ee docs: update HOWTO with observability dashboard, routing, and agent status
All checks were successful
CI / build (push) Successful in 29s
CI / build (pull_request) Successful in 48s
CI / docker (push) Successful in 47s
CI / docker (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:06:05 +02:00
hsiegeln
9f8d0f43ab feat: add dashboard Traefik route and CAMELEER_TENANT_ID config
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 21:04:57 +02:00
hsiegeln
43cd2d012f feat: add cameleer3-server startup connectivity check 2026-04-04 21:03:41 +02:00
hsiegeln
210da55e7a feat: add Traefik routing labels for customer apps with exposed ports
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 21:03:04 +02:00
hsiegeln
08b87edd6e feat: add agent status and observability status endpoints
Implements AgentStatusService (TDD) that proxies cameleer3-server agent
registry API and queries ClickHouse for trace counts. Gracefully degrades
to UNKNOWN state when server is unreachable or DataSource is absent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 21:01:43 +02:00
hsiegeln
024780c01e feat: add exposed port routing and route URL to app API
Adds domain config to RuntimeConfig/application.yml, expands AppResponse
with exposedPort and computed routeUrl, adds updateRouting to AppService,
and adds PATCH /{appId}/routing endpoint to AppController.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 20:57:37 +02:00
hsiegeln
d25849d665 feat: add labels support to StartContainerRequest and DockerRuntimeOrchestrator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 20:55:16 +02:00
hsiegeln
b0275bcf64 feat: add exposed_port column to apps table 2026-04-04 20:53:56 +02:00
hsiegeln
f8d80eaf79 docs: add Phase 4 Observability Pipeline implementation plan
All checks were successful
CI / build (push) Successful in 28s
CI / docker (push) Successful in 4s
8 tasks: migration, labels support, routing API, agent/observability
status endpoints, Traefik routing labels, connectivity check,
Docker Compose + env, HOWTO update.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 20:52:17 +02:00
hsiegeln
41629f3290 docs: add Phase 4 Observability Pipeline + Inbound Routing spec
All checks were successful
CI / build (push) Successful in 27s
CI / docker (push) Successful in 4s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 20:47:51 +02:00
hsiegeln
b78dfa9a7b docs: add HOWTO.md with install, start, and bootstrap instructions
All checks were successful
CI / build (push) Successful in 27s
CI / docker (push) Successful in 4s
Quick start, full installation guide, Logto setup, first tenant
creation, app deployment walkthrough, API reference, tier limits,
development commands, and troubleshooting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:31:56 +02:00
hsiegeln
d81ce2b697 ci: revert artifact approach, use BuildKit cache for Maven deps
All checks were successful
CI / build (push) Successful in 29s
CI / docker (push) Successful in 2m31s
Gitea Actions doesn't support upload/download-artifact v4.
Reverted to two-job approach (git clone + docker build).
Added BuildKit cache mount (--mount=type=cache,target=/root/.m2)
to Dockerfile so Maven deps persist across Docker builds on the
same runner. First build downloads, subsequent builds are cached.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:27:08 +02:00
hsiegeln
cbf7d5c60f ci: pass pre-built JAR to docker job via artifact
Some checks failed
CI / build (push) Failing after 51s
CI / docker (push) Has been skipped
Build job uploads the JAR, docker job downloads it and builds a
runtime-only image. Eliminates duplicate Maven dependency download
(~2min saving). The repo Dockerfile is kept for local builds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 18:15:12 +02:00
956eb13dd6 Merge pull request 'feat: Phase 3 — Runtime Orchestration + Environments' (#33) from feat/phase-3-runtime-orchestration into main
All checks were successful
CI / build (push) Successful in 29s
CI / docker (push) Successful in 45s
Reviewed-on: #33
2026-04-04 18:10:42 +02:00
175 changed files with 20857 additions and 3862 deletions

View File

@@ -11,11 +11,13 @@ POSTGRES_DB=cameleer_saas
# Logto Identity Provider
LOGTO_ENDPOINT=http://logto:3001
LOGTO_ISSUER_URI=http://logto:3001/oidc
LOGTO_PUBLIC_ENDPOINT=http://localhost:3001
LOGTO_ISSUER_URI=http://localhost:3001/oidc
LOGTO_JWK_SET_URI=http://logto:3001/oidc/jwks
LOGTO_DB_PASSWORD=change_me_in_production
LOGTO_M2M_CLIENT_ID=
LOGTO_M2M_CLIENT_SECRET=
LOGTO_SPA_CLIENT_ID=
# Ed25519 Keys (mount PEM files)
CAMELEER_JWT_PRIVATE_KEY_PATH=/etc/cameleer/keys/ed25519.key
@@ -27,3 +29,4 @@ DOMAIN=localhost
CAMELEER_AUTH_TOKEN=change_me_bootstrap_token
CAMELEER_CONTAINER_MEMORY_LIMIT=512m
CAMELEER_CONTAINER_CPU_SHARES=512
CAMELEER_TENANT_SLUG=default

View File

@@ -27,10 +27,29 @@ jobs:
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: ${{ runner.os }}-maven-
- name: Build SaaS frontend
run: |
cd ui
echo "//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${REGISTRY_TOKEN}" >> .npmrc
npm ci
npm run build
cp -r dist/ ../src/main/resources/static/
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and Test (unit tests only)
run: >-
mvn clean verify -B
-Dsurefire.excludes="**/AuthControllerTest.java,**/TenantControllerTest.java,**/LicenseControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java,**/EnvironmentControllerTest.java,**/AppControllerTest.java,**/DeploymentControllerTest.java"
-Dsurefire.excludes="**/AuthControllerTest.java,**/TenantControllerTest.java,**/LicenseControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java,**/EnvironmentControllerTest.java,**/AppControllerTest.java,**/DeploymentControllerTest.java,**/AgentStatusControllerTest.java"
- name: Build sign-in UI
run: |
cd ui/sign-in
echo "//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${REGISTRY_TOKEN}" >> .npmrc
npm ci
npm run build
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
docker:
needs: build
@@ -70,15 +89,56 @@ jobs:
echo "IMAGE_TAGS=branch-$SLUG" >> "$GITHUB_ENV"
fi
- name: Build and push
- name: Set up QEMU for cross-platform builds
run: docker run --rm --privileged gitea.siegeln.net/cameleer/binfmt:1 --install all
- name: Build and push SaaS image
run: |
docker buildx create --use --name cibuilder
TAGS="-t gitea.siegeln.net/cameleer/cameleer-saas:${{ github.sha }}"
for TAG in $IMAGE_TAGS; do
TAGS="$TAGS -t gitea.siegeln.net/cameleer/cameleer-saas:$TAG"
done
docker build $TAGS --provenance=false .
for TAG in $IMAGE_TAGS ${{ github.sha }}; do
docker push gitea.siegeln.net/cameleer/cameleer-saas:$TAG
done
docker buildx build --platform linux/amd64 \
--build-arg REGISTRY_TOKEN="$REGISTRY_TOKEN" \
$TAGS \
--cache-from type=registry,ref=gitea.siegeln.net/cameleer/cameleer-saas:buildcache \
--cache-to type=registry,ref=gitea.siegeln.net/cameleer/cameleer-saas:buildcache,mode=max \
--provenance=false \
--push .
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push runtime base image
run: |
AGENT_VERSION=$(curl -sf "https://gitea.siegeln.net/api/packages/cameleer/maven/com/cameleer3/cameleer3-agent/1.0-SNAPSHOT/maven-metadata.xml" \
| sed -n 's/.*<value>\([^<]*\)<\/value>.*/\1/p' | tail -1)
echo "Agent version: $AGENT_VERSION"
curl -sf -o docker/runtime-base/agent.jar \
"https://gitea.siegeln.net/api/packages/cameleer/maven/com/cameleer3/cameleer3-agent/1.0-SNAPSHOT/cameleer3-agent-${AGENT_VERSION}-shaded.jar"
ls -la docker/runtime-base/agent.jar
TAGS="-t gitea.siegeln.net/cameleer/cameleer-runtime-base:${{ github.sha }}"
for TAG in $IMAGE_TAGS; do
TAGS="$TAGS -t gitea.siegeln.net/cameleer/cameleer-runtime-base:$TAG"
done
docker buildx build --platform linux/amd64 \
$TAGS \
--provenance=false \
--push docker/runtime-base/
- name: Build and push Logto image
run: |
TAGS="-t gitea.siegeln.net/cameleer/cameleer-logto:${{ github.sha }}"
for TAG in $IMAGE_TAGS; do
TAGS="$TAGS -t gitea.siegeln.net/cameleer/cameleer-logto:$TAG"
done
docker buildx build --platform linux/amd64 \
--build-arg REGISTRY_TOKEN="$REGISTRY_TOKEN" \
-f ui/sign-in/Dockerfile \
$TAGS \
--cache-from type=registry,ref=gitea.siegeln.net/cameleer/cameleer-logto:buildcache \
--cache-to type=registry,ref=gitea.siegeln.net/cameleer/cameleer-logto:buildcache,mode=max \
--provenance=false \
--push ui/sign-in/
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}

View File

@@ -28,7 +28,7 @@ jobs:
- name: Build, Test and Analyze
run: >-
mvn clean verify sonar:sonar --batch-mode
-Dsurefire.excludes="**/AuthControllerTest.java,**/TenantControllerTest.java,**/LicenseControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java,**/EnvironmentControllerTest.java,**/AppControllerTest.java,**/DeploymentControllerTest.java"
-Dsurefire.excludes="**/AuthControllerTest.java,**/TenantControllerTest.java,**/LicenseControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java,**/EnvironmentControllerTest.java,**/AppControllerTest.java,**/DeploymentControllerTest.java,**/AgentStatusControllerTest.java"
-Dsonar.host.url=${{ secrets.SONAR_HOST_URL }}
-Dsonar.token=${{ secrets.SONAR_TOKEN }}
-Dsonar.projectKey=cameleer-saas

4
.gitignore vendored
View File

@@ -21,3 +21,7 @@ Thumbs.db
# Worktrees
.worktrees/
# Generated by postinstall from @cameleer/design-system
ui/public/favicon.svg
docker/runtime-base/agent.jar

188
CLAUDE.md
View File

@@ -11,12 +11,61 @@ Cameleer SaaS — multi-tenant SaaS platform wrapping the Cameleer observability
This repo is the SaaS layer on top of two proven components:
- **cameleer3** (sibling repo) — Java agent using ByteBuddy for zero-code instrumentation of Camel apps. Captures route executions, processor traces, payloads, metrics, and route graph topology. Deploys as `-javaagent` JAR.
- **cameleer3-server** (sibling repo) — Spring Boot observability backend. Receives agent data via HTTP, pushes config/commands via SSE. PostgreSQL + OpenSearch storage. React SPA dashboard. JWT auth with Ed25519 config signing.
- **cameleer3-server** (sibling repo) — Spring Boot observability backend. Receives agent data via HTTP, pushes config/commands via SSE. PostgreSQL + ClickHouse storage. React SPA dashboard. JWT auth with Ed25519 config signing. Docker container orchestration for app deployments.
- **cameleer-website** — Marketing site (Astro 5)
- **design-system** — Shared React component library (`@cameleer/design-system` on Gitea npm registry)
Agent-server protocol is defined in `cameleer3/cameleer3-common/PROTOCOL.md`. The agent and server are mature, proven components — this repo wraps them with multi-tenancy, billing, and self-service onboarding.
## Key Classes
### Java Backend (`src/main/java/net/siegeln/cameleer/saas/`)
**config/** — Security, tenant isolation, web config
- `SecurityConfig.java` — OAuth2 JWT decoder (ES384, issuer/audience validation, scope extraction)
- `TenantIsolationInterceptor.java` — HandlerInterceptor on `/api/**`; JWT org_id -> TenantContext, path variable validation, fail-closed
- `TenantContext.java` — ThreadLocal<UUID> tenant ID storage
- `WebConfig.java` — registers TenantIsolationInterceptor
- `PublicConfigController.java` — GET /api/config (Logto endpoint, SPA client ID, scopes)
- `MeController.java` — GET /api/me (authenticated user, tenant list)
**tenant/** — Tenant lifecycle
- `TenantEntity.java` — JPA entity (id, name, slug, tier, status, logto_org_id, stripe IDs, settings JSONB)
- `TenantService.java` — create tenant -> Logto org, activate, suspend
- `TenantController.java` — POST create, GET list, GET by ID
**license/** — License management
- `LicenseEntity.java` — JPA entity (id, tenant_id, tier, features JSONB, limits JSONB, expires_at)
- `LicenseService.java` — generation, validation, feature/limit lookups
- `LicenseController.java` — POST issue, GET verify, DELETE revoke
**identity/** — Logto & server integration
- `LogtoConfig.java` — Logto endpoint, M2M credentials (reads from bootstrap file)
- `LogtoManagementClient.java` — Logto Management API calls (create org, create user, add to org)
- `ServerApiClient.java` — M2M client for cameleer3-server API (Logto M2M token, `X-Cameleer-Protocol-Version: 1` header)
**audit/** — Audit logging
- `AuditEntity.java` — JPA entity (actor_id, tenant_id, action, resource, status)
- `AuditService.java` — log audit events (TENANT_CREATE, TENANT_UPDATE, etc.)
### React Frontend (`ui/src/`)
- `main.tsx` — React 19 root
- `router.tsx` — /login, /callback, / -> OrgResolver -> Layout -> pages
- `config.ts` — fetch Logto config from /platform/api/config
- `auth/useAuth.ts` — auth hook (isAuthenticated, logout, signIn)
- `auth/useOrganization.ts` — Zustand store for current tenant
- `auth/useScopes.ts` — decode JWT scopes, hasScope()
- `auth/ProtectedRoute.tsx` — guard (redirects to /login)
- `pages/DashboardPage.tsx` — tenant dashboard
- `pages/LicensePage.tsx` — license info
- `pages/AdminTenantsPage.tsx` — platform admin tenant management
### Custom Sign-in UI (`ui/sign-in/src/`)
- `SignInPage.tsx` — form with @cameleer/design-system components
- `experience-api.ts` — Logto Experience API client (4-step: init -> verify -> identify -> submit)
## Architecture Context
The existing cameleer3-server already has single-tenant auth (JWT, RBAC, bootstrap tokens, OIDC). The SaaS layer must:
@@ -26,12 +75,147 @@ The existing cameleer3-server already has single-tenant auth (JWT, RBAC, bootstr
- Proxy or federate access to tenant-specific cameleer3-server instances
- Enforce usage quotas and metered billing
### Routing (single-domain, path-based via Traefik)
All services on one hostname. Two env vars control everything: `PUBLIC_HOST` + `PUBLIC_PROTOCOL`.
| Path | Target | Notes |
|------|--------|-------|
| `/platform/*` | cameleer-saas:8080 | SPA + API (`server.servlet.context-path: /platform`) |
| `/server/*` | cameleer3-server-ui:80 | Server dashboard (strip-prefix + `BASE_PATH=/server`) |
| `/` | redirect -> `/platform/` | Via `docker/traefik-dynamic.yml` |
| `/*` (catch-all) | cameleer-logto:3001 (priority=1) | Custom sign-in UI, OIDC, interaction |
- SPA assets at `/_app/` (Vite `assetsDir: '_app'`) to avoid conflict with Logto's `/assets/`
- Logto `ENDPOINT` = `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}` (same domain, same origin)
- TLS: self-signed cert init container (`traefik-certs`) for dev, ACME for production
- Root `/` -> `/platform/` redirect via Traefik file provider (`docker/traefik-dynamic.yml`)
- LoginPage auto-redirects to Logto OIDC (no intermediate button)
### Docker Networks
Two networks in docker-compose.yml:
| Network | Name on Host | Purpose |
|---------|-------------|---------|
| `cameleer` | `cameleer-saas_cameleer` | Compose default — all services (DB, Logto, SaaS, server) |
| `cameleer-traefik` | `cameleer-traefik` (fixed `name:`) | Traefik + server + deployed app containers |
The `cameleer-traefik` network uses `name: cameleer-traefik` (no compose project prefix) so `DockerNetworkManager.ensureNetwork("cameleer-traefik")` in the server finds it. The server joins with DNS alias `cameleer3-server`, matching `CAMELEER_SERVER_URL=http://cameleer3-server:8081`. Per-environment networks (`cameleer-env-{slug}`) are created dynamically by the server's `DockerNetworkManager`.
### Custom sign-in UI (`ui/sign-in/`)
Separate Vite+React SPA replacing Logto's default sign-in page. Visually matches cameleer3-server LoginPage.
- Built as custom Logto Docker image (`cameleer-logto`): `ui/sign-in/Dockerfile` = node build stage + `FROM ghcr.io/logto-io/logto:latest` + COPY dist over `/etc/logto/packages/experience/dist/`
- Uses `@cameleer/design-system` components (Card, Input, Button, FormField, Alert)
- Authenticates via Logto Experience API (4-step: init -> verify password -> identify -> submit -> redirect)
- `CUSTOM_UI_PATH` env var does NOT work for Logto OSS — must volume-mount or replace the experience dist directory
- Favicon bundled in `ui/sign-in/public/favicon.svg` (served by Logto, not SaaS)
### Auth enforcement
- All API endpoints enforce OAuth2 scopes via `@PreAuthorize("hasAuthority('SCOPE_xxx')")` annotations
- Tenant isolation enforced by `TenantIsolationInterceptor` (a single `HandlerInterceptor` on `/api/**` that resolves JWT org_id to TenantContext and validates `{tenantId}`, `{environmentId}`, `{appId}` path variables; fail-closed, platform admins bypass)
- 13 OAuth2 scopes on the Logto API resource (`https://api.cameleer.local`): 10 platform scopes + 3 server scopes (`server:admin`, `server:operator`, `server:viewer`), served to the frontend from `GET /platform/api/config`
- Server scopes map to server RBAC roles via JWT `scope` claim (SaaS platform path) or `roles` claim (server-ui OIDC login path)
- 4-role model: `saas-vendor` (global, hosted only), org `owner` -> `server:admin`, org `operator` -> `server:operator`, org `viewer` -> `server:viewer`
- `saas-vendor` global role injected via `docker/vendor-seed.sh` (not standard bootstrap) — has `platform:admin` + all tenant scopes
- Custom `JwtDecoder` in `SecurityConfig.java` — ES384 algorithm, `at+jwt` token type, split issuer-uri (string validation) / jwk-set-uri (Docker-internal fetch), audience validation (`https://api.cameleer.local`)
- Logto Custom JWT (Phase 7b in bootstrap) injects a `roles` claim into access tokens based on org roles and global roles — this makes role data available to the server without Logto-specific code
### Server integration (cameleer3-server env vars)
| Env var | Value | Purpose |
|---------|-------|---------|
| `CAMELEER_OIDC_ISSUER_URI` | `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}/oidc` | Token issuer claim validation |
| `CAMELEER_OIDC_JWK_SET_URI` | `http://logto:3001/oidc/jwks` | Docker-internal JWK fetch |
| `CAMELEER_OIDC_TLS_SKIP_VERIFY` | `true` | Skip cert verify for OIDC discovery (dev only — disable in production) |
| `CAMELEER_CORS_ALLOWED_ORIGINS` | `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}` | Allow browser requests through Traefik |
| `BASE_PATH` (server-ui) | `/server` | React Router basename + `<base>` tag |
### Server runtime env vars (docker-compose.dev.yml)
| Env var | Value | Purpose |
|---------|-------|---------|
| `CAMELEER_RUNTIME_ENABLED` | `true` | Enable Docker orchestration |
| `CAMELEER_JAR_STORAGE_PATH` | `/data/jars` | Where JARs are stored inside server container |
| `CAMELEER_RUNTIME_BASE_IMAGE` | `gitea.siegeln.net/cameleer/cameleer-runtime-base:latest` | Base image for deployed apps |
| `CAMELEER_SERVER_URL` | `http://cameleer3-server:8081` | Server URL agents connect to |
| `CAMELEER_ROUTING_DOMAIN` | `${PUBLIC_HOST}` | Domain for Traefik routing labels |
| `CAMELEER_ROUTING_MODE` | `path` | `path` or `subdomain` routing |
| `CAMELEER_JAR_DOCKER_VOLUME` | `cameleer-saas_jardata` | Named volume for Docker-in-Docker JAR mounting |
### Server OIDC role extraction (two paths)
| Path | Token type | Role source | How it works |
|------|-----------|-------------|--------------|
| SaaS platform -> server API | Logto org-scoped access token | `scope` claim | `JwtAuthenticationFilter.extractRolesFromScopes()` reads `server:admin` from scope |
| Server-ui SSO login | Logto JWT access token (via Traditional Web App) | `roles` claim | `OidcTokenExchanger` decodes access_token, reads `roles` injected by Custom JWT |
The server's OIDC config (`OidcConfig`) includes `audience` (RFC 8707 resource indicator) and `additionalScopes`. The `audience` is sent as `resource` in both the authorization request and token exchange, which makes Logto return a JWT access token instead of opaque. The Custom JWT script maps org roles to `roles: ["server:admin"]`. If OIDC returns no roles and the user already exists, `syncOidcRoles` preserves existing local roles.
### Deployment pipeline
App deployment is handled by the cameleer3-server's `DeploymentExecutor` (7-stage async flow):
1. PRE_FLIGHT — validate config, check JAR exists
2. PULL_IMAGE — pull base image if missing
3. CREATE_NETWORK — ensure cameleer-traefik and cameleer-env-{slug} networks
4. START_REPLICAS — create N containers with Traefik labels
5. HEALTH_CHECK — poll `/cameleer/health` on agent port 9464
6. SWAP_TRAFFIC — stop old deployment (blue/green)
7. COMPLETE — mark RUNNING or DEGRADED
Key files:
- `DeploymentExecutor.java` (in cameleer3-server) — async staged deployment
- `DockerRuntimeOrchestrator.java` (in cameleer3-server) — Docker client, container lifecycle
- `docker/runtime-base/Dockerfile` — base image with agent JAR, maps env vars to `-D` system properties
- `ServerApiClient.java` — M2M token acquisition for SaaS->server API calls (agent status). Uses `X-Cameleer-Protocol-Version: 1` header
- Docker socket access: `group_add: ["0"]` in docker-compose.dev.yml (not root group membership in Dockerfile)
- Network: deployed containers join `cameleer-traefik` (routing) + `cameleer-env-{slug}` (isolation)
### Bootstrap (`docker/logto-bootstrap.sh`)
Idempotent script run via `logto-bootstrap` init container. Phases:
1. Wait for Logto + server health
2. Get Management API token (reads `m-default` secret from DB)
3. Create Logto apps (SPA, Traditional with `skipConsent`, M2M with Management API role + server API role)
3b. Create API resource scopes (10 platform + 3 server scopes)
4. Create org roles (owner, operator, viewer with API resource scope assignments) + M2M server role (`cameleer-m2m-server` with `server:admin` scope)
5. Create users (platform owner with Logto console access, viewer for testing read-only OIDC)
6. Create organization, add users with org roles (owner + viewer)
7. Configure cameleer3-server OIDC (`rolesClaim: "roles"`, `audience`, `defaultRoles: ["VIEWER"]`)
7b. Configure Logto Custom JWT for access tokens (maps org roles -> `roles` claim: admin->server:admin, member->server:viewer)
8. Configure Logto sign-in branding (Cameleer colors `#C6820E`/`#D4941E`, logo from `/platform/logo.svg`)
9. Cleanup seeded Logto apps
10. Write bootstrap results to `/data/logto-bootstrap.json`
Platform owner credentials (`SAAS_ADMIN_USER`/`SAAS_ADMIN_PASS`) work for both the SaaS platform and the Logto console (port 3002). The `saas-vendor` global role (hosted only) is created separately via `docker/vendor-seed.sh`.
## Database Migrations
PostgreSQL (Flyway): `src/main/resources/db/migration/`
- V001 — tenants (id, name, slug, tier, status, logto_org_id, stripe IDs, settings JSONB)
- V002 — licenses (id, tenant_id, tier, features JSONB, limits JSONB, expires_at)
- V003 — environments (tenant -> environments 1:N)
- V004 — api_keys (auth tokens for agent registration)
- V005 — apps (Camel applications)
- V006 — deployments (app versions, deployment history)
- V007 — audit_log
- V008 — app resource limits
- V010 — cleanup of migrated tables
## Related Conventions
- Gitea-hosted: `gitea.siegeln.net/cameleer/`
- CI: `.gitea/workflows/` — Gitea Actions
- K8s target: k3s cluster at 192.168.50.86
- Docker builds: multi-stage, buildx with registry cache, `--provenance=false` for Gitea compatibility
- Docker images: CI builds and pushes all images — Dockerfiles use multi-stage builds, no local builds needed
- `cameleer-saas` — SaaS app (frontend + JAR baked in)
- `cameleer-logto` — custom Logto with sign-in UI baked in
- `cameleer-runtime-base` — base image for deployed apps (agent JAR + JRE). CI downloads latest agent SNAPSHOT from Gitea Maven registry. Uses `CAMELEER_SERVER_URL` env var (not CAMELEER_EXPORT_ENDPOINT).
- Docker builds: `--no-cache`, `--provenance=false` for Gitea compatibility
- `docker-compose.dev.yml` — exposes ports for direct access, sets `SPRING_PROFILES_ACTIVE: dev`. Volume-mounts `./ui/dist` into the container so local UI builds are served without rebuilding the Docker image (`SPRING_WEB_RESOURCES_STATIC_LOCATIONS` overrides classpath). Adds Docker socket mount, jardata volume, and runtime env vars for container orchestration.
- Design system: import from `@cameleer/design-system` (Gitea npm registry)
## Disabled Skills

View File

@@ -1,15 +1,30 @@
# Dockerfile
FROM eclipse-temurin:21-jdk-alpine AS build
# syntax=docker/dockerfile:1
# Frontend: runs natively on build host
FROM --platform=$BUILDPLATFORM node:22-alpine AS frontend
ARG REGISTRY_TOKEN
WORKDIR /ui
COPY ui/package.json ui/package-lock.json ui/.npmrc ./
RUN --mount=type=cache,target=/root/.npm echo "//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${REGISTRY_TOKEN}" >> .npmrc && npm ci
COPY ui/ .
RUN npm run build
# Maven build: runs natively on build host (no QEMU emulation)
FROM --platform=$BUILDPLATFORM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /build
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw dependency:go-offline -B
# Cache deps — BuildKit cache mount persists across --no-cache builds
RUN --mount=type=cache,target=/root/.m2/repository ./mvnw dependency:go-offline -B || true
COPY src/ src/
RUN ./mvnw package -DskipTests -B
COPY --from=frontend /ui/dist/ src/main/resources/static/
RUN --mount=type=cache,target=/root/.m2/repository ./mvnw package -DskipTests -B
# Runtime: target platform (amd64)
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN addgroup -S cameleer && adduser -S cameleer -G cameleer
RUN addgroup -S cameleer && adduser -S cameleer -G cameleer \
&& mkdir -p /data/jars && chown -R cameleer:cameleer /data
COPY --from=build /build/target/*.jar app.jar
USER cameleer
EXPOSE 8080

389
HOWTO.md Normal file
View File

@@ -0,0 +1,389 @@
# Cameleer SaaS -- How to Install, Start & Bootstrap
## Quick Start (Development)
```bash
# 1. Clone
git clone https://gitea.siegeln.net/cameleer/cameleer-saas.git
cd cameleer-saas
# 2. Create environment file
cp .env.example .env
# 3. Generate Ed25519 key pair
mkdir -p keys
ssh-keygen -t ed25519 -f keys/ed25519 -N ""
mv keys/ed25519 keys/ed25519.key
# 4. Start the stack
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
# 5. Wait for services to be ready (~30s)
docker compose logs -f cameleer-saas --since 10s
# Look for: "Started CameleerSaasApplication"
# 6. Verify
curl http://localhost:8080/actuator/health
# {"status":"UP"}
```
## Prerequisites
- Docker Desktop (Windows/Mac) or Docker Engine 24+ (Linux)
- Git
- `curl` or any HTTP client (for testing)
## Architecture
The platform runs as a Docker Compose stack with 6 services:
| Service | Image | Port | Purpose |
|---------|-------|------|---------|
| **traefik** | traefik:v3 | 80, 443 | Reverse proxy, TLS, routing |
| **postgres** | postgres:16-alpine | 5432* | Platform database + Logto database |
| **logto** | ghcr.io/logto-io/logto | 3001*, 3002* | Identity provider (OIDC) |
| **cameleer-saas** | cameleer-saas:latest | 8080* | SaaS API server |
| **cameleer3-server** | cameleer3-server:latest | 8081 | Observability backend |
| **clickhouse** | clickhouse-server:latest | 8123* | Trace/metrics/log storage |
*Ports exposed to host only with `docker-compose.dev.yml` overlay.
## Installation
### 1. Environment Configuration
```bash
cp .env.example .env
```
Edit `.env` and set at minimum:
```bash
# Change in production
POSTGRES_PASSWORD=<strong-password>
CAMELEER_AUTH_TOKEN=<random-string-for-agent-bootstrap>
CAMELEER_TENANT_SLUG=<your-tenant-slug> # e.g., "acme" — tags all observability data
# Logto M2M credentials (get from Logto admin console after first boot)
LOGTO_M2M_CLIENT_ID=
LOGTO_M2M_CLIENT_SECRET=
```
### 2. Ed25519 Keys
The platform uses Ed25519 keys for license signing and machine token verification.
```bash
mkdir -p keys
ssh-keygen -t ed25519 -f keys/ed25519 -N ""
mv keys/ed25519 keys/ed25519.key
```
This creates `keys/ed25519.key` (private) and `keys/ed25519.pub` (public). The keys directory is mounted read-only into the cameleer-saas container.
If no key files are configured, the platform generates ephemeral keys on startup (suitable for development only -- keys change on every restart).
### 3. Start the Stack
**Development** (ports exposed for direct access):
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
```
**Production** (traffic routed through Traefik only):
```bash
docker compose up -d
```
### 4. Verify Services
```bash
# Health check
curl http://localhost:8080/actuator/health
# Check all containers are running
docker compose ps
```
## Bootstrapping
### First-Time Logto Setup
On first boot, Logto seeds its database automatically. Access the admin console to configure it:
1. Open http://localhost:3002 (Logto admin console)
2. Complete the initial setup wizard
3. Create a **Machine-to-Machine** application:
- Go to Applications > Create Application > Machine-to-Machine
- Note the **App ID** and **App Secret**
- Assign the **Logto Management API** resource with all scopes
4. Update `.env`:
```
LOGTO_M2M_CLIENT_ID=<app-id>
LOGTO_M2M_CLIENT_SECRET=<app-secret>
```
5. Restart cameleer-saas: `docker compose restart cameleer-saas`
### Create Your First Tenant
With a Logto user token (obtained via OIDC login flow):
```bash
TOKEN="<your-logto-jwt>"
# Create tenant
curl -X POST http://localhost:8080/api/tenants \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "My Company", "slug": "my-company", "tier": "MID"}'
# A "default" environment is auto-created with the tenant
```
### Generate a License
```bash
TENANT_ID="<uuid-from-above>"
curl -X POST "http://localhost:8080/api/tenants/$TENANT_ID/license" \
-H "Authorization: Bearer $TOKEN"
```
### Deploy a Camel Application
```bash
# List environments
curl "http://localhost:8080/api/tenants/$TENANT_ID/environments" \
-H "Authorization: Bearer $TOKEN"
ENV_ID="<default-environment-uuid>"
# Upload JAR and create app
curl -X POST "http://localhost:8080/api/environments/$ENV_ID/apps" \
-H "Authorization: Bearer $TOKEN" \
-F 'metadata={"slug":"order-service","displayName":"Order Service"};type=application/json' \
-F "file=@/path/to/your-camel-app.jar"
APP_ID="<app-uuid-from-response>"
# Deploy (async -- returns 202 with deployment ID)
curl -X POST "http://localhost:8080/api/apps/$APP_ID/deploy" \
-H "Authorization: Bearer $TOKEN"
DEPLOYMENT_ID="<deployment-uuid>"
# Poll deployment status
curl "http://localhost:8080/api/apps/$APP_ID/deployments/$DEPLOYMENT_ID" \
-H "Authorization: Bearer $TOKEN"
# Status transitions: BUILDING -> STARTING -> RUNNING (or FAILED)
# View container logs
curl "http://localhost:8080/api/apps/$APP_ID/logs?limit=50" \
-H "Authorization: Bearer $TOKEN"
# Stop the app
curl -X POST "http://localhost:8080/api/apps/$APP_ID/stop" \
-H "Authorization: Bearer $TOKEN"
```
### Enable Inbound HTTP Routing
If your Camel app exposes a REST endpoint, you can make it reachable from outside the stack:
```bash
# Set the port your app listens on (e.g., 8080 for Spring Boot)
curl -X PATCH "http://localhost:8080/api/environments/$ENV_ID/apps/$APP_ID/routing" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"exposedPort": 8080}'
```
Your app is now reachable at `http://{app-slug}.{env-slug}.{tenant-slug}.{domain}` (e.g., `http://order-service.default.my-company.localhost`). Traefik routes traffic automatically.
To disable routing, set `exposedPort` to `null`.
### View the Observability Dashboard
The cameleer3-server React SPA dashboard is available at:
```
http://localhost/dashboard
```
This shows execution traces, route topology graphs, metrics, and logs for all deployed apps. Authentication is required (Logto OIDC token via forward-auth).
### Check Agent & Observability Status
```bash
# Is the agent registered with cameleer3-server?
curl "http://localhost:8080/api/apps/$APP_ID/agent-status" \
-H "Authorization: Bearer $TOKEN"
# Returns: registered, state (ACTIVE/STALE/DEAD/UNKNOWN), routeIds
# Is the app producing observability data?
curl "http://localhost:8080/api/apps/$APP_ID/observability-status" \
-H "Authorization: Bearer $TOKEN"
# Returns: hasTraces, lastTraceAt, traceCount24h
```
## API Reference
### Tenants
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/tenants` | Create tenant |
| GET | `/api/tenants/{id}` | Get tenant |
| GET | `/api/tenants/by-slug/{slug}` | Get tenant by slug |
### Licensing
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/tenants/{tid}/license` | Generate license |
| GET | `/api/tenants/{tid}/license` | Get active license |
### Environments
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/tenants/{tid}/environments` | Create environment |
| GET | `/api/tenants/{tid}/environments` | List environments |
| GET | `/api/tenants/{tid}/environments/{eid}` | Get environment |
| PATCH | `/api/tenants/{tid}/environments/{eid}` | Rename environment |
| DELETE | `/api/tenants/{tid}/environments/{eid}` | Delete environment |
### Apps
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/environments/{eid}/apps` | Create app + upload JAR |
| GET | `/api/environments/{eid}/apps` | List apps |
| GET | `/api/environments/{eid}/apps/{aid}` | Get app |
| PUT | `/api/environments/{eid}/apps/{aid}/jar` | Re-upload JAR |
| PATCH | `/api/environments/{eid}/apps/{aid}/routing` | Set/clear exposed port |
| DELETE | `/api/environments/{eid}/apps/{aid}` | Delete app |
### Deployments
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/apps/{aid}/deploy` | Deploy app (async, 202) |
| GET | `/api/apps/{aid}/deployments` | Deployment history |
| GET | `/api/apps/{aid}/deployments/{did}` | Get deployment status |
| POST | `/api/apps/{aid}/stop` | Stop current deployment |
| POST | `/api/apps/{aid}/restart` | Restart app |
### Logs
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/apps/{aid}/logs` | Query container logs |
Query params: `since`, `until` (ISO timestamps), `limit` (default 500), `stream` (stdout/stderr/both)
### Observability
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/apps/{aid}/agent-status` | Agent registration status |
| GET | `/api/apps/{aid}/observability-status` | Trace/metrics data health |
### Dashboard
| Path | Description |
|------|-------------|
| `/dashboard` | cameleer3-server observability dashboard (forward-auth protected) |
### Health
| Method | Path | Description |
|--------|------|-------------|
| GET | `/actuator/health` | Health check (public) |
| GET | `/api/health/secured` | Authenticated health check |
## Tier Limits
| Tier | Environments | Apps | Retention | Features |
|------|-------------|------|-----------|----------|
| LOW | 1 | 3 | 7 days | Topology |
| MID | 2 | 10 | 30 days | + Lineage, Correlation |
| HIGH | Unlimited | 50 | 90 days | + Debugger, Replay |
| BUSINESS | Unlimited | Unlimited | 365 days | All features |
## Frontend Development
The SaaS management UI is a React SPA in the `ui/` directory.
### Setup
```bash
cd ui
npm install
```
### Dev Server
```bash
cd ui
npm run dev
```
The Vite dev server starts on http://localhost:5173 and proxies `/api` to `http://localhost:8080` (the Spring Boot backend). Run the backend in another terminal with `mvn spring-boot:run` or via Docker Compose.
### Environment Variables
| Variable | Purpose | Default |
|----------|---------|---------|
| `VITE_LOGTO_ENDPOINT` | Logto OIDC endpoint | `http://localhost:3001` |
| `VITE_LOGTO_CLIENT_ID` | Logto application client ID | (empty) |
Create a `ui/.env.local` file for local overrides:
```bash
VITE_LOGTO_ENDPOINT=http://localhost:3001
VITE_LOGTO_CLIENT_ID=your-client-id
```
### Production Build
```bash
cd ui
npm run build
```
Output goes to `src/main/resources/static/` (configured in `vite.config.ts`). The subsequent `mvn package` bundles the SPA into the JAR. In Docker builds, the Dockerfile handles this automatically via a multi-stage build.
### SPA Routing
Spring Boot serves `index.html` for all non-API routes via `SpaController.java`. React Router handles client-side routing. The SPA lives at `/`, while the observability dashboard (cameleer3-server) is at `/dashboard`.
## Development
### Running Tests
```bash
# Unit tests only (no Docker required)
mvn test -B -Dsurefire.excludes="**/*ControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java"
# Integration tests (requires Docker Desktop)
mvn test -B -Dtest="EnvironmentControllerTest,AppControllerTest,DeploymentControllerTest"
# All tests
mvn verify -B
```
### Building Locally
```bash
# Build JAR
mvn clean package -DskipTests -B
# Build Docker image
docker build -t cameleer-saas:local .
# Use local image
VERSION=local docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
```
## Troubleshooting
**Logto fails to start**: Check that PostgreSQL is healthy first. Logto needs the `logto` database created by `docker/init-databases.sh`. Run `docker compose logs logto` for details.
**cameleer-saas won't start**: Check `docker compose logs cameleer-saas`. Common issues:
- PostgreSQL not ready (wait for healthcheck)
- Flyway migration conflict (check for manual schema changes)
**Ephemeral key warnings**: `No Ed25519 key files configured -- generating ephemeral keys (dev mode)` is normal in development. For production, generate keys as described above.
**Container deployment fails**: Check that Docker socket is mounted (`/var/run/docker.sock`) and the `cameleer-runtime-base` image is available. Pull it with: `docker pull gitea.siegeln.net/cameleer/cameleer-runtime-base:latest`

View File

@@ -0,0 +1,269 @@
# Cameleer SaaS Platform UI Audit Findings
**Date:** 2026-04-09
**Auditor:** Claude Opus 4.6
**URL:** https://desktop-fb5vgj9.siegeln.internal/
**Credentials:** admin/admin
**Browser:** Playwright (Chromium)
---
## 1. Login Page (`/sign-in`)
**Screenshot:** `03-login-page.png`, `04-login-error.png`
### What works well
- Clean, centered card layout with consistent design system components
- Fun rotating subtitle taglines (e.g., "No ticket, no caravan") add personality
- Cameleer logo is displayed correctly
- Error handling works -- "Invalid username or password" alert appears on bad credentials (red alert banner)
- Sign in button is correctly disabled until both fields are populated
- Loading state on button during authentication
- Uses proper `autoComplete` attributes (`username`, `current-password`)
### Issues found
| Severity | Issue | Element |
|----------|-------|---------|
| Important | **No password visibility toggle** -- the Password input uses `type="password"` with no eye icon to reveal. Most modern login forms offer this. | Password field |
| Important | **Branding says "cameleer3"** not "Cameleer" or "Cameleer SaaS" -- the product name on the login page is the internal repo name, not the user-facing brand | `.logo` text content |
| Nice-to-have | **No "Forgot password" link** -- even if it goes to a "contact admin" page, users expect this | Below password field |
| Nice-to-have | **No Enter-key submit hint** -- though Enter does work via form submit, there's no visual affordance | Form area |
| Nice-to-have | **Page title is "Sign in -- cameleer3"** -- should match product branding ("Cameleer SaaS") | `<title>` tag |
---
## 2. Platform Dashboard (`/platform/`)
**Screenshots:** `05-platform-dashboard-loggedin.png`, `15-dashboard-desktop-1280.png`, `19-tenant-info-detail.png`, `20-kpi-strip-detail.png`
### What works well
- Clear tenant name as page heading ("Example Tenant")
- Tier badge next to tenant name provides immediate context
- KPI strip with Tier, Status, License cards is visually clean and well-structured
- License KPI card shows expiry date in green "expires 8.4.2027" trend indicator
- "Server Management" card provides clear description of what the server dashboard does
### Issues found
| Severity | Issue | Element |
|----------|-------|---------|
| **Critical** | **Label/value collision in Tenant Information card** -- "Slugdefault", "Created8.4.2026" have no visual separation between label and value. The source uses `flex justify-between` but the deployed Card component doesn't give the inner `div` full width, so items stack/collapse. | Tenant Information card |
| **Critical** | **"Open Server Dashboard" appears 3 times** on one page: (1) primary button in header area below tenant name, (2) "Server Management" card with secondary button, (3) sidebar footer link. This is redundant and clutters the page. Reduce to 1-2 locations max. | Header area, Server Management card, sidebar footer |
| Important | **Breadcrumb is always empty** -- the `breadcrumb` prop is passed as `[]`. Platform pages should have breadcrumbs like "Platform > Dashboard" or "Platform > License". | TopBar breadcrumb nav |
| Important | **Massive empty space below content** -- the dashboard only has ~4 cards but the page extends far below with blank white/cream space. The page feels sparse and "stub-like." | Below Server Management card |
| Important | **Tier badge color is misleading** -- "LOW" tier uses `primary` (orange) color, which doesn't convey it's the lowest/cheapest tier. The `tierColor()` function in DashboardPage maps to enterprise=success, pro=primary, starter=warning, but the actual data uses LOW/MID/HIGH/BUSINESS tiers (defined in LicensePage). Dashboard and License pages have different tier color mappings. | Tier badge |
| Important | **Status is shown redundantly** -- "ACTIVE" appears in (1) KPI strip Status card, (2) Tenant Information card with badge, and (3) header area badge. This is excessive for a single piece of information. | Multiple locations |
| Nice-to-have | **No tenant ID/slug in breadcrumb or subtitle** -- the slug "default" only appears buried in the Tenant Information card | Page header area |
---
## 3. License Page (`/platform/license`)
**Screenshots:** `06-license-page.png`, `07-license-token-revealed.png`, `16-license-features-detail.png`, `17-license-limits-detail.png`, `18-license-validity-detail.png`
### What works well
- Well-structured layout with logical sections (Validity, Features, Limits, License Token)
- Tier badge in header provides context
- Feature matrix clearly shows enabled vs disabled features
- "Days remaining" with color-coded badge (green for healthy, warning for <30 days, red for expired)
- Token show/hide toggle works correctly
- Token revealed in monospace code block with appropriate styling
### Issues found
| Severity | Issue | Element |
|----------|-------|---------|
| **Critical** | **Label/value collision in Validity section** -- "Issued8. April 2026" and "Expires8. April 2027" have no separation. Source code uses `flex items-center justify-between` but the flex container seems to not be stretching to full width. | Validity card rows |
| **Critical** | **Label/value collision in Limits section** -- "Max Agents3", "Retention Days7", "Max Environments1" have labels and values mashed together. Source uses `flex items-center justify-between` layout but the same rendering bug prevents proper spacing. | Limits card rows |
| Important | **No "Copy to clipboard" button** for the license token -- users need to manually select and copy. A copy button with confirmation toast is standard UX for tokens/secrets. | License Token section |
| Important | **Feature badge text mismatch** -- Source code says `'Not included'` for disabled features, but deployed version shows "DISABLED". This suggests the deployed build is out of sync with the source. | Features card badges |
| Important | **"Disabled" badge color** -- disabled features use `color='auto'` (which renders as a neutral/red-ish badge), while "Enabled" uses green. Consider using a muted gray for "Not included" to make it feel less like an error state. Red implies something is wrong, but a feature simply not being in the plan is not an error. | Features card disabled badges |
| Nice-to-have | **Limits values are not right-aligned** -- due to the label/value collision, the numeric values don't align in a column, making comparison harder | Limits card |
| Nice-to-have | **No units on limits** -- "Retention Days7" should be "7 days", "Max Agents3" should be "3 agents" or just "3" with clear formatting | Limits card values |
---
## 4. Admin Pages (`/platform/admin/tenants`)
**No screenshot available -- page returns HTTP error**
### Issues found
| Severity | Issue | Element |
|----------|-------|---------|
| **Critical** | **Admin page returns HTTP error (net::ERR_HTTP_RESPONSE_CODE_FAILURE)** -- navigating to `/platform/admin/tenants` fails with an HTTP error. The route exists in the router (`AdminTenantsPage`), but the admin section is not visible in the sidebar (no "Platform" item shown). | Admin route |
| Important | **Admin section not visible in sidebar** -- the `platform:admin` scope check in Layout.tsx hides the "Platform" sidebar item. Even though the user is "admin", they apparently don't have the `platform:admin` scope in their JWT. This may be intentional (scope not assigned) or a bug. | Sidebar Platform section |
| Important | **No graceful fallback for unauthorized admin access** -- if a user manually navigates to `/admin/tenants` without the scope, the page should show a "Not authorized" message rather than an HTTP error. | Admin route error handling |
---
## 5. Navigation
**Screenshots:** `21-sidebar-detail.png`, `12-sidebar-collapsed.png`
### What works well
- Clean sidebar with Cameleer SaaS branding and logo
- "Open Server Dashboard" in sidebar footer is a good location
- Sidebar has only 2 navigation items (Dashboard, License) which keeps it simple
### Issues found
| Severity | Issue | Element |
|----------|-------|---------|
| **Critical** | **No active state on sidebar navigation items** -- when on the Dashboard page, neither Dashboard nor License is highlighted/active. The sidebar uses `Sidebar.Section` components with `open={false}` as navigation links via `onToggle`, but `Section` is designed for expandable/collapsible groups, not navigation links. There is no visual indicator of the current page. | Sidebar items |
| Important | **Sidebar collapse doesn't work visually** -- clicking "Collapse sidebar" toggles the `active` state on the button but the sidebar doesn't visually collapse. The Layout component passes `collapsed={false}` as a hardcoded prop and `onCollapseToggle={() => {}}` as a no-op. | Sidebar collapse button |
| Important | **No clear distinction between "platform" and "server" levels** -- there's nothing in the sidebar header that says "Platform" vs "Server". The sidebar says "Cameleer SaaS" but when you switch to the server dashboard, it becomes a completely different app. A user might not understand the relationship. | Sidebar header |
| Nice-to-have | **"Open Server Dashboard" opens in new tab** -- `window.open('/server/', '_blank', 'noopener')` is used. While reasonable, there's no visual indicator (external link icon) that it will open a new tab. | Sidebar footer link, dashboard buttons |
---
## 6. Header Bar (TopBar)
**Screenshot:** `22-header-bar-detail.png`
### Issues found
| Severity | Issue | Element |
|----------|-------|---------|
| **Critical** | **Server-specific controls shown on platform pages** -- the TopBar always renders: (1) Search (Ctrl+K), (2) Status filters (OK/Warn/Error/Running), (3) Time range pills (1h/3h/6h/Today/24h/7d), (4) Auto-refresh toggle (MANUAL/AUTO). None of these are relevant to the platform dashboard or license page. They are observability controls designed for the server's exchange/route monitoring. | Entire TopBar filter area |
| Important | **Search button does nothing** -- clicking "Search..." on the platform does not open a search modal. The CommandPaletteProvider is likely not configured for the platform context. | Search button |
| Important | **Status filter buttons are interactive but meaningless** -- clicking OK/Warn/Error/Running on platform pages toggles state (global filter provider) but has no effect on the displayed content. | Status filter buttons |
| Important | **Time range selector is interactive but meaningless** -- similarly, changing the time range from 1h to 7d has no effect on platform pages. | Time range pills |
| Important | **Auto-refresh toggle is misleading** -- shows "MANUAL" toggle on platform pages where there's nothing to auto-refresh. | Auto-refresh button |
---
## 7. User Menu
**Screenshot:** `02-user-menu-dropdown.png`
### What works well
- User name "admin" and avatar initials "AD" displayed correctly
- Dropdown appears on click with Logout option
### Issues found
| Severity | Issue | Element |
|----------|-------|---------|
| Important | **User menu only has "Logout"** -- there's no "Profile", "Settings", "About", or "Switch Tenant" option. For a SaaS platform, users should at minimum see their role and tenant context. | User dropdown menu |
| Nice-to-have | **Avatar shows "AD" for "admin"** -- the Avatar component appears to use first 2 characters of the name. For "admin" this produces "AD" which looks like initials for a different name. | Avatar component |
---
## 8. Dark Mode
**Screenshots:** `08-dashboard-dark-mode.png`, `09-license-dark-mode.png`
### What works well
- Dark mode toggle works and applies globally
- Background transitions to dark brown/charcoal
- Text colors adapt appropriately
- Cards maintain visual distinction from background
- Design system tokens handle the switch smoothly
### Issues found
| Severity | Issue | Element |
|----------|-------|---------|
| Nice-to-have | **Dark mode is warm-toned (brown)** rather than the more common cool dark gray/charcoal. This is consistent with the design system's cameleer branding but may feel unusual to users accustomed to dark mode in other apps. | Global dark theme |
| Nice-to-have | **The same label/value collision issues appear in dark mode** -- these are layout bugs, not color bugs, so dark mode doesn't help or hurt. | Card content |
---
## 9. Responsiveness
**Screenshots:** `13-responsive-tablet.png`, `14-responsive-mobile.png`
### Issues found
| Severity | Issue | Element |
|----------|-------|---------|
| **Critical** | **Mobile layout is broken** -- at 375px width, the sidebar overlaps the main content. The KPI strip cards are truncated ("LO...", "AC..."). The header bar overflows. Content is unreadable. | Full page at mobile widths |
| Important | **Tablet layout (768px) is functional but crowded** -- sidebar takes significant width, header bar items are compressed ("Se..." for Search), but content is readable. KPI strip wraps correctly. | Full page at tablet widths |
| Important | **Sidebar doesn't collapse on mobile** -- there's no hamburger menu or responsive sidebar behavior. The sidebar is always visible, eating screen space on narrow viewports. | Sidebar |
---
## 10. Cross-cutting Concerns
### Loading States
- Dashboard and License pages both show a centered `Spinner` during loading -- this works well.
- `EmptyState` component used for "No tenant associated" and "License unavailable" -- good error handling in components.
### Error States
- Login page error handling is good (alert banner)
- No visible error boundary for unexpected errors on platform pages
- Admin route fails silently with HTTP error -- no user-facing error message
### Toast Notifications
- No toast notifications observed during the audit
- License token copy should trigger a toast confirmation (if a copy button existed)
### Confirmation Dialogs
- No destructive actions available on the platform (no delete/deactivate buttons) so no confirmation dialogs needed currently
---
## Summary of Issues by Severity
### Critical (5)
1. **Label/value collision** throughout Tenant Information card, License Validity, and License Limits sections -- labels and values run together without spacing
2. **"Open Server Dashboard" appears 3 times** on the dashboard page -- excessive redundancy
3. **No active state on sidebar navigation items** -- users can't tell which page they're on
4. **Server-specific header controls shown on platform pages** -- search, status filters, time range, auto-refresh are all meaningless on platform pages
5. **Mobile layout completely broken** -- sidebar overlaps content, content truncated
### Important (17)
1. No password visibility toggle on login
2. Branding says "cameleer3" instead of product name on login
3. Breadcrumbs always empty on platform pages
4. Massive empty space below dashboard content
5. Tier badge color mapping inconsistent between Dashboard and License pages
6. Status shown redundantly in 3 places on dashboard
7. No clipboard copy button for license token
8. Feature badge text mismatch between source and deployed build
9. "Disabled" badge uses red-ish color (implies error, not "not in plan")
10. Admin page returns HTTP error with no graceful fallback
11. Admin section invisible in sidebar despite being admin user
12. Sidebar collapse button doesn't work (no-op handler)
13. No clear platform vs server level distinction
14. Search button does nothing on platform
15. Status filters and time range interactive but meaningless on platform
16. User menu only has Logout (no profile/settings)
17. Sidebar doesn't collapse/hide on mobile
### Nice-to-have (8)
1. No "Forgot password" link on login
2. Login page title uses "cameleer3" branding
3. No external link icon on "Open Server Dashboard"
4. Avatar shows "AD" for "admin"
5. No units on limit values
6. Dark mode warm-toned (not standard cool dark)
7. No Enter-key submit hint
8. No tenant ID in breadcrumb/subtitle
---
## Overarching Assessment
The platform UI currently feels like a **thin shell** around the server dashboard. It has only 2 functioning pages (Dashboard and License), and both suffer from the same fundamental layout bug (label/value collision in Card components). The header bar is entirely borrowed from the server observability UI without any platform-specific adaptation, making 70% of the header controls irrelevant.
**Key architectural concerns:**
1. The TopBar component from the design system is monolithic -- it always renders server-specific controls (status filters, time range, search). The platform needs either a simplified TopBar variant or the ability to hide these sections.
2. The sidebar uses `Sidebar.Section` (expandable groups) as navigation links, which prevents active-state highlighting. It should use `Sidebar.Link` or a similar component.
3. The platform provides very little actionable functionality -- a user can view their tenant info and license, but can't manage anything. The "Server Management" card is just a link to another app.
**What works well overall:**
- Design system integration is solid (same look and feel as server)
- Dark mode works correctly
- Loading and error states are handled
- Login page is clean and functional
- KPI strip component is effective at summarizing key info
**Recommended priorities:**
1. Fix the label/value collision bug (affects 3 cards across 2 pages)
2. Hide or replace server-specific header controls on platform pages
3. Add sidebar active state and fix the collapse behavior
4. Add clipboard copy for license token
5. Fix mobile responsiveness

View File

@@ -0,0 +1,433 @@
# Cameleer SaaS UI — Source Code Audit Findings
**Audit date:** 2026-04-09
**Scope:** `ui/src/` (platform SPA) + `ui/sign-in/src/` (custom Logto sign-in)
**Design system:** `@cameleer/design-system@0.1.38`
---
## 1. Layout and Styling Patterns
### 1.1 Container Padding/Margin
All three page components use an identical outer wrapper pattern:
```tsx
// DashboardPage.tsx:67, LicensePage.tsx:82, AdminTenantsPage.tsx:60
<div className="space-y-6 p-6">
```
**Verdict:** Consistent across all pages. However, this padding is applied by each page individually rather than by the `Layout` component. If a new page omits `p-6`, the layout will be inconsistent. Consider moving container padding to the `Layout` component wrapping `<Outlet />`.
### 1.2 Use of Design System Components vs Custom HTML
| Component | DashboardPage | LicensePage | AdminTenantsPage |
|-----------|:---:|:---:|:---:|
| Badge | Yes | Yes | Yes |
| Button | Yes | - | - |
| Card | Yes | Yes | Yes |
| DataTable | - | - | Yes |
| EmptyState | Yes | Yes | - |
| KpiStrip | Yes | - | - |
| Spinner | Yes | Yes | Yes |
**Issues found:**
- **LicensePage.tsx:166-170** — Raw `<button>` for "Show token" / "Hide token" toggle instead of DS `Button variant="ghost"`:
```tsx
<button
type="button"
className="text-sm text-primary-400 hover:text-primary-300 underline underline-offset-2 focus:outline-none"
onClick={() => setTokenExpanded((v) => !v)}
>
```
This uses hardcoded Tailwind color classes (`text-primary-400`, `hover:text-primary-300`) instead of design tokens or a DS Button.
- **LicensePage.tsx:174** — Raw `<div>` + `<code>` for token display instead of DS `CodeBlock` (which is available and supports `copyable`):
```tsx
<div className="mt-2 rounded bg-white/5 border border-white/10 p-3 overflow-x-auto">
<code className="text-xs font-mono text-white/80 break-all">
{license.token}
</code>
</div>
```
- **AdminTenantsPage.tsx** — No empty state when `tenants` is empty. The DataTable renders with zero rows but no guidance for the admin.
### 1.3 Card/Section Grouping
- **DashboardPage** uses: KpiStrip + "Tenant Information" Card + "Server Management" Card. Good grouping.
- **LicensePage** uses: "Validity" Card + "Features" Card + "Limits" Card + "License Token" Card. Well-structured.
- **AdminTenantsPage** uses: single Card wrapping DataTable. Appropriate for a list view.
### 1.4 Typography
All pages use the same heading pattern:
```tsx
<h1 className="text-2xl font-semibold text-white">...</h1>
```
**Issue:** `text-white` is hardcoded rather than using a DS color token like `var(--text-primary)`. This will break if the design system ever supports a light theme (the DS has `ThemeProvider` and a theme toggle in the TopBar). The same pattern appears:
- `DashboardPage.tsx:73` — `text-white`
- `LicensePage.tsx:85` — `text-white`
- `AdminTenantsPage.tsx:62` — `text-white`
Similarly, muted text uses `text-white/60` and `text-white/80` throughout:
- `DashboardPage.tsx:96` — `text-white/80`
- `LicensePage.tsx:96,106,109` — `text-white/60`, `text-white`
- `LicensePage.tsx:129` — `text-sm text-white`
- `LicensePage.tsx:150` — `text-sm text-white/60`
These should use `var(--text-primary)` / `var(--text-secondary)` / `var(--text-muted)` from the design system.
### 1.5 Color Token Usage
**Positive:** The sign-in page CSS module (`SignInPage.module.css`) correctly uses DS variables:
```css
color: var(--text-primary); /* line 30 */
color: var(--text-muted); /* line 40 */
background: var(--bg-base); /* line 7 */
font-family: var(--font-body); /* line 20 */
```
**Negative:** The platform SPA pages bypass the design system's CSS variables entirely, using Tailwind utility classes with hardcoded dark-theme colors (`text-white`, `text-white/60`, `bg-white/5`, `border-white/10`, `divide-white/10`).
---
## 2. Interaction Patterns
### 2.1 Button Placement and Order
- **DashboardPage.tsx:81-87** — "Open Server Dashboard" button is top-right (standard). Also repeated inside a Card at line 119-125. Two identical CTAs on the same page is redundant.
- No forms exist in the platform pages. No create/edit/delete operations are exposed in the UI (read-only dashboard).
### 2.2 Confirmation Dialogs for Destructive Actions
- The DS provides `ConfirmDialog` and `AlertDialog` — neither is used anywhere.
- **AdminTenantsPage.tsx:47-57** — Row click silently switches tenant context and navigates to `/`. No confirmation dialog for context switching, which could be disorienting. The user clicks a row in the admin table, and their entire session context changes.
### 2.3 Loading States
All pages use the same loading pattern — centered `<Spinner />` in a fixed-height container:
```tsx
<div className="flex items-center justify-center h-64">
<Spinner />
</div>
```
**Issues:**
- Full-page auth loading screens (LoginPage, CallbackPage, ProtectedRoute, OrgResolver) use inline styles instead of Tailwind:
```tsx
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
```
This is inconsistent with the page components which use Tailwind classes.
- The `main.tsx` app bootstrap loading (line 59) also uses inline styles. Six files use this identical inline style pattern — it should be a shared component or consistent class.
- No `Skeleton` components are used anywhere, despite the DS providing `Skeleton`. For the dashboard and license pages which fetch data, skeletons would give better perceived performance than a generic spinner.
### 2.4 Error Handling
- **API client (`api/client.ts`):** Errors are thrown as generic `Error` objects. No toast notifications on failure.
- **LicensePage.tsx:63-69** — Shows `EmptyState` for `isError`. Good.
- **DashboardPage.tsx** — No error state handling at all. If `useTenant()` or `useLicense()` fails, the page renders with fallback `-` values silently. No `isError` check.
- **AdminTenantsPage.tsx** — No error state. If `useAllTenants()` fails, falls through to rendering the table with empty data.
- **OrgResolver.tsx:88-89** — On error, renders `null` (blank screen). The user sees nothing — no error message, no retry option, no redirect. This is the worst error UX in the app.
- No component imports or uses `useToast()` from the DS. Toasts are never shown for any operation.
### 2.5 Empty States
- **DashboardPage.tsx:57-63** — `EmptyState` for no tenant. Good.
- **LicensePage.tsx:54-60** — `EmptyState` for no tenant. Good.
- **LicensePage.tsx:63-69** — `EmptyState` for license fetch error. Good.
- **AdminTenantsPage.tsx** — **Missing.** No empty state when `tenants` array is empty. DataTable will render an empty table body.
---
## 3. Component Usage
### 3.1 DS Imports by File
| File | DS Components Imported |
|------|----------------------|
| `main.tsx` | ThemeProvider, ToastProvider, BreadcrumbProvider, GlobalFilterProvider, CommandPaletteProvider, Spinner |
| `Layout.tsx` | AppShell, Sidebar, TopBar |
| `DashboardPage.tsx` | Badge, Button, Card, EmptyState, KpiStrip, Spinner |
| `LicensePage.tsx` | Badge, Card, EmptyState, Spinner |
| `AdminTenantsPage.tsx` | Badge, Card, DataTable, Spinner + Column type |
| `LoginPage.tsx` | Spinner |
| `CallbackPage.tsx` | Spinner |
| `ProtectedRoute.tsx` | Spinner |
| `OrgResolver.tsx` | Spinner |
| `SignInPage.tsx` (sign-in) | Card, Input, Button, Alert, FormField |
### 3.2 Available but Unused DS Components
These DS components are relevant to the platform UI but unused:
| Component | Could be used for |
|-----------|------------------|
| `AlertDialog` / `ConfirmDialog` | Confirming tenant context switch in AdminTenantsPage |
| `CodeBlock` | License token display (currently raw HTML) |
| `Skeleton` | Loading states instead of spinner |
| `Tooltip` | Badge hover explanations, info about features |
| `StatusDot` | Tenant status indicators |
| `Breadcrumb` / `useBreadcrumb` | Page navigation context (currently empty `[]`) |
| `LoginForm` | Could replace the custom sign-in form (DS already has one) |
| `useToast` | Error/success notifications |
### 3.3 Raw HTML Where DS Components Exist
1. **LicensePage.tsx:166-170** — Raw `<button>` instead of `Button variant="ghost"`
2. **LicensePage.tsx:174-178** — Raw `<div><code>` instead of `CodeBlock`
3. **Layout.tsx:26-62** — Four inline SVG icon components instead of using `lucide-react` icons (the DS depends on lucide-react)
4. **DashboardPage.tsx:95-112** — Manual label/value list with `<div className="flex justify-between">` instead of using a DS pattern (the DS has no explicit key-value list component, so this is acceptable)
### 3.4 Styling Approach
- **Platform SPA pages:** Tailwind CSS utility classes (via class names like `space-y-6`, `p-6`, `flex`, `items-center`, etc.)
- **Sign-in page:** CSS modules (`SignInPage.module.css`) with DS CSS variables
- **Auth loading screens:** Inline `style={{}}` objects
- **No CSS modules** in the platform SPA at all (zero `.module.css` files in `ui/src/`)
This is a three-way inconsistency: Tailwind in pages, CSS modules in sign-in, inline styles in auth components.
---
## 4. Navigation
### 4.1 Sidebar
**File:** `ui/src/components/Layout.tsx:70-118`
The sidebar uses `Sidebar.Section` with `open={false}` and `{null}` children as a workaround to make sections act as navigation links (via `onToggle`). This is a semantic misuse — sections are designed as collapsible containers, not nav links.
```tsx
<Sidebar.Section
icon={<DashboardIcon />}
label="Dashboard"
open={false}
onToggle={() => navigate('/')}
>
{null}
</Sidebar.Section>
```
**Issues:**
- No `active` state is set on any section. The DS supports `active?: boolean` on `SidebarSectionProps` (line 988 of DS types), but it's never passed. The user has no visual indicator of which page they're on.
- `collapsed={false}` is hardcoded with `onCollapseToggle={() => {}}` — the sidebar cannot be collapsed. This is a no-op handler.
- Only three nav items: Dashboard, License, Platform (admin-only). Very sparse.
### 4.2 "Open Server Dashboard"
Two implementations, both identical:
1. **Sidebar footer** (`Layout.tsx:112-116`): `Sidebar.FooterLink` with `window.open('/server/', '_blank', 'noopener')`
2. **Dashboard page** (`DashboardPage.tsx:84`): Primary Button, same `window.open` call
3. **Dashboard page** (`DashboardPage.tsx:120-125`): Secondary Button in a Card, same `window.open` call
Three separate "Open Server Dashboard" triggers on the dashboard. The footer link is good; the two dashboard buttons are redundant.
### 4.3 Breadcrumbs
**File:** `Layout.tsx:124` — `<TopBar breadcrumb={[]} ... />`
Breadcrumbs are permanently empty. The DS provides `useBreadcrumb()` hook (exported, see line 1255 of DS types) that pages can call to set page-specific breadcrumbs, but none of the pages use it. The TopBar renders an empty breadcrumb area.
### 4.4 User Menu / Avatar
**File:** `Layout.tsx:125-126`
```tsx
<TopBar
user={username ? { name: username } : undefined}
onLogout={logout}
/>
```
The TopBar's `user` prop triggers a `Dropdown` with only a "Logout" option. The avatar is rendered by the DS using the `Avatar` component with the user's name.
**Issue:** When `username` is `null` (common if the Logto ID token doesn't have `username`, `name`, or `email` claims), no user indicator is shown at all — no avatar, no logout button. The user has no way to log out from the UI.
---
## 5. Header Bar
### 5.1 Shared TopBar with Server
The platform SPA and the server SPA both use the same `TopBar` component from `@cameleer/design-system`. This means they share identical header chrome.
### 5.2 Irrelevant Controls on Platform Pages
**Critical issue.** The `TopBar` component (DS source, lines 5569-5588 of `index.es.js`) **always** renders:
1. **Status filter pills** (Completed, Warning, Error, Running) — `ButtonGroup` with global filter status values
2. **Time range dropdown** — `TimeRangeDropdown` with presets like "Last 1h", "Last 24h"
3. **Auto-refresh toggle** — "AUTO" / "MANUAL" button
4. **Theme toggle** — Light/dark mode switch
5. **Command palette search** — "Search... Ctrl+K" button
These controls are hardcoded in the DS `TopBar` component. They read from `useGlobalFilters()` and operate on exchange status filters and time ranges — concepts that are **completely irrelevant** to the SaaS platform pages (Dashboard, License, Admin Tenants).
The platform wraps everything in `GlobalFilterProvider` (in `main.tsx:96`), which initializes the filter state, but nothing in the platform UI reads or uses these filters. They are dead UI elements that confuse users.
**Recommendation:** Either:
- The DS should make these controls optional/configurable on `TopBar`
- The platform should use a simpler header component
- The platform should not wrap in `GlobalFilterProvider` / `CommandPaletteProvider` (but this may cause runtime errors if TopBar assumes they exist)
---
## 6. Specific Issues
### 6.1 Label/Value Formatting — "Slugdefault" Concatenation Bug
**Not found in source code.** The source code properly formats label/value pairs with `flex justify-between` layout:
```tsx
// DashboardPage.tsx:96-99
<div className="flex justify-between text-white/80">
<span>Slug</span>
<span className="font-mono">{tenant?.slug ?? '-'}</span>
</div>
```
If "Slugdefault" concatenation is visible in the UI, it's a **rendering/CSS issue** rather than a template bug — the `flex justify-between` may collapse if the container is too narrow, or there may be a DS Card padding issue causing the spans to not separate. The code itself has proper separation.
Similarly for limits on the License page:
```tsx
// LicensePage.tsx:147-155
<span className="text-sm text-white/60">{label}</span>
<span className="text-sm font-mono text-white">{value !== undefined ? value : '—'}</span>
```
Labels and values are in separate `<span>` elements within `flex justify-between` containers. The code is correct.
### 6.2 Badge Colors
**Feature badges (LicensePage.tsx:130-133):**
```tsx
<Badge
label={enabled ? 'Enabled' : 'Not included'}
color={enabled ? 'success' : 'auto'}
/>
```
- Enabled features: `color="success"` (green) — appropriate
- Disabled features: `color="auto"` — this uses the DS's auto-color logic (hash-based). For a disabled/not-included state, `color="error"` or a neutral muted variant would be more appropriate to clearly communicate "not available."
**Tenant status badges (DashboardPage.tsx:102-105, AdminTenantsPage.tsx:24-29):**
```tsx
color={tenant?.status === 'ACTIVE' ? 'success' : 'warning'}
color={row.status === 'ACTIVE' ? 'success' : 'warning'}
```
- ACTIVE: green — appropriate
- Anything else (SUSPENDED, PENDING): yellow/warning — reasonable but SUSPENDED should arguably be `error` (red)
**Tier badges:** Use `tierColor()` function but it's defined differently in each file:
- `DashboardPage.tsx:12-18` maps: enterprise->success, pro->primary, starter->warning
- `LicensePage.tsx:25-33` maps: BUSINESS->success, HIGH->primary, MID->warning, LOW->error
These use **different tier names** (enterprise/pro/starter vs BUSINESS/HIGH/MID/LOW). One is for tenant tiers, the other for license tiers, but the inconsistency suggests either the data model has diverged or one mapping is stale.
### 6.3 Sign-In Page (`ui/sign-in/src/`)
**Positive findings:**
- Uses DS components: `Card`, `Input`, `Button`, `Alert`, `FormField`
- Uses CSS modules with DS CSS variables (`var(--bg-base)`, `var(--text-primary)`, etc.)
- Proper form with `aria-label="Sign in"`, `autoComplete` attributes
- Loading state on submit button via `loading` prop
- Error display via DS `Alert variant="error"`
- Creative rotating subtitle strings — good personality touch
**Issues:**
1. **No `ThemeProvider` wrapper** (`sign-in/src/main.tsx`):
```tsx
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
```
The sign-in page imports `@cameleer/design-system/style.css` which provides CSS variable defaults, so it works. But the theme toggle won't function, and if the DS ever requires `ThemeProvider` for initialization, this will break.
2. **No `ToastProvider`** — if any DS component internally uses `useToast()`, it will throw.
3. **Hardcoded branding** (`SignInPage.tsx:61`):
```tsx
cameleer3
```
The brand name is hardcoded text, not sourced from configuration.
4. **`React` import unused** (`SignInPage.tsx:1`): `useMemo` and `useState` are imported from `react` but the `import React` default import is absent, which is fine for React 19.
5. **No "forgot password" flow** — the form has username + password only. No recovery link. The DS `LoginForm` component supports `onForgotPassword` and `onSignUp` callbacks.
---
## 7. Architecture Observations
### 7.1 Provider Stack Over-provisioning
`main.tsx` wraps the app in:
```
ThemeProvider > ToastProvider > BreadcrumbProvider > GlobalFilterProvider > CommandPaletteProvider
```
`GlobalFilterProvider` and `CommandPaletteProvider` are server-dashboard concepts (exchange status filters, time range, search). They are unused by any platform page but are required because `TopBar` reads from them internally. This creates coupling between the server's observability UI concerns and the SaaS platform pages.
### 7.2 Route Guard Nesting
The route structure is:
```
ProtectedRoute > OrgResolver > Layout > (pages)
```
`OrgResolver` fetches `/api/me` and resolves tenant context. If it fails (`isError`), it renders `null` — a blank screen inside the Layout shell. This means the sidebar and TopBar render but the content area is completely empty with no explanation.
### 7.3 Unused Import
- `LicensePage.tsx:1` imports `React` and `useState` — `React` import is not needed with React 19's JSX transform, and `useState` is used so that's fine. But `React` as a namespace import isn't used.
### 7.4 DataTable Requires `id` Field
`AdminTenantsPage.tsx:67` passes `tenants` to `DataTable`. The DS type requires `T extends { id: string }`. The `TenantResponse` type has `id: string`, so this works, but the `createdAt` column (line 31) renders the raw ISO timestamp string without formatting — unlike DashboardPage which formats it with `toLocaleDateString()`.
---
## 8. Summary of Issues by Severity
### High Priority
| # | Issue | File(s) | Line(s) |
|---|-------|---------|---------|
| H1 | TopBar shows irrelevant status filters, time range, auto-refresh for platform pages | `Layout.tsx` / DS `TopBar` | 122-128 |
| H2 | OrgResolver error state renders blank screen (no error UI) | `OrgResolver.tsx` | 88-89 |
| H3 | Hardcoded `text-white` colors break light theme | All pages | Multiple |
### Medium Priority
| # | Issue | File(s) | Line(s) |
|---|-------|---------|---------|
| M1 | No active state on sidebar navigation items | `Layout.tsx` | 79-108 |
| M2 | Breadcrumbs permanently empty | `Layout.tsx` | 124 |
| M3 | DashboardPage has no error handling for failed API calls | `DashboardPage.tsx` | 23-26 |
| M4 | AdminTenantsPage missing empty state | `AdminTenantsPage.tsx` | 67-72 |
| M5 | AdminTenantsPage row click silently switches tenant context | `AdminTenantsPage.tsx` | 47-57 |
| M6 | Toasts never used despite ToastProvider being mounted | All pages | - |
| M7 | Raw `<button>` and `<code>` instead of DS components in LicensePage | `LicensePage.tsx` | 166-178 |
| M8 | AdminTenantsPage `createdAt` column renders raw ISO string | `AdminTenantsPage.tsx` | 31 |
| M9 | `tierColor()` defined twice with different tier mappings | `DashboardPage.tsx`, `LicensePage.tsx` | 12-18, 25-33 |
| M10 | "Not included" feature badge uses `color="auto"` instead of muted/neutral | `LicensePage.tsx` | 133 |
### Low Priority
| # | Issue | File(s) | Line(s) |
|---|-------|---------|---------|
| L1 | Three "Open Server Dashboard" buttons/links on dashboard | `Layout.tsx`, `DashboardPage.tsx` | 112-116, 81-87, 119-125 |
| L2 | Inconsistent loading style (inline styles vs Tailwind) | Auth files vs pages | Multiple |
| L3 | No Skeleton loading used (all Spinner) | All pages | - |
| L4 | Sidebar collapse disabled (no-op handler) | `Layout.tsx` | 71 |
| L5 | Sign-in page missing ThemeProvider wrapper | `sign-in/src/main.tsx` | 6-9 |
| L6 | Sign-in page has no forgot-password or sign-up link | `sign-in/src/SignInPage.tsx` | - |
| L7 | Custom SVG icons in Layout instead of lucide-react | `Layout.tsx` | 26-62 |
| L8 | Username null = no logout button visible | `Layout.tsx` | 125-126 |
| L9 | Page padding `p-6` repeated per-page instead of in Layout | All pages | - |

View File

@@ -8,14 +8,41 @@ services:
logto:
ports:
- "3001:3001"
- "3002:3002"
cameleer-saas:
ports:
- "8080:8080"
volumes:
- ./ui/dist:/app/static
environment:
SPRING_PROFILES_ACTIVE: dev
SPRING_WEB_RESOURCES_STATIC_LOCATIONS: file:/app/static/,classpath:/static/
cameleer3-server:
ports:
- "8081:8081"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- jardata:/data/jars
group_add:
- "0"
environment:
CAMELEER_RUNTIME_ENABLED: "true"
CAMELEER_JAR_STORAGE_PATH: /data/jars
CAMELEER_RUNTIME_BASE_IMAGE: gitea.siegeln.net/cameleer/cameleer-runtime-base:latest
CAMELEER_DOCKER_NETWORK: cameleer-saas_cameleer
CAMELEER_SERVER_URL: http://cameleer3-server:8081
CAMELEER_ROUTING_DOMAIN: ${PUBLIC_HOST:-localhost}
CAMELEER_ROUTING_MODE: path
CAMELEER_JAR_DOCKER_VOLUME: cameleer-saas_jardata
cameleer3-server-ui:
ports:
- "8082:80"
clickhouse:
ports:
- "8123:8123"
volumes:
jardata:

View File

@@ -1,16 +1,44 @@
services:
traefik-certs:
image: alpine:latest
restart: "no"
entrypoint: ["sh", "-c"]
command:
- |
if [ ! -f /certs/cert.pem ]; then
apk add --no-cache openssl >/dev/null 2>&1
openssl req -x509 -newkey rsa:4096 \
-keyout /certs/key.pem -out /certs/cert.pem \
-days 365 -nodes \
-subj "/CN=$$PUBLIC_HOST" \
-addext "subjectAltName=DNS:$$PUBLIC_HOST,DNS:*.$$PUBLIC_HOST"
echo "Generated self-signed cert for $$PUBLIC_HOST"
else
echo "Certs already exist, skipping"
fi
environment:
PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
volumes:
- certs:/certs
traefik:
image: traefik:v3
restart: unless-stopped
depends_on:
traefik-certs:
condition: service_completed_successfully
ports:
- "80:80"
- "443:443"
- "3002:3002"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik.yml:/etc/traefik/traefik.yml:ro
- acme:/etc/traefik/acme
- ./docker/traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
- certs:/etc/traefik/certs:ro
networks:
- cameleer
- cameleer-traefik
postgres:
image: postgres:16-alpine
@@ -23,7 +51,7 @@ services:
- pgdata:/var/lib/postgresql/data
- ./docker/init-databases.sh:/docker-entrypoint-initdb.d/init-databases.sh:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-cameleer}"]
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-cameleer} -d ${POSTGRES_DB:-cameleer_saas}"]
interval: 5s
timeout: 5s
retries: 5
@@ -31,7 +59,7 @@ services:
- cameleer
logto:
image: ghcr.io/logto-io/logto:latest
image: ${LOGTO_IMAGE:-gitea.siegeln.net/cameleer/cameleer-logto}:${VERSION:-latest}
restart: unless-stopped
depends_on:
postgres:
@@ -39,13 +67,67 @@ services:
entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm start"]
environment:
DB_URL: postgres://${POSTGRES_USER:-cameleer}:${POSTGRES_PASSWORD:-cameleer_dev}@postgres:5432/logto
ENDPOINT: ${LOGTO_ENDPOINT:-http://localhost:3001}
ADMIN_ENDPOINT: ${LOGTO_ADMIN_ENDPOINT:-http://localhost:3002}
ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
ADMIN_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:3002
TRUST_PROXY_HEADER: 1
NODE_TLS_REJECT_UNAUTHORIZED: "0" # dev only — accept self-signed cert for internal OIDC discovery
healthcheck:
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3001/oidc/.well-known/openid-configuration', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\""]
interval: 5s
timeout: 5s
retries: 30
start_period: 15s
labels:
- traefik.enable=true
- traefik.http.routers.logto.rule=PathPrefix(`/oidc`) || PathPrefix(`/interaction`)
- traefik.http.routers.logto.rule=PathPrefix(`/`)
- traefik.http.routers.logto.priority=1
- traefik.http.routers.logto.entrypoints=websecure
- traefik.http.routers.logto.tls=true
- traefik.http.routers.logto.service=logto
- traefik.http.routers.logto.middlewares=logto-cors
- traefik.http.middlewares.logto-cors.headers.accessControlAllowOriginList=${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:3002
- traefik.http.middlewares.logto-cors.headers.accessControlAllowMethods=GET,POST,PUT,PATCH,DELETE,OPTIONS
- traefik.http.middlewares.logto-cors.headers.accessControlAllowHeaders=Authorization,Content-Type
- traefik.http.middlewares.logto-cors.headers.accessControlAllowCredentials=true
- traefik.http.services.logto.loadbalancer.server.port=3001
- traefik.http.routers.logto-console.rule=PathPrefix(`/`)
- traefik.http.routers.logto-console.entrypoints=admin-console
- traefik.http.routers.logto-console.tls=true
- traefik.http.routers.logto-console.service=logto-console
- traefik.http.services.logto-console.loadbalancer.server.port=3002
networks:
- cameleer
logto-bootstrap:
image: postgres:16-alpine
depends_on:
logto:
condition: service_healthy
cameleer3-server:
condition: service_healthy
restart: "no"
entrypoint: ["sh", "/scripts/logto-bootstrap.sh"]
environment:
LOGTO_ENDPOINT: http://logto:3001
LOGTO_ADMIN_ENDPOINT: http://logto:3002
LOGTO_PUBLIC_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
PUBLIC_PROTOCOL: ${PUBLIC_PROTOCOL:-https}
PG_HOST: postgres
PG_USER: ${POSTGRES_USER:-cameleer}
PG_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
PG_DB_SAAS: ${POSTGRES_DB:-cameleer_saas}
SAAS_ADMIN_USER: ${SAAS_ADMIN_USER:-admin}
SAAS_ADMIN_PASS: ${SAAS_ADMIN_PASS:-admin}
TENANT_ADMIN_USER: ${TENANT_ADMIN_USER:-camel}
TENANT_ADMIN_PASS: ${TENANT_ADMIN_PASS:-camel}
CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}
SERVER_ENDPOINT: http://cameleer3-server:8081
SERVER_UI_USER: ${CAMELEER_UI_USER:-admin}
SERVER_UI_PASS: ${CAMELEER_UI_PASSWORD:-admin}
volumes:
- ./docker/logto-bootstrap.sh:/scripts/logto-bootstrap.sh:ro
- bootstrapdata:/data
networks:
- cameleer
@@ -55,30 +137,27 @@ services:
depends_on:
postgres:
condition: service_healthy
logto-bootstrap:
condition: service_completed_successfully
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./keys:/etc/cameleer/keys:ro
- jardata:/data/jars
- bootstrapdata:/data/bootstrap:ro
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-cameleer_saas}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
LOGTO_ENDPOINT: ${LOGTO_ENDPOINT:-http://logto:3001}
LOGTO_ISSUER_URI: ${LOGTO_ISSUER_URI:-http://logto:3001/oidc}
LOGTO_JWK_SET_URI: ${LOGTO_JWK_SET_URI:-http://logto:3001/oidc/jwks}
LOGTO_PUBLIC_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
LOGTO_ISSUER_URI: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}/oidc
LOGTO_JWK_SET_URI: ${LOGTO_ENDPOINT:-http://logto:3001}/oidc/jwks
LOGTO_M2M_CLIENT_ID: ${LOGTO_M2M_CLIENT_ID:-}
LOGTO_M2M_CLIENT_SECRET: ${LOGTO_M2M_CLIENT_SECRET:-}
CAMELEER_JWT_PRIVATE_KEY_PATH: ${CAMELEER_JWT_PRIVATE_KEY_PATH:-}
CAMELEER_JWT_PUBLIC_KEY_PATH: ${CAMELEER_JWT_PUBLIC_KEY_PATH:-}
CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}
CAMELEER3_SERVER_ENDPOINT: http://cameleer3-server:8081
CLICKHOUSE_URL: jdbc:clickhouse://clickhouse:8123/cameleer
labels:
- traefik.enable=true
- traefik.http.routers.api.rule=PathPrefix(`/api`)
- traefik.http.services.api.loadbalancer.server.port=8080
- traefik.http.routers.forwardauth.rule=Path(`/auth/verify`)
- traefik.http.services.forwardauth.loadbalancer.server.port=8080
- traefik.http.routers.saas.rule=PathPrefix(`/platform`)
- traefik.http.routers.saas.entrypoints=websecure
- traefik.http.routers.saas.tls=true
- traefik.http.services.saas.loadbalancer.server.port=8080
networks:
- cameleer
@@ -91,16 +170,50 @@ services:
clickhouse:
condition: service_started
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-cameleer_saas}
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/cameleer3
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
CLICKHOUSE_URL: jdbc:clickhouse://clickhouse:8123/cameleer
CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}
CAMELEER_JWT_SECRET: ${CAMELEER_JWT_SECRET:-cameleer-dev-jwt-secret-change-in-production}
CAMELEER_TENANT_ID: ${CAMELEER_TENANT_SLUG:-default}
CAMELEER_OIDC_ISSUER_URI: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}/oidc
CAMELEER_OIDC_JWK_SET_URI: ${LOGTO_ENDPOINT:-http://logto:3001}/oidc/jwks
CAMELEER_OIDC_TLS_SKIP_VERIFY: "true" # dev only — disable in production with real certs
CAMELEER_OIDC_AUDIENCE: ${CAMELEER_OIDC_AUDIENCE:-https://api.cameleer.local}
CAMELEER_CORS_ALLOWED_ORIGINS: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8081/api/v1/health || exit 1"]
interval: 5s
timeout: 5s
retries: 30
start_period: 15s
labels:
- traefik.enable=false
networks:
cameleer:
cameleer-traefik:
aliases:
- cameleer3-server
cameleer3-server-ui:
image: ${CAMELEER3_SERVER_UI_IMAGE:-gitea.siegeln.net/cameleer/cameleer3-server-ui}:${VERSION:-latest}
restart: unless-stopped
depends_on:
cameleer3-server:
condition: service_healthy
environment:
CAMELEER_API_URL: http://cameleer3-server:8081
BASE_PATH: /server
labels:
- traefik.enable=true
- traefik.http.routers.observe.rule=PathPrefix(`/observe`)
- traefik.http.routers.observe.middlewares=forward-auth
- traefik.http.middlewares.forward-auth.forwardauth.address=http://cameleer-saas:8080/auth/verify
- traefik.http.middlewares.forward-auth.forwardauth.authResponseHeaders=X-Tenant-Id,X-User-Id,X-User-Email
- traefik.http.services.observe.loadbalancer.server.port=8080
- traefik.http.routers.server-ui.rule=PathPrefix(`/server`)
- traefik.http.routers.server-ui.entrypoints=websecure
- traefik.http.routers.server-ui.tls=true
- traefik.http.routers.server-ui.middlewares=server-ui-strip
- traefik.http.middlewares.server-ui-strip.stripprefix.prefixes=/server
- traefik.http.routers.server-ui.service=server-ui
- traefik.http.services.server-ui.loadbalancer.server.port=80
networks:
- cameleer
@@ -109,6 +222,8 @@ services:
restart: unless-stopped
volumes:
- chdata:/var/lib/clickhouse
- ./docker/clickhouse-init.sql:/docker-entrypoint-initdb.d/init.sql:ro
- ./docker/clickhouse-users.xml:/etc/clickhouse-server/users.d/default-user.xml:ro
healthcheck:
test: ["CMD-SHELL", "clickhouse-client --query 'SELECT 1'"]
interval: 10s
@@ -120,9 +235,12 @@ services:
networks:
cameleer:
driver: bridge
cameleer-traefik:
name: cameleer-traefik
driver: bridge
volumes:
pgdata:
chdata:
acme:
jardata:
certs:
bootstrapdata:

View File

@@ -0,0 +1 @@
CREATE DATABASE IF NOT EXISTS cameleer;

View File

@@ -0,0 +1,9 @@
<clickhouse>
<users>
<default>
<networks>
<ip>::/0</ip>
</networks>
</default>
</users>
</clickhouse>

View File

@@ -1,7 +1,9 @@
#!/bin/bash
set -e
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
CREATE DATABASE logto;
CREATE DATABASE cameleer3;
GRANT ALL PRIVILEGES ON DATABASE logto TO $POSTGRES_USER;
GRANT ALL PRIVILEGES ON DATABASE cameleer3 TO $POSTGRES_USER;
EOSQL

726
docker/logto-bootstrap.sh Normal file
View File

@@ -0,0 +1,726 @@
#!/bin/sh
set -e
# Cameleer SaaS — Bootstrap Script
# Creates Logto apps, users, organizations, roles.
# Seeds cameleer_saas DB with tenant, environment, license.
# Configures cameleer3-server OIDC.
# Idempotent: checks existence before creating.
LOGTO_ENDPOINT="${LOGTO_ENDPOINT:-http://logto:3001}"
LOGTO_ADMIN_ENDPOINT="${LOGTO_ADMIN_ENDPOINT:-http://logto:3002}"
LOGTO_PUBLIC_ENDPOINT="${LOGTO_PUBLIC_ENDPOINT:-http://localhost:3001}"
MGMT_API_RESOURCE="https://default.logto.app/api"
BOOTSTRAP_FILE="/data/logto-bootstrap.json"
PG_HOST="${PG_HOST:-postgres}"
PG_USER="${PG_USER:-cameleer}"
PG_DB_LOGTO="logto"
PG_DB_SAAS="${PG_DB_SAAS:-cameleer_saas}"
# App names
SPA_APP_NAME="Cameleer SaaS"
M2M_APP_NAME="Cameleer SaaS Backend"
TRAD_APP_NAME="Cameleer Dashboard"
API_RESOURCE_INDICATOR="https://api.cameleer.local"
API_RESOURCE_NAME="Cameleer SaaS API"
# Users (configurable via env vars)
SAAS_ADMIN_USER="${SAAS_ADMIN_USER:-admin}"
SAAS_ADMIN_PASS="${SAAS_ADMIN_PASS:-admin}"
TENANT_ADMIN_USER="${TENANT_ADMIN_USER:-camel}"
TENANT_ADMIN_PASS="${TENANT_ADMIN_PASS:-camel}"
# Tenant config
TENANT_NAME="Example Tenant"
TENANT_SLUG="default"
BOOTSTRAP_TOKEN="${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}"
# Server config
SERVER_ENDPOINT="${SERVER_ENDPOINT:-http://cameleer3-server:8081}"
SERVER_UI_USER="${SERVER_UI_USER:-admin}"
SERVER_UI_PASS="${SERVER_UI_PASS:-admin}"
# Redirect URIs (derived from PUBLIC_HOST and PUBLIC_PROTOCOL)
HOST="${PUBLIC_HOST:-localhost}"
PROTO="${PUBLIC_PROTOCOL:-https}"
SPA_REDIRECT_URIS="[\"${PROTO}://${HOST}/platform/callback\"]"
SPA_POST_LOGOUT_URIS="[\"${PROTO}://${HOST}/platform/login\"]"
TRAD_REDIRECT_URIS="[\"${PROTO}://${HOST}/oidc/callback\",\"${PROTO}://${HOST}/server/oidc/callback\"]"
TRAD_POST_LOGOUT_URIS="[\"${PROTO}://${HOST}\",\"${PROTO}://${HOST}/server\",\"${PROTO}://${HOST}/server/login?local\"]"
log() { echo "[bootstrap] $1"; }
pgpass() { PGPASSWORD="${PG_PASSWORD:-cameleer_dev}"; export PGPASSWORD; }
# Install jq + curl
apk add --no-cache jq curl >/dev/null 2>&1
# Read cached secrets from previous run
if [ -f "$BOOTSTRAP_FILE" ]; then
CACHED_M2M_SECRET=$(jq -r '.m2mClientSecret // empty' "$BOOTSTRAP_FILE" 2>/dev/null)
CACHED_TRAD_SECRET=$(jq -r '.tradAppSecret // empty' "$BOOTSTRAP_FILE" 2>/dev/null)
CACHED_SPA_ID=$(jq -r '.spaClientId // empty' "$BOOTSTRAP_FILE" 2>/dev/null)
log "Found cached bootstrap file"
if [ -n "$CACHED_M2M_SECRET" ] && [ -n "$CACHED_SPA_ID" ]; then
log "Bootstrap already complete — skipping. Delete $BOOTSTRAP_FILE to force re-run."
exit 0
fi
fi
# ============================================================
# PHASE 1: Wait for services
# ============================================================
log "Waiting for Logto..."
for i in $(seq 1 60); do
if curl -sf "${LOGTO_ENDPOINT}/oidc/.well-known/openid-configuration" >/dev/null 2>&1; then
log "Logto is ready."
break
fi
[ "$i" -eq 60 ] && { log "ERROR: Logto not ready after 60s"; exit 1; }
sleep 1
done
log "Waiting for cameleer3-server..."
for i in $(seq 1 60); do
if curl -sf "${SERVER_ENDPOINT}/api/v1/health" >/dev/null 2>&1; then
log "cameleer3-server is ready."
break
fi
[ "$i" -eq 60 ] && { log "WARNING: cameleer3-server not ready after 60s — skipping OIDC config"; }
sleep 1
done
# ============================================================
# PHASE 2: Get Management API token
# ============================================================
log "Reading m-default secret from database..."
pgpass
M_DEFAULT_SECRET=$(psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB_LOGTO" -t -A -c \
"SELECT secret FROM applications WHERE id = 'm-default' AND tenant_id = 'admin';")
[ -z "$M_DEFAULT_SECRET" ] && { log "ERROR: m-default app not found"; exit 1; }
get_admin_token() {
curl -s -X POST "${LOGTO_ADMIN_ENDPOINT}/oidc/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "Host: ${HOST}:3002" \
-H "X-Forwarded-Proto: https" \
-d "grant_type=client_credentials&client_id=${1}&client_secret=${2}&resource=${MGMT_API_RESOURCE}&scope=all"
}
get_default_token() {
curl -s -X POST "${LOGTO_ENDPOINT}/oidc/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "Host: ${HOST}" \
-d "grant_type=client_credentials&client_id=${1}&client_secret=${2}&resource=${MGMT_API_RESOURCE}&scope=all"
}
log "Getting Management API token..."
TOKEN_RESPONSE=$(get_admin_token "m-default" "$M_DEFAULT_SECRET")
TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token' 2>/dev/null)
[ -z "$TOKEN" ] || [ "$TOKEN" = "null" ] && { log "ERROR: Failed to get token"; exit 1; }
log "Got Management API token."
# Verify Management API is fully ready (Logto may still be initializing internally)
log "Verifying Management API is responsive..."
for i in $(seq 1 30); do
VERIFY_RESPONSE=$(curl -s -H "Authorization: Bearer $TOKEN" -H "Host: ${HOST}" "${LOGTO_ENDPOINT}/api/roles" 2>/dev/null)
if echo "$VERIFY_RESPONSE" | jq -e 'type == "array"' >/dev/null 2>&1; then
log "Management API is ready."
break
fi
[ "$i" -eq 30 ] && { log "ERROR: Management API not responsive after 30s"; exit 1; }
sleep 1
done
# --- Helper: Logto API calls ---
api_get() {
curl -s -H "Authorization: Bearer $TOKEN" -H "Host: ${HOST}" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || echo "[]"
}
api_post() {
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -H "Host: ${HOST}" \
-d "$2" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true
}
api_put() {
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -H "Host: ${HOST}" \
-d "$2" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true
}
api_delete() {
curl -s -X DELETE -H "Authorization: Bearer $TOKEN" -H "Host: ${HOST}" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true
}
api_patch() {
curl -s -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -H "Host: ${HOST}" \
-d "$2" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true
}
# ============================================================
# PHASE 3: Create Logto applications
# ============================================================
EXISTING_APPS=$(api_get "/api/applications")
# --- SPA app (for SaaS frontend) ---
SPA_ID=$(echo "$EXISTING_APPS" | jq -r ".[] | select(.name == \"$SPA_APP_NAME\" and .type == \"SPA\") | .id")
if [ -n "$SPA_ID" ]; then
log "SPA app exists: $SPA_ID"
else
log "Creating SPA app..."
SPA_RESPONSE=$(api_post "/api/applications" "{
\"name\": \"$SPA_APP_NAME\",
\"type\": \"SPA\",
\"oidcClientMetadata\": {
\"redirectUris\": $SPA_REDIRECT_URIS,
\"postLogoutRedirectUris\": $SPA_POST_LOGOUT_URIS
}
}")
SPA_ID=$(echo "$SPA_RESPONSE" | jq -r '.id')
log "Created SPA app: $SPA_ID"
fi
# --- Traditional Web App (for cameleer3-server OIDC) ---
TRAD_ID=$(echo "$EXISTING_APPS" | jq -r ".[] | select(.name == \"$TRAD_APP_NAME\" and .type == \"Traditional\") | .id")
TRAD_SECRET=""
if [ -n "$TRAD_ID" ]; then
log "Traditional app exists: $TRAD_ID"
TRAD_SECRET="${CACHED_TRAD_SECRET:-}"
else
log "Creating Traditional Web app..."
TRAD_RESPONSE=$(api_post "/api/applications" "{
\"name\": \"$TRAD_APP_NAME\",
\"type\": \"Traditional\",
\"oidcClientMetadata\": {
\"redirectUris\": $TRAD_REDIRECT_URIS,
\"postLogoutRedirectUris\": $TRAD_POST_LOGOUT_URIS
}
}")
TRAD_ID=$(echo "$TRAD_RESPONSE" | jq -r '.id')
TRAD_SECRET=$(echo "$TRAD_RESPONSE" | jq -r '.secret')
[ "$TRAD_SECRET" = "null" ] && TRAD_SECRET=""
log "Created Traditional app: $TRAD_ID"
fi
# Enable skip consent for the Traditional app (first-party SSO)
api_put "/api/applications/$TRAD_ID" '{"isThirdParty": false, "customClientMetadata": {"alwaysIssueRefreshToken": true, "skipConsent": true}}' >/dev/null 2>&1
log "Traditional app: skip consent enabled."
# --- API resource ---
EXISTING_RESOURCES=$(api_get "/api/resources")
API_RESOURCE_ID=$(echo "$EXISTING_RESOURCES" | jq -r ".[] | select(.indicator == \"$API_RESOURCE_INDICATOR\") | .id")
if [ -n "$API_RESOURCE_ID" ]; then
log "API resource exists: $API_RESOURCE_ID"
else
log "Creating API resource..."
RESOURCE_RESPONSE=$(api_post "/api/resources" "{
\"name\": \"$API_RESOURCE_NAME\",
\"indicator\": \"$API_RESOURCE_INDICATOR\"
}")
API_RESOURCE_ID=$(echo "$RESOURCE_RESPONSE" | jq -r '.id')
log "Created API resource: $API_RESOURCE_ID"
fi
# ============================================================
# PHASE 3b: Create API resource scopes
# ============================================================
log "Creating API resource scopes..."
EXISTING_SCOPES=$(api_get "/api/resources/${API_RESOURCE_ID}/scopes")
create_scope() {
local name="$1"
local desc="$2"
local existing_id=$(echo "$EXISTING_SCOPES" | jq -r ".[] | select(.name == \"$name\") | .id")
if [ -n "$existing_id" ]; then
log " Scope '$name' exists: $existing_id" >&2
echo "$existing_id"
else
local resp=$(api_post "/api/resources/${API_RESOURCE_ID}/scopes" "{\"name\": \"$name\", \"description\": \"$desc\"}")
local new_id=$(echo "$resp" | jq -r '.id')
log " Created scope '$name': $new_id" >&2
echo "$new_id"
fi
}
# Platform-level scope
SCOPE_PLATFORM_ADMIN=$(create_scope "platform:admin" "SaaS platform administration")
# Tenant-level scopes
SCOPE_TENANT_MANAGE=$(create_scope "tenant:manage" "Manage tenant settings")
SCOPE_BILLING_MANAGE=$(create_scope "billing:manage" "Manage billing")
SCOPE_TEAM_MANAGE=$(create_scope "team:manage" "Manage team members")
SCOPE_APPS_MANAGE=$(create_scope "apps:manage" "Create and delete apps")
SCOPE_APPS_DEPLOY=$(create_scope "apps:deploy" "Deploy apps")
SCOPE_SECRETS_MANAGE=$(create_scope "secrets:manage" "Manage secrets")
SCOPE_OBSERVE_READ=$(create_scope "observe:read" "View observability data")
SCOPE_OBSERVE_DEBUG=$(create_scope "observe:debug" "Debug and replay operations")
SCOPE_SETTINGS_MANAGE=$(create_scope "settings:manage" "Manage settings")
# Server-level scopes (mapped to server RBAC roles via JWT scope claim)
SCOPE_SERVER_ADMIN=$(create_scope "server:admin" "Full server access")
SCOPE_SERVER_OPERATOR=$(create_scope "server:operator" "Deploy and manage apps in server")
SCOPE_SERVER_VIEWER=$(create_scope "server:viewer" "Read-only server observability")
# Collect scope IDs for role assignment
# Owner: full tenant control
OWNER_SCOPE_IDS="\"$SCOPE_TENANT_MANAGE\",\"$SCOPE_BILLING_MANAGE\",\"$SCOPE_TEAM_MANAGE\",\"$SCOPE_APPS_MANAGE\",\"$SCOPE_APPS_DEPLOY\",\"$SCOPE_SECRETS_MANAGE\",\"$SCOPE_OBSERVE_READ\",\"$SCOPE_OBSERVE_DEBUG\",\"$SCOPE_SETTINGS_MANAGE\",\"$SCOPE_SERVER_ADMIN\""
# Operator: app lifecycle + observability (no billing/team/secrets/settings)
OPERATOR_SCOPE_IDS="\"$SCOPE_APPS_MANAGE\",\"$SCOPE_APPS_DEPLOY\",\"$SCOPE_OBSERVE_READ\",\"$SCOPE_OBSERVE_DEBUG\",\"$SCOPE_SERVER_OPERATOR\""
# Viewer: read-only observability
VIEWER_SCOPE_IDS="\"$SCOPE_OBSERVE_READ\",\"$SCOPE_SERVER_VIEWER\""
# Vendor (saas-vendor global role): platform:admin + all tenant scopes
ALL_SCOPE_IDS="\"$SCOPE_PLATFORM_ADMIN\",$OWNER_SCOPE_IDS"
# --- M2M app ---
M2M_ID=$(echo "$EXISTING_APPS" | jq -r ".[] | select(.name == \"$M2M_APP_NAME\" and .type == \"MachineToMachine\") | .id")
M2M_SECRET=""
if [ -n "$M2M_ID" ]; then
log "M2M app exists: $M2M_ID"
M2M_SECRET="${CACHED_M2M_SECRET:-}"
else
log "Creating M2M app..."
M2M_RESPONSE=$(api_post "/api/applications" "{
\"name\": \"$M2M_APP_NAME\",
\"type\": \"MachineToMachine\"
}")
M2M_ID=$(echo "$M2M_RESPONSE" | jq -r '.id')
M2M_SECRET=$(echo "$M2M_RESPONSE" | jq -r '.secret')
log "Created M2M app: $M2M_ID"
# Assign Management API role
log "Assigning Management API access to M2M app..."
pgpass
MGMT_RESOURCE_ID=$(psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB_LOGTO" -t -A -c \
"SELECT id FROM resources WHERE indicator = '$MGMT_API_RESOURCE' AND tenant_id = 'default';")
if [ -n "$MGMT_RESOURCE_ID" ]; then
SCOPE_IDS=$(psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB_LOGTO" -t -A -c \
"SELECT json_agg(id) FROM scopes WHERE resource_id = '$MGMT_RESOURCE_ID' AND tenant_id = 'default';" | tr -d '[:space:]')
ROLE_RESPONSE=$(api_post "/api/roles" "{
\"name\": \"cameleer-m2m-management\",
\"description\": \"Full Management API access for Cameleer SaaS\",
\"type\": \"MachineToMachine\",
\"scopeIds\": $SCOPE_IDS
}")
ROLE_ID=$(echo "$ROLE_RESPONSE" | jq -r '.id')
if [ -n "$ROLE_ID" ] && [ "$ROLE_ID" != "null" ]; then
api_post "/api/roles/$ROLE_ID/applications" "{\"applicationIds\": [\"$M2M_ID\"]}" >/dev/null
log "Assigned Management API role to M2M app."
VERIFY=$(get_default_token "$M2M_ID" "$M2M_SECRET")
VERIFY_TOKEN=$(echo "$VERIFY" | jq -r '.access_token')
if [ -n "$VERIFY_TOKEN" ] && [ "$VERIFY_TOKEN" != "null" ]; then
log "Verified M2M app works."
else
log "WARNING: M2M verification failed"
M2M_SECRET=""
fi
fi
fi
fi
# Create M2M role for the Cameleer API resource (server:admin access) — idempotent
EXISTING_M2M_SERVER_ROLE=$(api_get "/api/roles" | jq -r '.[] | select(.name == "cameleer-m2m-server") | .id')
if [ -z "$EXISTING_M2M_SERVER_ROLE" ]; then
log "Creating M2M server access role..."
SERVER_M2M_ROLE_RESPONSE=$(api_post "/api/roles" "{
\"name\": \"cameleer-m2m-server\",
\"description\": \"Server API access for SaaS backend (M2M)\",
\"type\": \"MachineToMachine\",
\"scopeIds\": [\"$SCOPE_SERVER_ADMIN\"]
}")
EXISTING_M2M_SERVER_ROLE=$(echo "$SERVER_M2M_ROLE_RESPONSE" | jq -r '.id')
fi
if [ -n "$EXISTING_M2M_SERVER_ROLE" ] && [ "$EXISTING_M2M_SERVER_ROLE" != "null" ] && [ -n "$M2M_ID" ]; then
api_post "/api/roles/$EXISTING_M2M_SERVER_ROLE/applications" "{\"applicationIds\": [\"$M2M_ID\"]}" >/dev/null 2>&1
log "Assigned server API role to M2M app: $EXISTING_M2M_SERVER_ROLE"
fi
# ============================================================
# PHASE 4: Create roles
# ============================================================
# --- Organization roles: owner, operator, viewer ---
# Note: platform-admin / saas-vendor global role is NOT created here.
# It is injected via docker/vendor-seed.sh on the hosted SaaS environment only.
log "Creating organization roles..."
EXISTING_ORG_ROLES=$(api_get "/api/organization-roles")
ORG_OWNER_ROLE_ID=$(echo "$EXISTING_ORG_ROLES" | jq -r '.[] | select(.name == "owner") | .id')
if [ -n "$ORG_OWNER_ROLE_ID" ]; then
log "Org owner role exists: $ORG_OWNER_ROLE_ID"
else
ORG_OWNER_RESPONSE=$(api_post "/api/organization-roles" "{
\"name\": \"owner\",
\"description\": \"Platform owner — full tenant control\"
}")
ORG_OWNER_ROLE_ID=$(echo "$ORG_OWNER_RESPONSE" | jq -r '.id')
log "Created org owner role: $ORG_OWNER_ROLE_ID"
fi
ORG_OPERATOR_ROLE_ID=$(echo "$EXISTING_ORG_ROLES" | jq -r '.[] | select(.name == "operator") | .id')
if [ -z "$ORG_OPERATOR_ROLE_ID" ]; then
ORG_OPERATOR_RESPONSE=$(api_post "/api/organization-roles" "{
\"name\": \"operator\",
\"description\": \"Operator — manage apps, deploy, observe\"
}")
ORG_OPERATOR_ROLE_ID=$(echo "$ORG_OPERATOR_RESPONSE" | jq -r '.id')
log "Created org operator role: $ORG_OPERATOR_ROLE_ID"
fi
ORG_VIEWER_ROLE_ID=$(echo "$EXISTING_ORG_ROLES" | jq -r '.[] | select(.name == "viewer") | .id')
if [ -z "$ORG_VIEWER_ROLE_ID" ]; then
ORG_VIEWER_RESPONSE=$(api_post "/api/organization-roles" "{
\"name\": \"viewer\",
\"description\": \"Viewer — read-only observability\"
}")
ORG_VIEWER_ROLE_ID=$(echo "$ORG_VIEWER_RESPONSE" | jq -r '.id')
log "Created org viewer role: $ORG_VIEWER_ROLE_ID"
fi
# Assign API resource scopes to org roles (these appear in org-scoped resource tokens)
log "Assigning API resource scopes to organization roles..."
api_put "/api/organization-roles/${ORG_OWNER_ROLE_ID}/resource-scopes" "{\"scopeIds\": [$OWNER_SCOPE_IDS]}" >/dev/null 2>&1
api_put "/api/organization-roles/${ORG_OPERATOR_ROLE_ID}/resource-scopes" "{\"scopeIds\": [$OPERATOR_SCOPE_IDS]}" >/dev/null 2>&1
api_put "/api/organization-roles/${ORG_VIEWER_ROLE_ID}/resource-scopes" "{\"scopeIds\": [$VIEWER_SCOPE_IDS]}" >/dev/null 2>&1
log "API resource scopes assigned to organization roles."
# ============================================================
# PHASE 5: Create users
# ============================================================
# --- Platform Owner ---
log "Checking for platform owner user '$SAAS_ADMIN_USER'..."
ADMIN_USER_ID=$(api_get "/api/users?search=$SAAS_ADMIN_USER" | jq -r ".[] | select(.username == \"$SAAS_ADMIN_USER\") | .id")
if [ -n "$ADMIN_USER_ID" ]; then
log "Platform owner exists: $ADMIN_USER_ID"
else
log "Creating platform owner '$SAAS_ADMIN_USER'..."
ADMIN_RESPONSE=$(api_post "/api/users" "{
\"username\": \"$SAAS_ADMIN_USER\",
\"password\": \"$SAAS_ADMIN_PASS\",
\"name\": \"Platform Owner\"
}")
ADMIN_USER_ID=$(echo "$ADMIN_RESPONSE" | jq -r '.id')
log "Created platform owner: $ADMIN_USER_ID"
# No global role assigned — owner role is org-scoped.
# SaaS vendor role is injected via docker/vendor-seed.sh on hosted environments.
fi
# --- Grant SaaS admin Logto console access (admin tenant, port 3002) ---
log "Granting SaaS admin Logto console access..."
# Get admin-tenant M2M token (m-default token has wrong audience for port 3002)
ADMIN_MGMT_RESOURCE="https://admin.logto.app/api"
log "Reading m-admin secret from database..."
M_ADMIN_SECRET=$(psql -h "$PG_HOST" -U "$PG_USER" -d "$PG_DB_LOGTO" -t -A -c \
"SELECT secret FROM applications WHERE id = 'm-admin' AND tenant_id = 'admin';" 2>/dev/null)
if [ -z "$M_ADMIN_SECRET" ]; then
log "WARNING: m-admin app not found — skipping console access"
else
ADMIN_TOKEN_RESPONSE=$(curl -s -X POST "${LOGTO_ADMIN_ENDPOINT}/oidc/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "Host: ${HOST}:3002" \
-H "X-Forwarded-Proto: https" \
-d "grant_type=client_credentials&client_id=m-admin&client_secret=${M_ADMIN_SECRET}&resource=${ADMIN_MGMT_RESOURCE}&scope=all")
ADMIN_TOKEN=$(echo "$ADMIN_TOKEN_RESPONSE" | jq -r '.access_token' 2>/dev/null)
if [ -z "$ADMIN_TOKEN" ] || [ "$ADMIN_TOKEN" = "null" ]; then
log "WARNING: Failed to get admin tenant token — skipping console access"
log "Response: $(echo "$ADMIN_TOKEN_RESPONSE" | head -c 200)"
else
log "Got admin tenant token."
# Admin-tenant API helpers (port 3002, admin token)
admin_api_get() {
curl -s -H "Authorization: Bearer $ADMIN_TOKEN" -H "Host: ${HOST}:3002" -H "X-Forwarded-Proto: https" "${LOGTO_ADMIN_ENDPOINT}${1}" 2>/dev/null || echo "[]"
}
admin_api_post() {
curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" -H "Host: ${HOST}:3002" -H "X-Forwarded-Proto: https" \
-d "$2" "${LOGTO_ADMIN_ENDPOINT}${1}" 2>/dev/null || true
}
admin_api_patch() {
curl -s -X PATCH -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" -H "Host: ${HOST}:3002" -H "X-Forwarded-Proto: https" \
-d "$2" "${LOGTO_ADMIN_ENDPOINT}${1}" 2>/dev/null || true
}
# Check if admin user already exists on admin tenant
ADMIN_TENANT_USER_ID=$(admin_api_get "/api/users?search=$SAAS_ADMIN_USER" | jq -r ".[] | select(.username == \"$SAAS_ADMIN_USER\") | .id" 2>/dev/null)
if [ -z "$ADMIN_TENANT_USER_ID" ] || [ "$ADMIN_TENANT_USER_ID" = "null" ]; then
log "Creating admin console user '$SAAS_ADMIN_USER'..."
ADMIN_TENANT_RESPONSE=$(admin_api_post "/api/users" "{
\"username\": \"$SAAS_ADMIN_USER\",
\"password\": \"$SAAS_ADMIN_PASS\",
\"name\": \"Platform Admin\"
}")
ADMIN_TENANT_USER_ID=$(echo "$ADMIN_TENANT_RESPONSE" | jq -r '.id')
log "Created admin console user: $ADMIN_TENANT_USER_ID"
else
log "Admin console user exists: $ADMIN_TENANT_USER_ID"
fi
if [ -n "$ADMIN_TENANT_USER_ID" ] && [ "$ADMIN_TENANT_USER_ID" != "null" ]; then
# Assign both 'user' (required base role) and 'default:admin' (Management API access)
ADMIN_USER_ROLE_ID=$(admin_api_get "/api/roles" | jq -r '.[] | select(.name == "user") | .id')
ADMIN_ROLE_ID=$(admin_api_get "/api/roles" | jq -r '.[] | select(.name == "default:admin") | .id')
ROLE_IDS_JSON="[]"
if [ -n "$ADMIN_USER_ROLE_ID" ] && [ "$ADMIN_USER_ROLE_ID" != "null" ]; then
ROLE_IDS_JSON=$(echo "$ROLE_IDS_JSON" | jq ". + [\"$ADMIN_USER_ROLE_ID\"]")
fi
if [ -n "$ADMIN_ROLE_ID" ] && [ "$ADMIN_ROLE_ID" != "null" ]; then
ROLE_IDS_JSON=$(echo "$ROLE_IDS_JSON" | jq ". + [\"$ADMIN_ROLE_ID\"]")
fi
if [ "$ROLE_IDS_JSON" != "[]" ]; then
admin_api_post "/api/users/$ADMIN_TENANT_USER_ID/roles" "{\"roleIds\": $ROLE_IDS_JSON}" >/dev/null 2>&1
log "Assigned admin tenant roles (user + default:admin)."
else
log "WARNING: admin tenant roles not found"
fi
# Add to t-default organization with admin role
admin_api_post "/api/organizations/t-default/users" "{\"userIds\": [\"$ADMIN_TENANT_USER_ID\"]}" >/dev/null 2>&1
TENANT_ADMIN_ORG_ROLE_ID=$(admin_api_get "/api/organization-roles" | jq -r '.[] | select(.name == "admin") | .id')
if [ -n "$TENANT_ADMIN_ORG_ROLE_ID" ] && [ "$TENANT_ADMIN_ORG_ROLE_ID" != "null" ]; then
admin_api_post "/api/organizations/t-default/users/$ADMIN_TENANT_USER_ID/roles" "{\"organizationRoleIds\": [\"$TENANT_ADMIN_ORG_ROLE_ID\"]}" >/dev/null 2>&1
log "Added to t-default organization with admin role."
fi
# Switch admin tenant sign-in mode from Register to SignIn (user already created)
admin_api_patch "/api/sign-in-exp" '{"signInMode": "SignIn"}' >/dev/null 2>&1
log "Set admin tenant sign-in mode to SignIn."
log "SaaS admin granted Logto console access."
else
log "WARNING: Could not create admin console user"
fi
fi # end: ADMIN_TOKEN check
fi # end: M_ADMIN_SECRET check
# --- Viewer user (for testing read-only OIDC role in server) ---
log "Checking for viewer user '$TENANT_ADMIN_USER'..."
TENANT_USER_ID=$(api_get "/api/users?search=$TENANT_ADMIN_USER" | jq -r ".[] | select(.username == \"$TENANT_ADMIN_USER\") | .id")
if [ -n "$TENANT_USER_ID" ]; then
log "Viewer user exists: $TENANT_USER_ID"
else
log "Creating viewer user '$TENANT_ADMIN_USER'..."
TENANT_RESPONSE=$(api_post "/api/users" "{
\"username\": \"$TENANT_ADMIN_USER\",
\"password\": \"$TENANT_ADMIN_PASS\",
\"name\": \"Viewer\"
}")
TENANT_USER_ID=$(echo "$TENANT_RESPONSE" | jq -r '.id')
log "Created viewer user: $TENANT_USER_ID"
fi
# ============================================================
# PHASE 6: Create organization + add users
# ============================================================
log "Checking for organization '$TENANT_NAME'..."
EXISTING_ORGS=$(api_get "/api/organizations")
ORG_ID=$(echo "$EXISTING_ORGS" | jq -r ".[] | select(.name == \"$TENANT_NAME\") | .id")
if [ -n "$ORG_ID" ]; then
log "Organization exists: $ORG_ID"
else
log "Creating organization '$TENANT_NAME'..."
ORG_RESPONSE=$(api_post "/api/organizations" "{
\"name\": \"$TENANT_NAME\",
\"description\": \"Bootstrap demo tenant\"
}")
ORG_ID=$(echo "$ORG_RESPONSE" | jq -r '.id')
log "Created organization: $ORG_ID"
fi
# Add users to organization
if [ -n "$ADMIN_USER_ID" ] && [ "$ADMIN_USER_ID" != "null" ]; then
log "Adding platform owner to organization..."
api_post "/api/organizations/$ORG_ID/users" "{\"userIds\": [\"$ADMIN_USER_ID\"]}" >/dev/null 2>&1
api_put "/api/organizations/$ORG_ID/users/$ADMIN_USER_ID/roles" "{\"organizationRoleIds\": [\"$ORG_OWNER_ROLE_ID\"]}" >/dev/null 2>&1
log "Platform owner added to org with owner role."
fi
if [ -n "$TENANT_USER_ID" ] && [ "$TENANT_USER_ID" != "null" ]; then
log "Adding viewer user to organization..."
api_post "/api/organizations/$ORG_ID/users" "{\"userIds\": [\"$TENANT_USER_ID\"]}" >/dev/null 2>&1
api_put "/api/organizations/$ORG_ID/users/$TENANT_USER_ID/roles" "{\"organizationRoleIds\": [\"$ORG_VIEWER_ROLE_ID\"]}" >/dev/null 2>&1
log "Viewer user added to org with viewer role."
fi
# ============================================================
# PHASE 7: Configure cameleer3-server OIDC
# ============================================================
SERVER_HEALTHY="no"
for i in 1 2 3; do
if curl -sf "${SERVER_ENDPOINT}/api/v1/health" >/dev/null 2>&1; then
SERVER_HEALTHY="yes"
break
fi
sleep 2
done
log "Phase 7 check: SERVER_HEALTHY=$SERVER_HEALTHY, TRAD_SECRET length=${#TRAD_SECRET}"
if [ "$SERVER_HEALTHY" = "yes" ] && [ -n "$TRAD_SECRET" ]; then
log "Configuring cameleer3-server OIDC..."
# Login to server as admin
SERVER_TOKEN_RESPONSE=$(curl -s -X POST "${SERVER_ENDPOINT}/api/v1/auth/login" \
-H "Content-Type: application/json" \
-d "{\"username\": \"$SERVER_UI_USER\", \"password\": \"$SERVER_UI_PASS\"}")
SERVER_TOKEN=$(echo "$SERVER_TOKEN_RESPONSE" | jq -r '.accessToken' 2>/dev/null)
if [ -n "$SERVER_TOKEN" ] && [ "$SERVER_TOKEN" != "null" ]; then
# Configure OIDC
OIDC_RESPONSE=$(curl -s -X PUT "${SERVER_ENDPOINT}/api/v1/admin/oidc" \
-H "Authorization: Bearer $SERVER_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"enabled\": true,
\"issuerUri\": \"$LOGTO_PUBLIC_ENDPOINT/oidc\",
\"clientId\": \"$TRAD_ID\",
\"clientSecret\": \"$TRAD_SECRET\",
\"autoSignup\": true,
\"defaultRoles\": [\"VIEWER\"],
\"displayNameClaim\": \"name\",
\"rolesClaim\": \"roles\",
\"audience\": \"$API_RESOURCE_INDICATOR\",
\"additionalScopes\": []
}")
log "OIDC config response: $(echo "$OIDC_RESPONSE" | head -c 200)"
log "cameleer3-server OIDC configured."
# Seed claim mapping rules (roles → server RBAC)
log "Seeding claim mapping rules..."
EXISTING_MAPPINGS=$(curl -s -H "Authorization: Bearer $SERVER_TOKEN" \
"${SERVER_ENDPOINT}/api/v1/admin/claim-mappings" 2>/dev/null || echo "[]")
seed_claim_mapping() {
local match_value="$1"
local target="$2"
local priority="$3"
local exists=$(echo "$EXISTING_MAPPINGS" | jq -r ".[] | select(.matchValue == \"$match_value\") | .id")
if [ -n "$exists" ]; then
log " Claim mapping '$match_value' → $target exists"
else
local resp=$(curl -s -X POST "${SERVER_ENDPOINT}/api/v1/admin/claim-mappings" \
-H "Authorization: Bearer $SERVER_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"claim\":\"roles\",\"matchType\":\"contains\",\"matchValue\":\"$match_value\",\"action\":\"assignRole\",\"target\":\"$target\",\"priority\":$priority}")
log " Created claim mapping '$match_value' → $target"
fi
}
seed_claim_mapping "server:admin" "ADMIN" 10
seed_claim_mapping "server:operator" "OPERATOR" 20
log "Claim mapping rules seeded."
else
log "WARNING: Could not login to cameleer3-server — skipping OIDC config"
fi
else
log "WARNING: cameleer3-server not available or no Traditional app secret — skipping OIDC config"
fi
# ============================================================
# PHASE 7b: Configure Logto Custom JWT for access tokens
# ============================================================
# Adds a 'roles' claim to access tokens based on user's org roles and global roles.
# This allows the server to extract roles from the access token using rolesClaim: "roles".
log "Configuring Logto Custom JWT for access tokens..."
CUSTOM_JWT_SCRIPT='const getCustomJwtClaims = async ({ token, context, environmentVariables }) => {
const roleMap = { owner: "server:admin", operator: "server:operator", viewer: "server:viewer" };
const roles = new Set();
if (context?.user?.organizationRoles) {
for (const orgRole of context.user.organizationRoles) {
const mapped = roleMap[orgRole.roleName];
if (mapped) roles.add(mapped);
}
}
if (context?.user?.roles) {
for (const role of context.user.roles) {
if (role.name === "saas-vendor") roles.add("server:admin");
}
}
return roles.size > 0 ? { roles: [...roles] } : {};
};'
CUSTOM_JWT_PAYLOAD=$(jq -n --arg script "$CUSTOM_JWT_SCRIPT" '{ script: $script }')
CUSTOM_JWT_RESPONSE=$(api_put "/api/configs/jwt-customizer/access-token" "$CUSTOM_JWT_PAYLOAD" 2>&1)
if echo "$CUSTOM_JWT_RESPONSE" | jq -e '.script' >/dev/null 2>&1; then
log "Custom JWT configured for access tokens."
else
log "WARNING: Custom JWT configuration failed — server OIDC login may fall back to local roles"
log "Response: $(echo "$CUSTOM_JWT_RESPONSE" | head -c 200)"
fi
# ============================================================
# PHASE 8: Configure sign-in branding
# ============================================================
log "Configuring sign-in experience branding..."
api_patch "/api/sign-in-exp" "{
\"color\": {
\"primaryColor\": \"#C6820E\",
\"isDarkModeEnabled\": true,
\"darkPrimaryColor\": \"#D4941E\"
},
\"branding\": {
\"logoUrl\": \"${PROTO}://${HOST}/platform/logo.svg\",
\"darkLogoUrl\": \"${PROTO}://${HOST}/platform/logo-dark.svg\"
}
}"
log "Sign-in branding configured."
# ============================================================
# PHASE 9: Cleanup seeded apps
# ============================================================
if [ -n "$M2M_SECRET" ]; then
log "Cleaning up seeded apps with known secrets..."
for SEEDED_ID in "m-default" "m-admin" "s6cz3wajdv8gtdyz8e941"; do
if echo "$EXISTING_APPS" | jq -e ".[] | select(.id == \"$SEEDED_ID\")" >/dev/null 2>&1; then
api_delete "/api/applications/$SEEDED_ID"
log "Deleted seeded app: $SEEDED_ID"
fi
done
fi
# ============================================================
# PHASE 10: Write bootstrap results
# ============================================================
log "Writing bootstrap config to $BOOTSTRAP_FILE..."
mkdir -p "$(dirname "$BOOTSTRAP_FILE")"
cat > "$BOOTSTRAP_FILE" <<EOF
{
"spaClientId": "$SPA_ID",
"m2mClientId": "$M2M_ID",
"m2mClientSecret": "$M2M_SECRET",
"tradAppId": "$TRAD_ID",
"tradAppSecret": "$TRAD_SECRET",
"apiResourceIndicator": "$API_RESOURCE_INDICATOR",
"organizationId": "$ORG_ID",
"tenantName": "$TENANT_NAME",
"tenantSlug": "$TENANT_SLUG",
"bootstrapToken": "$BOOTSTRAP_TOKEN",
"platformAdminUser": "$SAAS_ADMIN_USER",
"tenantAdminUser": "$TENANT_ADMIN_USER",
"oidcIssuerUri": "${LOGTO_ENDPOINT}/oidc",
"oidcAudience": "$API_RESOURCE_INDICATOR"
}
EOF
chmod 644 "$BOOTSTRAP_FILE"
log ""
log "=== Bootstrap complete! ==="
# dev only — remove credential logging in production
log " Platform Owner: $SAAS_ADMIN_USER / $SAAS_ADMIN_PASS (org role: owner)"
log " Viewer: $TENANT_ADMIN_USER / $TENANT_ADMIN_PASS (org role: viewer)"
log " Tenant: $TENANT_NAME (slug: $TENANT_SLUG)"
log " Organization: $ORG_ID"
log " SPA Client ID: $SPA_ID"
log ""
log " To add SaaS Vendor role (hosted only): run docker/vendor-seed.sh"

View File

@@ -7,7 +7,7 @@ COPY agent.jar /app/agent.jar
ENTRYPOINT exec java \
-Dcameleer.export.type=${CAMELEER_EXPORT_TYPE:-HTTP} \
-Dcameleer.export.endpoint=${CAMELEER_EXPORT_ENDPOINT} \
-Dcameleer.export.endpoint=${CAMELEER_SERVER_URL} \
-Dcameleer.agent.name=${HOSTNAME} \
-Dcameleer.agent.application=${CAMELEER_APPLICATION_ID:-default} \
-Dcameleer.agent.environment=${CAMELEER_ENVIRONMENT_ID:-default} \

View File

@@ -0,0 +1,20 @@
#!/bin/sh
# Patched entrypoint: fixes the sed ordering bug in the server-ui image.
# The original entrypoint inserts <base href> then rewrites ALL href="/..."
# including the just-inserted base tag, causing /server/server/ doubling.
BASE_PATH="${BASE_PATH:-/}"
if [ "$BASE_PATH" != "/" ]; then
BASE_PATH=$(echo "$BASE_PATH" | sed 's#/*$#/#; s#^/*#/#')
INDEX="/usr/share/nginx/html/index.html"
# Rewrite absolute asset paths FIRST (before inserting <base>)
sed -i "s|href=\"/|href=\"${BASE_PATH}|g; s|src=\"/|src=\"${BASE_PATH}|g" "$INDEX"
# THEN inject <base> tag
sed -i "s|<head>|<head><base href=\"${BASE_PATH}\">|" "$INDEX"
echo "BASE_PATH set to ${BASE_PATH} — rewrote index.html"
fi
exec /docker-entrypoint.sh "$@"

View File

@@ -0,0 +1,17 @@
http:
routers:
root-redirect:
rule: "Path(`/`)"
priority: 100
entryPoints:
- websecure
tls: {}
middlewares:
- root-to-platform
service: saas@docker
middlewares:
root-to-platform:
redirectRegex:
regex: "^(https?://[^/]+)/?$"
replacement: "${1}/platform/"
permanent: false

135
docker/vendor-seed.sh Normal file
View File

@@ -0,0 +1,135 @@
#!/bin/sh
set -e
# Cameleer SaaS — Vendor Seed Script
# Creates the saas-vendor global role and vendor user.
# Run ONCE on the hosted SaaS environment AFTER standard bootstrap.
# NOT part of docker-compose.yml — invoked manually or by CI.
LOGTO_ENDPOINT="${LOGTO_ENDPOINT:-http://logto:3001}"
MGMT_API_RESOURCE="https://default.logto.app/api"
API_RESOURCE_INDICATOR="https://api.cameleer.local"
PG_HOST="${PG_HOST:-postgres}"
PG_USER="${PG_USER:-cameleer}"
PG_DB_LOGTO="logto"
# Vendor credentials (override via env vars)
VENDOR_USER="${VENDOR_USER:-vendor}"
VENDOR_PASS="${VENDOR_PASS:-vendor}"
VENDOR_NAME="${VENDOR_NAME:-SaaS Vendor}"
log() { echo "[vendor-seed] $1"; }
pgpass() { PGPASSWORD="${PG_PASSWORD:-cameleer_dev}"; export PGPASSWORD; }
# Install jq + curl
apk add --no-cache jq curl >/dev/null 2>&1
# ============================================================
# Get Management API token
# ============================================================
log "Reading M2M credentials from bootstrap file..."
BOOTSTRAP_FILE="/data/logto-bootstrap.json"
if [ ! -f "$BOOTSTRAP_FILE" ]; then
log "ERROR: Bootstrap file not found at $BOOTSTRAP_FILE — run standard bootstrap first"
exit 1
fi
M2M_ID=$(jq -r '.m2mClientId' "$BOOTSTRAP_FILE")
M2M_SECRET=$(jq -r '.m2mClientSecret' "$BOOTSTRAP_FILE")
if [ -z "$M2M_ID" ] || [ "$M2M_ID" = "null" ] || [ -z "$M2M_SECRET" ] || [ "$M2M_SECRET" = "null" ]; then
log "ERROR: M2M credentials not found in bootstrap file"
exit 1
fi
log "Getting Management API token..."
TOKEN_RESPONSE=$(curl -s -X POST "${LOGTO_ENDPOINT}/oidc/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials&client_id=${M2M_ID}&client_secret=${M2M_SECRET}&resource=${MGMT_API_RESOURCE}&scope=all")
TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token' 2>/dev/null)
[ -z "$TOKEN" ] || [ "$TOKEN" = "null" ] && { log "ERROR: Failed to get token"; exit 1; }
log "Got Management API token."
api_get() { curl -s -H "Authorization: Bearer $TOKEN" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || echo "[]"; }
api_post() { curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d "$2" "${LOGTO_ENDPOINT}${1}" 2>/dev/null || true; }
# ============================================================
# Create saas-vendor global role
# ============================================================
log "Checking for saas-vendor role..."
EXISTING_ROLES=$(api_get "/api/roles")
VENDOR_ROLE_ID=$(echo "$EXISTING_ROLES" | jq -r '.[] | select(.name == "saas-vendor" and .type == "User") | .id')
if [ -n "$VENDOR_ROLE_ID" ]; then
log "saas-vendor role exists: $VENDOR_ROLE_ID"
else
# Collect all API resource scope IDs
EXISTING_RESOURCES=$(api_get "/api/resources")
API_RESOURCE_ID=$(echo "$EXISTING_RESOURCES" | jq -r ".[] | select(.indicator == \"$API_RESOURCE_INDICATOR\") | .id")
ALL_SCOPE_IDS=$(api_get "/api/resources/$API_RESOURCE_ID/scopes" | jq '[.[].id]')
log "Creating saas-vendor role with all scopes..."
VENDOR_ROLE_RESPONSE=$(api_post "/api/roles" "{
\"name\": \"saas-vendor\",
\"description\": \"SaaS vendor — full platform control across all tenants\",
\"type\": \"User\",
\"scopeIds\": $ALL_SCOPE_IDS
}")
VENDOR_ROLE_ID=$(echo "$VENDOR_ROLE_RESPONSE" | jq -r '.id')
log "Created saas-vendor role: $VENDOR_ROLE_ID"
fi
# ============================================================
# Create vendor user
# ============================================================
log "Checking for vendor user '$VENDOR_USER'..."
VENDOR_USER_ID=$(api_get "/api/users?search=$VENDOR_USER" | jq -r ".[] | select(.username == \"$VENDOR_USER\") | .id")
if [ -n "$VENDOR_USER_ID" ]; then
log "Vendor user exists: $VENDOR_USER_ID"
else
log "Creating vendor user '$VENDOR_USER'..."
VENDOR_RESPONSE=$(api_post "/api/users" "{
\"username\": \"$VENDOR_USER\",
\"password\": \"$VENDOR_PASS\",
\"name\": \"$VENDOR_NAME\"
}")
VENDOR_USER_ID=$(echo "$VENDOR_RESPONSE" | jq -r '.id')
log "Created vendor user: $VENDOR_USER_ID"
fi
# Assign saas-vendor role
if [ -n "$VENDOR_ROLE_ID" ] && [ "$VENDOR_ROLE_ID" != "null" ]; then
api_post "/api/users/$VENDOR_USER_ID/roles" "{\"roleIds\": [\"$VENDOR_ROLE_ID\"]}" >/dev/null 2>&1
log "Assigned saas-vendor role."
fi
# ============================================================
# Add vendor to all existing organizations with owner role
# ============================================================
log "Adding vendor to all organizations..."
ORG_OWNER_ROLE_ID=$(api_get "/api/organization-roles" | jq -r '.[] | select(.name == "owner") | .id')
ORGS=$(api_get "/api/organizations")
ORG_COUNT=$(echo "$ORGS" | jq 'length')
for i in $(seq 0 $((ORG_COUNT - 1))); do
ORG_ID=$(echo "$ORGS" | jq -r ".[$i].id")
ORG_NAME=$(echo "$ORGS" | jq -r ".[$i].name")
api_post "/api/organizations/$ORG_ID/users" "{\"userIds\": [\"$VENDOR_USER_ID\"]}" >/dev/null 2>&1
if [ -n "$ORG_OWNER_ROLE_ID" ] && [ "$ORG_OWNER_ROLE_ID" != "null" ]; then
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"organizationRoleIds\": [\"$ORG_OWNER_ROLE_ID\"]}" \
"${LOGTO_ENDPOINT}/api/organizations/$ORG_ID/users/$VENDOR_USER_ID/roles" >/dev/null 2>&1
fi
log " Added to org '$ORG_NAME' ($ORG_ID) with owner role."
done
log ""
log "=== Vendor seed complete! ==="
log " Vendor user: $VENDOR_USER / $VENDOR_PASS"
log " Role: saas-vendor (global) + owner (in all orgs)"
log " This user has platform:admin scope and cross-tenant access."

989
docs/architecture.md Normal file
View File

@@ -0,0 +1,989 @@
# 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:
```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<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_SERVER_URL` | 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": "<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:
```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
```
<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` | Logto org ID (for token scoping) |
| `currentTenantId`| `string | null` | DB UUID (for API calls) |
| `organizations` | `OrgInfo[]` | All orgs the user belongs to |
| `scopes` | `Set<string>` | OAuth2 scopes from access token |
**`useAuth()`** hook -- `ui/src/auth/useAuth.ts`:
Combines `@logto/react` state (`isAuthenticated`, `isLoading`) with org store
state (`currentTenantId`). Provides `logout` and `signIn` callbacks.
---
## 10. Configuration Reference
### 10.1 cameleer-saas
**Spring / Database:**
| Variable | Default | Description |
|------------------------------|----------------------------------------------|----------------------------------|
| `SPRING_DATASOURCE_URL` | `jdbc:postgresql://postgres:5432/cameleer_saas` | PostgreSQL JDBC URL |
| `SPRING_DATASOURCE_USERNAME`| `cameleer` | PostgreSQL user |
| `SPRING_DATASOURCE_PASSWORD`| `cameleer_dev` | PostgreSQL password |
**Logto / OIDC:**
| Variable | Default | Description |
|---------------------------|------------|--------------------------------------------|
| `LOGTO_ENDPOINT` | (empty) | Logto internal URL (Docker-internal) |
| `LOGTO_PUBLIC_ENDPOINT` | (empty) | Logto public URL (browser-accessible) |
| `LOGTO_ISSUER_URI` | (empty) | OIDC issuer URI for JWT validation |
| `LOGTO_JWK_SET_URI` | (empty) | JWKS endpoint for JWT signature validation |
| `LOGTO_M2M_CLIENT_ID` | (empty) | M2M app client ID (from bootstrap) |
| `LOGTO_M2M_CLIENT_SECRET` | (empty) | M2M app client secret (from bootstrap) |
| `LOGTO_SPA_CLIENT_ID` | (empty) | SPA app client ID (fallback; bootstrap preferred) |
**Runtime / Deployment:**
| Variable | Default | Description |
|-----------------------------------|------------------------------------|----------------------------------|
| `CAMELEER3_SERVER_ENDPOINT` | `http://cameleer3-server:8081` | cameleer3-server internal URL |
| `CAMELEER_JAR_STORAGE_PATH` | `/data/jars` | JAR upload storage directory |
| `CAMELEER_RUNTIME_BASE_IMAGE` | `cameleer-runtime-base:latest` | Base Docker image for app builds |
| `CAMELEER_DOCKER_NETWORK` | `cameleer` | Docker network for containers |
| `CAMELEER_CONTAINER_MEMORY_LIMIT`| `512m` | Per-container memory limit |
| `CAMELEER_CONTAINER_CPU_SHARES` | `512` | Per-container CPU shares |
| `CLICKHOUSE_URL` | `jdbc:clickhouse://clickhouse:8123/cameleer` | ClickHouse JDBC URL |
| `CLICKHOUSE_ENABLED` | `true` | Enable ClickHouse integration |
| `CLICKHOUSE_USERNAME` | `default` | ClickHouse user |
| `CLICKHOUSE_PASSWORD` | (empty) | ClickHouse password |
| `DOMAIN` | `localhost` | Base domain for Traefik routing |
### 10.2 cameleer3-server
| Variable | Default | Description |
|------------------------------|----------------------------------------------|----------------------------------|
| `SPRING_DATASOURCE_URL` | `jdbc:postgresql://postgres:5432/cameleer3` | PostgreSQL JDBC URL |
| `SPRING_DATASOURCE_USERNAME`| `cameleer` | PostgreSQL user |
| `SPRING_DATASOURCE_PASSWORD`| `cameleer_dev` | PostgreSQL password |
| `CLICKHOUSE_URL` | `jdbc:clickhouse://clickhouse:8123/cameleer` | ClickHouse JDBC URL |
| `CAMELEER_AUTH_TOKEN` | `default-bootstrap-token` | Agent bootstrap token |
| `CAMELEER_JWT_SECRET` | `cameleer-dev-jwt-secret-...` | HMAC secret for internal JWTs |
| `CAMELEER_TENANT_ID` | `default` | Tenant slug for data isolation |
| `CAMELEER_OIDC_ISSUER_URI` | (empty) | Logto issuer for M2M token validation |
| `CAMELEER_OIDC_AUDIENCE` | (empty) | Expected JWT audience |
### 10.3 logto
| Variable | Default | Description |
|---------------------|--------------------------|---------------------------------|
| `LOGTO_PUBLIC_ENDPOINT` | `http://localhost:3001`| Public-facing Logto URL |
| `LOGTO_ADMIN_ENDPOINT` | `http://localhost:3002`| Admin console URL (not exposed) |
### 10.4 postgres
| Variable | Default | Description |
|---------------------|-------------------|---------------------------------|
| `POSTGRES_DB` | `cameleer_saas` | Default database name |
| `POSTGRES_USER` | `cameleer` | PostgreSQL superuser |
| `POSTGRES_PASSWORD` | `cameleer_dev` | PostgreSQL password |
### 10.5 logto-bootstrap
| Variable | Default | Description |
|----------------------|----------------------------|--------------------------------|
| `SAAS_ADMIN_USER` | `admin` | Platform admin username |
| `SAAS_ADMIN_PASS` | `admin` | Platform admin password |
| `TENANT_ADMIN_USER` | `camel` | Default tenant admin username |
| `TENANT_ADMIN_PASS` | `camel` | Default tenant admin password |
| `CAMELEER_AUTH_TOKEN`| `default-bootstrap-token` | Agent bootstrap token |
### 10.6 Bootstrap Output
The bootstrap script writes `/data/logto-bootstrap.json` containing:
```json
{
"spaClientId": "<auto-generated>",
"m2mClientId": "<auto-generated>",
"m2mClientSecret": "<auto-generated>",
"tradAppId": "<auto-generated>",
"tradAppSecret": "<auto-generated>",
"apiResourceIndicator": "https://api.cameleer.local",
"organizationId": "<auto-generated>",
"tenantName": "Example Tenant",
"tenantSlug": "default",
"bootstrapToken": "<from env>",
"platformAdminUser": "<from env>",
"tenantAdminUser": "<from env>",
"oidcIssuerUri": "http://logto:3001/oidc",
"oidcAudience": "https://api.cameleer.local"
}
```
This file is mounted read-only into cameleer-saas via the `bootstrapdata`
volume. `PublicConfigController` reads it to serve SPA client IDs and the API
resource indicator without requiring environment variable configuration. 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 |

View File

@@ -0,0 +1,789 @@
# Phase 4: Observability Pipeline + Inbound Routing — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Complete the deploy → hit endpoint → see traces loop. Serve the existing cameleer3-server dashboard, add agent connectivity verification, enable optional inbound HTTP routing for customer apps, and wire up observability data health checks.
**Architecture:** Wiring phase — cameleer3-server already has full observability. Phase 4 adds Traefik routing for the dashboard + customer app endpoints, new API endpoints in cameleer-saas for agent-status and observability-status, and configures `CAMELEER_TENANT_ID` on the server.
**Tech Stack:** Spring Boot 3.4.3, docker-java 3.4.1, ClickHouse JDBC, Traefik v3 labels, Spring RestClient
---
## File Structure
### New Files
- `src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusService.java` — Queries cameleer3-server for agent registration
- `src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusController.java` — Agent status + observability status endpoints
- `src/main/java/net/siegeln/cameleer/saas/observability/dto/AgentStatusResponse.java` — Response DTO
- `src/main/java/net/siegeln/cameleer/saas/observability/dto/ObservabilityStatusResponse.java` — Response DTO
- `src/main/java/net/siegeln/cameleer/saas/observability/dto/UpdateRoutingRequest.java` — Request DTO for PATCH routing
- `src/main/java/net/siegeln/cameleer/saas/observability/ConnectivityHealthCheck.java` — Startup connectivity verification
- `src/test/java/net/siegeln/cameleer/saas/observability/AgentStatusServiceTest.java` — Unit tests
- `src/test/java/net/siegeln/cameleer/saas/observability/AgentStatusControllerTest.java` — Integration tests
- `src/main/resources/db/migration/V010__add_exposed_port_to_apps.sql` — Migration
### Modified Files
- `src/main/java/net/siegeln/cameleer/saas/runtime/StartContainerRequest.java` — Add `labels` field
- `src/main/java/net/siegeln/cameleer/saas/runtime/DockerRuntimeOrchestrator.java` — Apply labels on container create
- `src/main/java/net/siegeln/cameleer/saas/runtime/RuntimeConfig.java` — Add `domain` property
- `src/main/java/net/siegeln/cameleer/saas/app/AppEntity.java` — Add `exposedPort` field
- `src/main/java/net/siegeln/cameleer/saas/app/AppService.java` — Add `updateRouting` method
- `src/main/java/net/siegeln/cameleer/saas/app/AppController.java` — Add PATCH routing endpoint
- `src/main/java/net/siegeln/cameleer/saas/app/dto/AppResponse.java` — Add `exposedPort` + `routeUrl` fields
- `src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java` — Build labels for Traefik routing
- `src/main/resources/application.yml` — Add `domain` property
- `docker-compose.yml` — Add dashboard Traefik route, `CAMELEER_TENANT_ID`
- `.env.example` — Add `CAMELEER_TENANT_SLUG`
- `HOWTO.md` — Update with observability + routing docs
---
## Task 1: Database Migration + Entity Changes
**Files:**
- Create: `src/main/resources/db/migration/V010__add_exposed_port_to_apps.sql`
- Modify: `src/main/java/net/siegeln/cameleer/saas/app/AppEntity.java`
- [ ] **Step 1: Create migration V010**
```sql
ALTER TABLE apps ADD COLUMN exposed_port INTEGER;
```
- [ ] **Step 2: Add exposedPort field to AppEntity**
Add after `previousDeploymentId` field:
```java
@Column(name = "exposed_port")
private Integer exposedPort;
```
Add getter and setter:
```java
public Integer getExposedPort() { return exposedPort; }
public void setExposedPort(Integer exposedPort) { this.exposedPort = exposedPort; }
```
- [ ] **Step 3: Verify compilation**
Run: `mvn compile -B -q`
- [ ] **Step 4: Commit**
```bash
git add src/main/resources/db/migration/V010__add_exposed_port_to_apps.sql \
src/main/java/net/siegeln/cameleer/saas/app/AppEntity.java
git commit -m "feat: add exposed_port column to apps table"
```
---
## Task 2: StartContainerRequest Labels + DockerRuntimeOrchestrator
**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/runtime/StartContainerRequest.java`
- Modify: `src/main/java/net/siegeln/cameleer/saas/runtime/DockerRuntimeOrchestrator.java`
- [ ] **Step 1: Add labels field to StartContainerRequest**
Replace the current record with:
```java
package net.siegeln.cameleer.saas.runtime;
import java.util.Map;
public record StartContainerRequest(
String imageRef,
String containerName,
String network,
Map<String, String> envVars,
long memoryLimitBytes,
int cpuShares,
int healthCheckPort,
Map<String, String> labels
) {}
```
- [ ] **Step 2: Apply labels in DockerRuntimeOrchestrator.startContainer**
In the `startContainer` method, after `.withHostConfig(hostConfig)` and before `.withHealthcheck(...)`, add:
```java
.withLabels(request.labels() != null ? request.labels() : Map.of())
```
- [ ] **Step 3: Fix all existing callers of StartContainerRequest**
The `DeploymentService.executeDeploymentAsync` method creates a `StartContainerRequest`. Add `Map.of()` as the labels argument (empty labels for now — routing labels come in Task 5):
Find the existing `new StartContainerRequest(...)` call and add `Map.of()` as the last argument.
- [ ] **Step 4: Verify compilation and run unit tests**
Run: `mvn test -B -Dsurefire.excludes="**/*ControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java" -q`
- [ ] **Step 5: Commit**
```bash
git add src/main/java/net/siegeln/cameleer/saas/runtime/StartContainerRequest.java \
src/main/java/net/siegeln/cameleer/saas/runtime/DockerRuntimeOrchestrator.java \
src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java
git commit -m "feat: add labels support to StartContainerRequest and DockerRuntimeOrchestrator"
```
---
## Task 3: RuntimeConfig Domain + AppResponse + AppService Routing
**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/runtime/RuntimeConfig.java`
- Modify: `src/main/java/net/siegeln/cameleer/saas/app/dto/AppResponse.java`
- Modify: `src/main/java/net/siegeln/cameleer/saas/app/AppService.java`
- Modify: `src/main/java/net/siegeln/cameleer/saas/app/AppController.java`
- Create: `src/main/java/net/siegeln/cameleer/saas/observability/dto/UpdateRoutingRequest.java`
- Modify: `src/main/resources/application.yml`
- [ ] **Step 1: Add domain property to RuntimeConfig**
Add field and getter:
```java
@Value("${cameleer.runtime.domain:localhost}")
private String domain;
public String getDomain() { return domain; }
```
- [ ] **Step 2: Add domain to application.yml**
In the `cameleer.runtime` section, add:
```yaml
domain: ${DOMAIN:localhost}
```
- [ ] **Step 3: Update AppResponse to include exposedPort and routeUrl**
Replace the record:
```java
package net.siegeln.cameleer.saas.app.dto;
import java.time.Instant;
import java.util.UUID;
public record AppResponse(
UUID id,
UUID environmentId,
String slug,
String displayName,
String jarOriginalFilename,
Long jarSizeBytes,
String jarChecksum,
Integer exposedPort,
String routeUrl,
UUID currentDeploymentId,
UUID previousDeploymentId,
Instant createdAt,
Instant updatedAt
) {}
```
- [ ] **Step 4: Create UpdateRoutingRequest**
```java
package net.siegeln.cameleer.saas.observability.dto;
public record UpdateRoutingRequest(
Integer exposedPort
) {}
```
- [ ] **Step 5: Add updateRouting method to AppService**
```java
public AppEntity updateRouting(UUID appId, Integer exposedPort, UUID actorId) {
var app = appRepository.findById(appId)
.orElseThrow(() -> new IllegalArgumentException("App not found"));
app.setExposedPort(exposedPort);
return appRepository.save(app);
}
```
- [ ] **Step 6: Update AppController — add PATCH routing endpoint and update toResponse**
Add the endpoint:
```java
@PatchMapping("/{appId}/routing")
public ResponseEntity<AppResponse> updateRouting(
@PathVariable UUID environmentId,
@PathVariable UUID appId,
@RequestBody UpdateRoutingRequest request,
Authentication authentication) {
try {
var actorId = resolveActorId(authentication);
var app = appService.updateRouting(appId, request.exposedPort(), actorId);
var env = environmentService.getById(app.getEnvironmentId()).orElse(null);
return ResponseEntity.ok(toResponse(app, env));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
```
This requires adding `EnvironmentService` and `RuntimeConfig` as constructor dependencies to `AppController`. Update the constructor.
Update `toResponse` to accept the environment and compute the route URL:
```java
private AppResponse toResponse(AppEntity app, EnvironmentEntity env) {
String routeUrl = null;
if (app.getExposedPort() != null && env != null) {
var tenant = tenantRepository.findById(env.getTenantId()).orElse(null);
if (tenant != null) {
routeUrl = "http://" + app.getSlug() + "." + env.getSlug() + "."
+ tenant.getSlug() + "." + runtimeConfig.getDomain();
}
}
return new AppResponse(
app.getId(), app.getEnvironmentId(), app.getSlug(), app.getDisplayName(),
app.getJarOriginalFilename(), app.getJarSizeBytes(), app.getJarChecksum(),
app.getExposedPort(), routeUrl,
app.getCurrentDeploymentId(), app.getPreviousDeploymentId(),
app.getCreatedAt(), app.getUpdatedAt());
}
```
This requires adding `TenantRepository` as a constructor dependency too. Update the existing `toResponse(AppEntity)` calls in other methods to pass the environment — look up the environment from the `environmentId` path variable or from `environmentService`.
For the list/get/create endpoints that already have `environmentId` in the path, look up the environment once and pass it.
- [ ] **Step 7: Verify compilation**
Run: `mvn compile -B -q`
- [ ] **Step 8: Commit**
```bash
git add src/main/java/net/siegeln/cameleer/saas/runtime/RuntimeConfig.java \
src/main/java/net/siegeln/cameleer/saas/app/dto/AppResponse.java \
src/main/java/net/siegeln/cameleer/saas/app/AppService.java \
src/main/java/net/siegeln/cameleer/saas/app/AppController.java \
src/main/java/net/siegeln/cameleer/saas/observability/dto/UpdateRoutingRequest.java \
src/main/resources/application.yml
git commit -m "feat: add exposed port routing and route URL to app API"
```
---
## Task 4: Agent Status + Observability Status Endpoints (TDD)
**Files:**
- Create: `src/main/java/net/siegeln/cameleer/saas/observability/dto/AgentStatusResponse.java`
- Create: `src/main/java/net/siegeln/cameleer/saas/observability/dto/ObservabilityStatusResponse.java`
- Create: `src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusService.java`
- Create: `src/main/java/net/siegeln/cameleer/saas/observability/AgentStatusController.java`
- Create: `src/test/java/net/siegeln/cameleer/saas/observability/AgentStatusServiceTest.java`
- [ ] **Step 1: Create DTOs**
`AgentStatusResponse.java`:
```java
package net.siegeln.cameleer.saas.observability.dto;
import java.time.Instant;
import java.util.List;
public record AgentStatusResponse(
boolean registered,
String state,
Instant lastHeartbeat,
List<String> routeIds,
String applicationId,
String environmentId
) {}
```
`ObservabilityStatusResponse.java`:
```java
package net.siegeln.cameleer.saas.observability.dto;
import java.time.Instant;
public record ObservabilityStatusResponse(
boolean hasTraces,
boolean hasMetrics,
boolean hasDiagrams,
Instant lastTraceAt,
long traceCount24h
) {}
```
- [ ] **Step 2: Write failing tests**
```java
package net.siegeln.cameleer.saas.observability;
import net.siegeln.cameleer.saas.app.AppEntity;
import net.siegeln.cameleer.saas.app.AppRepository;
import net.siegeln.cameleer.saas.environment.EnvironmentEntity;
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class AgentStatusServiceTest {
@Mock private AppRepository appRepository;
@Mock private EnvironmentRepository environmentRepository;
@Mock private RuntimeConfig runtimeConfig;
private AgentStatusService agentStatusService;
@BeforeEach
void setUp() {
when(runtimeConfig.getCameleer3ServerEndpoint()).thenReturn("http://cameleer3-server:8081");
agentStatusService = new AgentStatusService(appRepository, environmentRepository, runtimeConfig);
}
@Test
void getAgentStatus_appNotFound_shouldThrow() {
when(appRepository.findById(any())).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class,
() -> agentStatusService.getAgentStatus(UUID.randomUUID()));
}
@Test
void getAgentStatus_shouldReturnUnknownWhenServerUnreachable() {
var appId = UUID.randomUUID();
var envId = UUID.randomUUID();
var app = new AppEntity();
app.setId(appId);
app.setEnvironmentId(envId);
app.setSlug("my-app");
when(appRepository.findById(appId)).thenReturn(Optional.of(app));
var env = new EnvironmentEntity();
env.setId(envId);
env.setSlug("default");
when(environmentRepository.findById(envId)).thenReturn(Optional.of(env));
var result = agentStatusService.getAgentStatus(appId);
assertNotNull(result);
assertFalse(result.registered());
assertEquals("UNKNOWN", result.state());
}
}
```
- [ ] **Step 3: Implement AgentStatusService**
```java
package net.siegeln.cameleer.saas.observability;
import net.siegeln.cameleer.saas.app.AppRepository;
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
import net.siegeln.cameleer.saas.observability.dto.AgentStatusResponse;
import net.siegeln.cameleer.saas.observability.dto.ObservabilityStatusResponse;
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import javax.sql.DataSource;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@Service
public class AgentStatusService {
private static final Logger log = LoggerFactory.getLogger(AgentStatusService.class);
private final AppRepository appRepository;
private final EnvironmentRepository environmentRepository;
private final RuntimeConfig runtimeConfig;
private final RestClient restClient;
@Autowired(required = false)
@Qualifier("clickHouseDataSource")
private DataSource clickHouseDataSource;
public AgentStatusService(AppRepository appRepository,
EnvironmentRepository environmentRepository,
RuntimeConfig runtimeConfig) {
this.appRepository = appRepository;
this.environmentRepository = environmentRepository;
this.runtimeConfig = runtimeConfig;
this.restClient = RestClient.builder()
.baseUrl(runtimeConfig.getCameleer3ServerEndpoint())
.build();
}
public AgentStatusResponse getAgentStatus(UUID appId) {
var app = appRepository.findById(appId)
.orElseThrow(() -> new IllegalArgumentException("App not found"));
var env = environmentRepository.findById(app.getEnvironmentId())
.orElseThrow(() -> new IllegalStateException("Environment not found"));
try {
var response = restClient.get()
.uri("/api/v1/agents")
.header("Authorization", "Bearer " + runtimeConfig.getBootstrapToken())
.retrieve()
.body(List.class);
if (response != null) {
for (var agentObj : response) {
if (agentObj instanceof java.util.Map<?, ?> agent) {
var agentAppId = String.valueOf(agent.get("applicationId"));
var agentEnvId = String.valueOf(agent.get("environmentId"));
if (app.getSlug().equals(agentAppId) && env.getSlug().equals(agentEnvId)) {
var state = String.valueOf(agent.getOrDefault("state", "UNKNOWN"));
var routeIds = agent.get("routeIds");
@SuppressWarnings("unchecked")
var routes = routeIds instanceof List<?> r ? (List<String>) r : List.<String>of();
return new AgentStatusResponse(true, state, null, routes,
agentAppId, agentEnvId);
}
}
}
}
return new AgentStatusResponse(false, "NOT_REGISTERED", null,
List.of(), app.getSlug(), env.getSlug());
} catch (Exception e) {
log.warn("Failed to query agent status from cameleer3-server: {}", e.getMessage());
return new AgentStatusResponse(false, "UNKNOWN", null,
List.of(), app.getSlug(), env.getSlug());
}
}
public ObservabilityStatusResponse getObservabilityStatus(UUID appId) {
var app = appRepository.findById(appId)
.orElseThrow(() -> new IllegalArgumentException("App not found"));
var env = environmentRepository.findById(app.getEnvironmentId())
.orElseThrow(() -> new IllegalStateException("Environment not found"));
if (clickHouseDataSource == null) {
return new ObservabilityStatusResponse(false, false, false, null, 0);
}
try (var conn = clickHouseDataSource.getConnection();
var ps = conn.prepareStatement("""
SELECT
count() as trace_count,
max(start_time) as last_trace
FROM executions
WHERE application_id = ? AND environment = ?
AND start_time > now() - INTERVAL 24 HOUR
""")) {
ps.setString(1, app.getSlug());
ps.setString(2, env.getSlug());
try (var rs = ps.executeQuery()) {
if (rs.next()) {
var count = rs.getLong("trace_count");
var lastTrace = rs.getTimestamp("last_trace");
return new ObservabilityStatusResponse(
count > 0, false, false,
lastTrace != null ? lastTrace.toInstant() : null,
count);
}
}
} catch (Exception e) {
log.warn("Failed to query observability status from ClickHouse: {}", e.getMessage());
}
return new ObservabilityStatusResponse(false, false, false, null, 0);
}
}
```
- [ ] **Step 4: Create AgentStatusController**
```java
package net.siegeln.cameleer.saas.observability;
import net.siegeln.cameleer.saas.observability.dto.AgentStatusResponse;
import net.siegeln.cameleer.saas.observability.dto.ObservabilityStatusResponse;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.UUID;
@RestController
@RequestMapping("/api/apps/{appId}")
public class AgentStatusController {
private final AgentStatusService agentStatusService;
public AgentStatusController(AgentStatusService agentStatusService) {
this.agentStatusService = agentStatusService;
}
@GetMapping("/agent-status")
public ResponseEntity<AgentStatusResponse> getAgentStatus(@PathVariable UUID appId) {
try {
var status = agentStatusService.getAgentStatus(appId);
return ResponseEntity.ok(status);
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@GetMapping("/observability-status")
public ResponseEntity<ObservabilityStatusResponse> getObservabilityStatus(@PathVariable UUID appId) {
try {
var status = agentStatusService.getObservabilityStatus(appId);
return ResponseEntity.ok(status);
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
}
```
- [ ] **Step 5: Run tests**
Run: `mvn test -pl . -Dtest=AgentStatusServiceTest -B`
Expected: 2 tests PASS
- [ ] **Step 6: Commit**
```bash
git add src/main/java/net/siegeln/cameleer/saas/observability/ \
src/test/java/net/siegeln/cameleer/saas/observability/
git commit -m "feat: add agent status and observability status endpoints"
```
---
## Task 5: Traefik Routing Labels in DeploymentService
**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java`
- [ ] **Step 1: Build Traefik labels when app has exposedPort**
In `executeDeploymentAsync`, after building the `envVars` map and before creating `startRequest`, add label computation:
```java
// Build Traefik labels for inbound routing
var labels = new java.util.HashMap<String, String>();
if (app.getExposedPort() != null) {
labels.put("traefik.enable", "true");
labels.put("traefik.http.routers." + containerName + ".rule",
"Host(`" + app.getSlug() + "." + env.getSlug() + "."
+ tenant.getSlug() + "." + runtimeConfig.getDomain() + "`)");
labels.put("traefik.http.services." + containerName + ".loadbalancer.server.port",
String.valueOf(app.getExposedPort()));
}
```
Then pass `labels` to the `StartContainerRequest` constructor (replacing the `Map.of()` added in Task 2).
Note: The `tenant` variable is already looked up earlier in the method for container naming.
- [ ] **Step 2: Run unit tests**
Run: `mvn test -pl . -Dtest=DeploymentServiceTest -B`
Expected: All tests PASS
- [ ] **Step 3: Commit**
```bash
git add src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java
git commit -m "feat: add Traefik routing labels for customer apps with exposed ports"
```
---
## Task 6: Connectivity Health Check
**Files:**
- Create: `src/main/java/net/siegeln/cameleer/saas/observability/ConnectivityHealthCheck.java`
- [ ] **Step 1: Create startup connectivity check**
```java
package net.siegeln.cameleer.saas.observability;
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
@Component
public class ConnectivityHealthCheck {
private static final Logger log = LoggerFactory.getLogger(ConnectivityHealthCheck.class);
private final RuntimeConfig runtimeConfig;
public ConnectivityHealthCheck(RuntimeConfig runtimeConfig) {
this.runtimeConfig = runtimeConfig;
}
@EventListener(ApplicationReadyEvent.class)
public void verifyConnectivity() {
checkCameleer3Server();
}
private void checkCameleer3Server() {
try {
var client = RestClient.builder()
.baseUrl(runtimeConfig.getCameleer3ServerEndpoint())
.build();
var response = client.get()
.uri("/actuator/health")
.retrieve()
.toBodilessEntity();
if (response.getStatusCode().is2xxSuccessful()) {
log.info("cameleer3-server connectivity: OK ({})",
runtimeConfig.getCameleer3ServerEndpoint());
} else {
log.warn("cameleer3-server connectivity: HTTP {} ({})",
response.getStatusCode(), runtimeConfig.getCameleer3ServerEndpoint());
}
} catch (Exception e) {
log.warn("cameleer3-server connectivity: FAILED ({}) - {}",
runtimeConfig.getCameleer3ServerEndpoint(), e.getMessage());
}
}
}
```
- [ ] **Step 2: Verify compilation**
Run: `mvn compile -B -q`
- [ ] **Step 3: Commit**
```bash
git add src/main/java/net/siegeln/cameleer/saas/observability/ConnectivityHealthCheck.java
git commit -m "feat: add cameleer3-server startup connectivity check"
```
---
## Task 7: Docker Compose + .env + CI Updates
**Files:**
- Modify: `docker-compose.yml`
- Modify: `.env.example`
- Modify: `.gitea/workflows/ci.yml`
- [ ] **Step 1: Update docker-compose.yml — add dashboard route and CAMELEER_TENANT_ID**
In the `cameleer3-server` service:
Add to environment section:
```yaml
CAMELEER_TENANT_ID: ${CAMELEER_TENANT_SLUG:-default}
```
Add new Traefik labels (after existing ones):
```yaml
- traefik.http.routers.dashboard.rule=PathPrefix(`/dashboard`)
- traefik.http.routers.dashboard.middlewares=forward-auth,dashboard-strip
- traefik.http.middlewares.dashboard-strip.stripprefix.prefixes=/dashboard
- traefik.http.services.dashboard.loadbalancer.server.port=8080
```
- [ ] **Step 2: Update .env.example**
Add:
```
CAMELEER_TENANT_SLUG=default
```
- [ ] **Step 3: Update CI excludes**
In `.gitea/workflows/ci.yml`, add `**/AgentStatusControllerTest.java` to the Surefire excludes (if integration test exists).
- [ ] **Step 4: Run all unit tests**
Run: `mvn test -B -Dsurefire.excludes="**/*ControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java" -q`
- [ ] **Step 5: Commit**
```bash
git add docker-compose.yml .env.example .gitea/workflows/ci.yml
git commit -m "feat: add dashboard Traefik route and CAMELEER_TENANT_ID config"
```
---
## Task 8: Update HOWTO.md
**Files:**
- Modify: `HOWTO.md`
- [ ] **Step 1: Add observability and routing sections**
After the "Deploy a Camel Application" section, add:
**Observability Dashboard section** — explains how to access the dashboard at `/dashboard`, what data is visible.
**Inbound HTTP Routing section** — explains how to set `exposedPort` on an app and what URL to use.
**Agent Status section** — explains the agent-status and observability-status endpoints.
Update the API Reference table with the new endpoints:
- `GET /api/apps/{aid}/agent-status`
- `GET /api/apps/{aid}/observability-status`
- `PATCH /api/environments/{eid}/apps/{aid}/routing`
Update the .env table to include `CAMELEER_TENANT_SLUG`.
- [ ] **Step 2: Commit**
```bash
git add HOWTO.md
git commit -m "docs: update HOWTO with observability dashboard, routing, and agent status"
```
---
## Summary of Spec Coverage
| Spec Requirement | Task |
|---|---|
| Serve cameleer3-server dashboard via Traefik | Task 7 (dashboard Traefik labels) |
| CAMELEER_TENANT_ID configuration | Task 7 (docker-compose env) |
| Agent connectivity verification endpoint | Task 4 (AgentStatusService + Controller) |
| Observability data health endpoint | Task 4 (ObservabilityStatusResponse) |
| Inbound HTTP routing (exposedPort + Traefik labels) | Tasks 1, 2, 3, 5 |
| StartContainerRequest labels support | Task 2 |
| AppResponse with routeUrl | Task 3 |
| PATCH routing API | Task 3 |
| Startup connectivity check | Task 6 |
| Docker Compose changes | Task 7 |
| .env.example updates | Task 7 |
| HOWTO.md updates | Task 8 |
| V010 migration | Task 1 |

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,986 @@
# Plan 1: Auth & RBAC Overhaul
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add claim-based RBAC with managed/direct assignment origins, and make the server operate as a pure OAuth2 resource server when OIDC is configured.
**Architecture:** Extend the existing RBAC schema with an `origin` column (direct vs managed) on assignment tables, add a `claim_mapping_rules` table, and implement a ClaimMappingService that evaluates JWT claims against mapping rules on every OIDC login. When OIDC is configured, the server becomes a pure resource server — no local login, no JWT generation for users. Agents always use server-issued tokens regardless of auth mode.
**Tech Stack:** Java 17, Spring Boot 3.4.3, PostgreSQL 16, Flyway, JUnit 5, Testcontainers, AssertJ
**Repo:** `C:\Users\Hendrik\Documents\projects\cameleer3-server`
---
## File Map
### New Files
- `cameleer3-server-app/src/main/resources/db/migration/V2__claim_mapping.sql`
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingRule.java`
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingRepository.java`
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingService.java`
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/AssignmentOrigin.java`
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresClaimMappingRepository.java`
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ClaimMappingAdminController.java`
- `cameleer3-server-app/src/test/java/com/cameleer3/server/core/rbac/ClaimMappingServiceTest.java`
- `cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ClaimMappingAdminControllerIT.java`
- `cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/OidcOnlyModeIT.java`
### Modified Files
- `cameleer3-server-app/src/main/resources/db/migration/V1__init.sql` — no changes (immutable)
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/rbac/RbacServiceImpl.java` — add origin-aware query methods
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresUserRepository.java` — add origin-aware queries
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java` — replace syncOidcRoles with claim mapping
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java` — disable internal token path in OIDC-only mode
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java` — conditional endpoint registration
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java` — disable in OIDC-only mode
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryBeanConfig.java` — wire ClaimMappingService
- `cameleer3-server-app/src/main/resources/application.yml` — no new properties needed (OIDC config already exists)
---
### Task 1: Database Migration — Add Origin Tracking and Claim Mapping Rules
**Files:**
- Create: `cameleer3-server-app/src/main/resources/db/migration/V2__claim_mapping.sql`
- [ ] **Step 1: Write the migration**
```sql
-- V2__claim_mapping.sql
-- Add origin tracking to assignment tables
ALTER TABLE user_roles ADD COLUMN origin TEXT NOT NULL DEFAULT 'direct';
ALTER TABLE user_roles ADD COLUMN mapping_id UUID;
ALTER TABLE user_groups ADD COLUMN origin TEXT NOT NULL DEFAULT 'direct';
ALTER TABLE user_groups ADD COLUMN mapping_id UUID;
-- Drop old primary keys (they don't include origin)
ALTER TABLE user_roles DROP CONSTRAINT user_roles_pkey;
ALTER TABLE user_roles ADD PRIMARY KEY (user_id, role_id, origin);
ALTER TABLE user_groups DROP CONSTRAINT user_groups_pkey;
ALTER TABLE user_groups ADD PRIMARY KEY (user_id, group_id, origin);
-- Claim mapping rules table
CREATE TABLE claim_mapping_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
claim TEXT NOT NULL,
match_type TEXT NOT NULL,
match_value TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT NOT NULL,
priority INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_match_type CHECK (match_type IN ('equals', 'contains', 'regex')),
CONSTRAINT chk_action CHECK (action IN ('assignRole', 'addToGroup'))
);
-- Foreign key from assignments to mapping rules
ALTER TABLE user_roles ADD CONSTRAINT fk_user_roles_mapping
FOREIGN KEY (mapping_id) REFERENCES claim_mapping_rules(id) ON DELETE CASCADE;
ALTER TABLE user_groups ADD CONSTRAINT fk_user_groups_mapping
FOREIGN KEY (mapping_id) REFERENCES claim_mapping_rules(id) ON DELETE CASCADE;
-- Index for fast managed assignment cleanup
CREATE INDEX idx_user_roles_origin ON user_roles(user_id, origin);
CREATE INDEX idx_user_groups_origin ON user_groups(user_id, origin);
```
- [ ] **Step 2: Run migration to verify**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn flyway:migrate -pl cameleer3-server-app -Dflyway.url=jdbc:postgresql://localhost:5432/cameleer3 -Dflyway.user=cameleer -Dflyway.password=cameleer_dev`
If no local PostgreSQL, verify syntax by running the existing test suite which uses Testcontainers.
- [ ] **Step 3: Commit**
```bash
git add cameleer3-server-app/src/main/resources/db/migration/V2__claim_mapping.sql
git commit -m "feat: add claim mapping rules table and origin tracking to RBAC assignments"
```
---
### Task 2: Core Domain — ClaimMappingRule, AssignmentOrigin, Repository Interface
**Files:**
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/AssignmentOrigin.java`
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingRule.java`
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingRepository.java`
- [ ] **Step 1: Create AssignmentOrigin enum**
```java
package com.cameleer3.server.core.rbac;
public enum AssignmentOrigin {
direct, managed
}
```
- [ ] **Step 2: Create ClaimMappingRule record**
```java
package com.cameleer3.server.core.rbac;
import java.time.Instant;
import java.util.UUID;
public record ClaimMappingRule(
UUID id,
String claim,
String matchType,
String matchValue,
String action,
String target,
int priority,
Instant createdAt
) {
public enum MatchType { equals, contains, regex }
public enum Action { assignRole, addToGroup }
}
```
- [ ] **Step 3: Create ClaimMappingRepository interface**
```java
package com.cameleer3.server.core.rbac;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface ClaimMappingRepository {
List<ClaimMappingRule> findAll();
Optional<ClaimMappingRule> findById(UUID id);
UUID create(String claim, String matchType, String matchValue, String action, String target, int priority);
void update(UUID id, String claim, String matchType, String matchValue, String action, String target, int priority);
void delete(UUID id);
}
```
- [ ] **Step 4: Commit**
```bash
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/AssignmentOrigin.java
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingRule.java
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingRepository.java
git commit -m "feat: add ClaimMappingRule domain model and repository interface"
```
---
### Task 3: Core Domain — ClaimMappingService
**Files:**
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingService.java`
- Create: `cameleer3-server-app/src/test/java/com/cameleer3/server/core/rbac/ClaimMappingServiceTest.java`
- [ ] **Step 1: Write tests for ClaimMappingService**
```java
package com.cameleer3.server.core.rbac;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.*;
import static org.assertj.core.api.Assertions.assertThat;
class ClaimMappingServiceTest {
private ClaimMappingService service;
@BeforeEach
void setUp() {
service = new ClaimMappingService();
}
@Test
void evaluate_containsMatch_onStringArrayClaim() {
var rule = new ClaimMappingRule(
UUID.randomUUID(), "groups", "contains", "cameleer-admins",
"assignRole", "ADMIN", 0, null);
Map<String, Object> claims = Map.of("groups", List.of("eng", "cameleer-admins", "devops"));
var results = service.evaluate(List.of(rule), claims);
assertThat(results).hasSize(1);
assertThat(results.get(0).rule()).isEqualTo(rule);
}
@Test
void evaluate_equalsMatch_onStringClaim() {
var rule = new ClaimMappingRule(
UUID.randomUUID(), "department", "equals", "platform",
"assignRole", "OPERATOR", 0, null);
Map<String, Object> claims = Map.of("department", "platform");
var results = service.evaluate(List.of(rule), claims);
assertThat(results).hasSize(1);
}
@Test
void evaluate_regexMatch() {
var rule = new ClaimMappingRule(
UUID.randomUUID(), "email", "regex", ".*@example\\.com$",
"addToGroup", "Example Corp", 0, null);
Map<String, Object> claims = Map.of("email", "john@example.com");
var results = service.evaluate(List.of(rule), claims);
assertThat(results).hasSize(1);
}
@Test
void evaluate_noMatch_returnsEmpty() {
var rule = new ClaimMappingRule(
UUID.randomUUID(), "groups", "contains", "cameleer-admins",
"assignRole", "ADMIN", 0, null);
Map<String, Object> claims = Map.of("groups", List.of("eng", "devops"));
var results = service.evaluate(List.of(rule), claims);
assertThat(results).isEmpty();
}
@Test
void evaluate_missingClaim_returnsEmpty() {
var rule = new ClaimMappingRule(
UUID.randomUUID(), "groups", "contains", "admins",
"assignRole", "ADMIN", 0, null);
Map<String, Object> claims = Map.of("department", "eng");
var results = service.evaluate(List.of(rule), claims);
assertThat(results).isEmpty();
}
@Test
void evaluate_rulesOrderedByPriority() {
var lowPriority = new ClaimMappingRule(
UUID.randomUUID(), "role", "equals", "dev",
"assignRole", "VIEWER", 0, null);
var highPriority = new ClaimMappingRule(
UUID.randomUUID(), "role", "equals", "dev",
"assignRole", "OPERATOR", 10, null);
Map<String, Object> claims = Map.of("role", "dev");
var results = service.evaluate(List.of(highPriority, lowPriority), claims);
assertThat(results).hasSize(2);
assertThat(results.get(0).rule().priority()).isEqualTo(0);
assertThat(results.get(1).rule().priority()).isEqualTo(10);
}
@Test
void evaluate_containsMatch_onSpaceSeparatedString() {
var rule = new ClaimMappingRule(
UUID.randomUUID(), "scope", "contains", "server:admin",
"assignRole", "ADMIN", 0, null);
Map<String, Object> claims = Map.of("scope", "openid profile server:admin");
var results = service.evaluate(List.of(rule), claims);
assertThat(results).hasSize(1);
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=ClaimMappingServiceTest -Dsurefire.failIfNoSpecifiedTests=false`
Expected: Compilation error — ClaimMappingService does not exist yet.
- [ ] **Step 3: Implement ClaimMappingService**
```java
package com.cameleer3.server.core.rbac;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.*;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
public class ClaimMappingService {
private static final Logger log = LoggerFactory.getLogger(ClaimMappingService.class);
public record MappingResult(ClaimMappingRule rule) {}
public List<MappingResult> evaluate(List<ClaimMappingRule> rules, Map<String, Object> claims) {
return rules.stream()
.sorted(Comparator.comparingInt(ClaimMappingRule::priority))
.filter(rule -> matches(rule, claims))
.map(MappingResult::new)
.toList();
}
private boolean matches(ClaimMappingRule rule, Map<String, Object> claims) {
Object claimValue = claims.get(rule.claim());
if (claimValue == null) return false;
return switch (rule.matchType()) {
case "equals" -> equalsMatch(claimValue, rule.matchValue());
case "contains" -> containsMatch(claimValue, rule.matchValue());
case "regex" -> regexMatch(claimValue, rule.matchValue());
default -> {
log.warn("Unknown match type: {}", rule.matchType());
yield false;
}
};
}
private boolean equalsMatch(Object claimValue, String matchValue) {
if (claimValue instanceof String s) {
return s.equalsIgnoreCase(matchValue);
}
return String.valueOf(claimValue).equalsIgnoreCase(matchValue);
}
private boolean containsMatch(Object claimValue, String matchValue) {
if (claimValue instanceof List<?> list) {
return list.stream().anyMatch(item -> String.valueOf(item).equalsIgnoreCase(matchValue));
}
if (claimValue instanceof String s) {
// Space-separated string (e.g., OAuth2 scope claim)
return Arrays.stream(s.split("\\s+"))
.anyMatch(part -> part.equalsIgnoreCase(matchValue));
}
return false;
}
private boolean regexMatch(Object claimValue, String matchValue) {
String s = String.valueOf(claimValue);
try {
return Pattern.matches(matchValue, s);
} catch (Exception e) {
log.warn("Invalid regex in claim mapping rule: {}", matchValue, e);
return false;
}
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=ClaimMappingServiceTest`
Expected: All 7 tests PASS.
- [ ] **Step 5: Commit**
```bash
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingService.java
git add cameleer3-server-app/src/test/java/com/cameleer3/server/core/rbac/ClaimMappingServiceTest.java
git commit -m "feat: implement ClaimMappingService with equals/contains/regex matching"
```
---
### Task 4: PostgreSQL Repository — ClaimMappingRepository Implementation
**Files:**
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresClaimMappingRepository.java`
- [ ] **Step 1: Implement PostgresClaimMappingRepository**
```java
package com.cameleer3.server.app.storage;
import com.cameleer3.server.core.rbac.ClaimMappingRepository;
import com.cameleer3.server.core.rbac.ClaimMappingRule;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public class PostgresClaimMappingRepository implements ClaimMappingRepository {
private final JdbcTemplate jdbc;
public PostgresClaimMappingRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Override
public List<ClaimMappingRule> findAll() {
return jdbc.query("""
SELECT id, claim, match_type, match_value, action, target, priority, created_at
FROM claim_mapping_rules ORDER BY priority, created_at
""", (rs, i) -> new ClaimMappingRule(
rs.getObject("id", UUID.class),
rs.getString("claim"),
rs.getString("match_type"),
rs.getString("match_value"),
rs.getString("action"),
rs.getString("target"),
rs.getInt("priority"),
rs.getTimestamp("created_at").toInstant()
));
}
@Override
public Optional<ClaimMappingRule> findById(UUID id) {
var results = jdbc.query("""
SELECT id, claim, match_type, match_value, action, target, priority, created_at
FROM claim_mapping_rules WHERE id = ?
""", (rs, i) -> new ClaimMappingRule(
rs.getObject("id", UUID.class),
rs.getString("claim"),
rs.getString("match_type"),
rs.getString("match_value"),
rs.getString("action"),
rs.getString("target"),
rs.getInt("priority"),
rs.getTimestamp("created_at").toInstant()
), id);
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
@Override
public UUID create(String claim, String matchType, String matchValue, String action, String target, int priority) {
UUID id = UUID.randomUUID();
jdbc.update("""
INSERT INTO claim_mapping_rules (id, claim, match_type, match_value, action, target, priority)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", id, claim, matchType, matchValue, action, target, priority);
return id;
}
@Override
public void update(UUID id, String claim, String matchType, String matchValue, String action, String target, int priority) {
jdbc.update("""
UPDATE claim_mapping_rules
SET claim = ?, match_type = ?, match_value = ?, action = ?, target = ?, priority = ?
WHERE id = ?
""", claim, matchType, matchValue, action, target, priority, id);
}
@Override
public void delete(UUID id) {
jdbc.update("DELETE FROM claim_mapping_rules WHERE id = ?", id);
}
}
```
- [ ] **Step 2: Wire the bean in AgentRegistryBeanConfig (or a new RbacBeanConfig)**
Add to `cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryBeanConfig.java` (or create a new `RbacBeanConfig.java`):
```java
@Bean
public ClaimMappingRepository claimMappingRepository(JdbcTemplate jdbcTemplate) {
return new PostgresClaimMappingRepository(jdbcTemplate);
}
@Bean
public ClaimMappingService claimMappingService() {
return new ClaimMappingService();
}
```
- [ ] **Step 3: Commit**
```bash
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresClaimMappingRepository.java
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryBeanConfig.java
git commit -m "feat: implement PostgresClaimMappingRepository and wire beans"
```
---
### Task 5: Modify RbacServiceImpl — Origin-Aware Assignments
**Files:**
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/rbac/RbacServiceImpl.java`
- [ ] **Step 1: Add managed assignment methods to RbacService interface**
In `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RbacService.java`, add:
```java
void clearManagedAssignments(String userId);
void assignManagedRole(String userId, UUID roleId, UUID mappingId);
void addUserToManagedGroup(String userId, UUID groupId, UUID mappingId);
```
- [ ] **Step 2: Implement in RbacServiceImpl**
Add these methods to `RbacServiceImpl.java`:
```java
@Override
public void clearManagedAssignments(String userId) {
jdbc.update("DELETE FROM user_roles WHERE user_id = ? AND origin = 'managed'", userId);
jdbc.update("DELETE FROM user_groups WHERE user_id = ? AND origin = 'managed'", userId);
}
@Override
public void assignManagedRole(String userId, UUID roleId, UUID mappingId) {
jdbc.update("""
INSERT INTO user_roles (user_id, role_id, origin, mapping_id)
VALUES (?, ?, 'managed', ?)
ON CONFLICT (user_id, role_id, origin) DO UPDATE SET mapping_id = EXCLUDED.mapping_id
""", userId, roleId, mappingId);
}
@Override
public void addUserToManagedGroup(String userId, UUID groupId, UUID mappingId) {
jdbc.update("""
INSERT INTO user_groups (user_id, group_id, origin, mapping_id)
VALUES (?, ?, 'managed', ?)
ON CONFLICT (user_id, group_id, origin) DO UPDATE SET mapping_id = EXCLUDED.mapping_id
""", userId, groupId, mappingId);
}
```
- [ ] **Step 3: Update existing assignRoleToUser to specify origin='direct'**
Modify the existing `assignRoleToUser` and `addUserToGroup` methods to explicitly set `origin = 'direct'`:
```java
@Override
public void assignRoleToUser(String userId, UUID roleId) {
jdbc.update("""
INSERT INTO user_roles (user_id, role_id, origin)
VALUES (?, ?, 'direct')
ON CONFLICT (user_id, role_id, origin) DO NOTHING
""", userId, roleId);
}
@Override
public void addUserToGroup(String userId, UUID groupId) {
jdbc.update("""
INSERT INTO user_groups (user_id, group_id, origin)
VALUES (?, ?, 'direct')
ON CONFLICT (user_id, group_id, origin) DO NOTHING
""", userId, groupId);
}
```
- [ ] **Step 4: Update getDirectRolesForUser to filter by origin='direct'**
```java
@Override
public List<RoleSummary> getDirectRolesForUser(String userId) {
return jdbc.query("""
SELECT r.id, r.name, r.system FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
WHERE ur.user_id = ? AND ur.origin = 'direct'
""", (rs, i) -> new RoleSummary(
rs.getObject("id", UUID.class),
rs.getString("name"),
rs.getBoolean("system"),
"direct"
), userId);
}
```
- [ ] **Step 5: Run existing tests**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app`
Expected: All existing tests still pass (migration adds columns with defaults).
- [ ] **Step 6: Commit**
```bash
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RbacService.java
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/rbac/RbacServiceImpl.java
git commit -m "feat: add origin-aware managed/direct assignment methods to RbacService"
```
---
### Task 6: Modify OidcAuthController — Replace syncOidcRoles with Claim Mapping
**Files:**
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java`
- [ ] **Step 1: Inject ClaimMappingService and ClaimMappingRepository**
Add to constructor:
```java
private final ClaimMappingService claimMappingService;
private final ClaimMappingRepository claimMappingRepository;
```
- [ ] **Step 2: Replace syncOidcRoles with applyClaimMappings**
Replace the `syncOidcRoles` method (lines 176-208) with:
```java
private void applyClaimMappings(String userId, Map<String, Object> claims) {
List<ClaimMappingRule> rules = claimMappingRepository.findAll();
if (rules.isEmpty()) {
log.debug("No claim mapping rules configured, skipping for user {}", userId);
return;
}
rbacService.clearManagedAssignments(userId);
List<ClaimMappingService.MappingResult> results = claimMappingService.evaluate(rules, claims);
for (var result : results) {
ClaimMappingRule rule = result.rule();
switch (rule.action()) {
case "assignRole" -> {
UUID roleId = SystemRole.BY_NAME.get(SystemRole.normalizeScope(rule.target()));
if (roleId == null) {
log.warn("Claim mapping target role '{}' not found, skipping", rule.target());
continue;
}
rbacService.assignManagedRole(userId, roleId, rule.id());
log.debug("Managed role {} assigned to {} via mapping {}", rule.target(), userId, rule.id());
}
case "addToGroup" -> {
// Look up group by name
var groups = groupRepository.findAll();
var group = groups.stream().filter(g -> g.name().equalsIgnoreCase(rule.target())).findFirst();
if (group.isEmpty()) {
log.warn("Claim mapping target group '{}' not found, skipping", rule.target());
continue;
}
rbacService.addUserToManagedGroup(userId, group.get().id(), rule.id());
log.debug("Managed group {} assigned to {} via mapping {}", rule.target(), userId, rule.id());
}
}
}
}
```
- [ ] **Step 3: Update callback() to call applyClaimMappings**
In the `callback()` method, replace the `syncOidcRoles(userId, oidcRoles, config)` call with:
```java
// Extract all claims from the access token for claim mapping
Map<String, Object> claims = tokenExchanger.extractAllClaims(oidcUser);
applyClaimMappings(userId, claims);
```
Note: `extractAllClaims` needs to be added to `OidcTokenExchanger` — it returns the raw JWT claims map from the access token.
- [ ] **Step 4: Run existing tests**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app`
Expected: PASS (OIDC tests may need adjustment if they test syncOidcRoles directly).
- [ ] **Step 5: Commit**
```bash
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java
git commit -m "feat: replace syncOidcRoles with claim mapping evaluation on OIDC login"
```
---
### Task 7: OIDC-Only Mode — Disable Local Auth When OIDC Configured
**Files:**
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java`
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java`
- [ ] **Step 1: Add isOidcEnabled() helper to SecurityConfig**
```java
private boolean isOidcEnabled() {
return oidcIssuerUri != null && !oidcIssuerUri.isBlank();
}
```
- [ ] **Step 2: Conditionally disable local login endpoints**
In `SecurityConfig.filterChain()`, when OIDC is enabled, remove `/api/v1/auth/login` and `/api/v1/auth/refresh` from public endpoints (or let them return 404). The simplest approach: add a condition in `UiAuthController`:
```java
// In UiAuthController
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
if (oidcEnabled) {
return ResponseEntity.status(404).body(Map.of("error", "Local login disabled when OIDC is configured"));
}
// ... existing logic
}
```
- [ ] **Step 3: Modify JwtAuthenticationFilter to skip internal token path for user tokens in OIDC mode**
In `JwtAuthenticationFilter`, when OIDC is enabled, only accept internal (HMAC) tokens for agent subjects (starting with no `user:` prefix or explicitly agent subjects). User-facing tokens must come from the OIDC decoder:
```java
private void tryInternalToken(String token, HttpServletRequest request) {
try {
JwtService.JwtValidationResult result = jwtService.validateAccessToken(token);
// In OIDC mode, only accept agent tokens via internal validation
if (oidcDecoder != null && result.subject() != null && result.subject().startsWith("user:")) {
return; // User tokens must go through OIDC path
}
setAuthentication(result, request);
} catch (Exception e) {
// Not a valid internal token, will try OIDC next
}
}
```
- [ ] **Step 4: Disable user admin endpoints in OIDC mode**
In `UserAdminController`, add a guard for user creation and password reset:
```java
@PostMapping
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) {
if (oidcEnabled) {
return ResponseEntity.status(400).body(Map.of("error", "User creation disabled when OIDC is configured. Users are auto-provisioned on OIDC login."));
}
// ... existing logic
}
@PostMapping("/{userId}/password")
public ResponseEntity<?> resetPassword(@PathVariable String userId, @RequestBody PasswordRequest request) {
if (oidcEnabled) {
return ResponseEntity.status(400).body(Map.of("error", "Password management disabled when OIDC is configured"));
}
// ... existing logic
}
```
- [ ] **Step 5: Run full test suite**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java
git commit -m "feat: disable local auth when OIDC is configured (resource server mode)"
```
---
### Task 8: Claim Mapping Admin Controller
**Files:**
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ClaimMappingAdminController.java`
- [ ] **Step 1: Implement the controller**
```java
package com.cameleer3.server.app.controller;
import com.cameleer3.server.core.rbac.ClaimMappingRepository;
import com.cameleer3.server.core.rbac.ClaimMappingRule;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/admin/claim-mappings")
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "Claim Mapping Admin", description = "Manage OIDC claim-to-role/group mapping rules")
public class ClaimMappingAdminController {
private final ClaimMappingRepository repository;
public ClaimMappingAdminController(ClaimMappingRepository repository) {
this.repository = repository;
}
@GetMapping
@Operation(summary = "List all claim mapping rules")
public List<ClaimMappingRule> list() {
return repository.findAll();
}
@GetMapping("/{id}")
@Operation(summary = "Get a claim mapping rule by ID")
public ResponseEntity<ClaimMappingRule> get(@PathVariable UUID id) {
return repository.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
record CreateRuleRequest(String claim, String matchType, String matchValue,
String action, String target, int priority) {}
@PostMapping
@Operation(summary = "Create a claim mapping rule")
public ResponseEntity<ClaimMappingRule> create(@RequestBody CreateRuleRequest request) {
UUID id = repository.create(
request.claim(), request.matchType(), request.matchValue(),
request.action(), request.target(), request.priority());
return repository.findById(id)
.map(rule -> ResponseEntity.created(URI.create("/api/v1/admin/claim-mappings/" + id)).body(rule))
.orElse(ResponseEntity.internalServerError().build());
}
@PutMapping("/{id}")
@Operation(summary = "Update a claim mapping rule")
public ResponseEntity<ClaimMappingRule> update(@PathVariable UUID id, @RequestBody CreateRuleRequest request) {
if (repository.findById(id).isEmpty()) {
return ResponseEntity.notFound().build();
}
repository.update(id, request.claim(), request.matchType(), request.matchValue(),
request.action(), request.target(), request.priority());
return repository.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.internalServerError().build());
}
@DeleteMapping("/{id}")
@Operation(summary = "Delete a claim mapping rule")
public ResponseEntity<Void> delete(@PathVariable UUID id) {
if (repository.findById(id).isEmpty()) {
return ResponseEntity.notFound().build();
}
repository.delete(id);
return ResponseEntity.noContent().build();
}
}
```
- [ ] **Step 2: Add endpoint to SecurityConfig**
In `SecurityConfig.filterChain()`, the `/api/v1/admin/**` path already requires ADMIN role. No changes needed.
- [ ] **Step 3: Run full test suite**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ClaimMappingAdminController.java
git commit -m "feat: add ClaimMappingAdminController for CRUD on mapping rules"
```
---
### Task 9: Integration Test — Claim Mapping End-to-End
**Files:**
- Create: `cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ClaimMappingAdminControllerIT.java`
- [ ] **Step 1: Write integration test**
```java
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.AbstractPostgresIT;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;
import static org.assertj.core.api.Assertions.assertThat;
class ClaimMappingAdminControllerIT extends AbstractPostgresIT {
@Autowired private TestRestTemplate restTemplate;
@Autowired private ObjectMapper objectMapper;
@Autowired private TestSecurityHelper securityHelper;
private HttpHeaders adminHeaders;
@BeforeEach
void setUp() {
adminHeaders = securityHelper.adminHeaders();
}
@Test
void createAndListRules() throws Exception {
String body = """
{"claim":"groups","matchType":"contains","matchValue":"admins","action":"assignRole","target":"ADMIN","priority":0}
""";
var createResponse = restTemplate.exchange("/api/v1/admin/claim-mappings",
HttpMethod.POST, new HttpEntity<>(body, adminHeaders), String.class);
assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
var listResponse = restTemplate.exchange("/api/v1/admin/claim-mappings",
HttpMethod.GET, new HttpEntity<>(adminHeaders), String.class);
assertThat(listResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode rules = objectMapper.readTree(listResponse.getBody());
assertThat(rules.isArray()).isTrue();
assertThat(rules.size()).isGreaterThanOrEqualTo(1);
}
@Test
void deleteRule() throws Exception {
String body = """
{"claim":"dept","matchType":"equals","matchValue":"eng","action":"assignRole","target":"VIEWER","priority":0}
""";
var createResponse = restTemplate.exchange("/api/v1/admin/claim-mappings",
HttpMethod.POST, new HttpEntity<>(body, adminHeaders), String.class);
JsonNode created = objectMapper.readTree(createResponse.getBody());
String id = created.get("id").asText();
var deleteResponse = restTemplate.exchange("/api/v1/admin/claim-mappings/" + id,
HttpMethod.DELETE, new HttpEntity<>(adminHeaders), Void.class);
assertThat(deleteResponse.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
var getResponse = restTemplate.exchange("/api/v1/admin/claim-mappings/" + id,
HttpMethod.GET, new HttpEntity<>(adminHeaders), String.class);
assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
}
```
- [ ] **Step 2: Run integration tests**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=ClaimMappingAdminControllerIT`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ClaimMappingAdminControllerIT.java
git commit -m "test: add integration tests for claim mapping admin API"
```
---
### Task 10: Run Full Test Suite and Final Verification
- [ ] **Step 1: Run all tests**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean verify`
Expected: All tests PASS. Build succeeds.
- [ ] **Step 2: Verify migration applies cleanly on fresh database**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=AbstractPostgresIT`
Expected: Testcontainers starts fresh PostgreSQL, Flyway applies V1 + V2, context loads.
- [ ] **Step 3: Commit any remaining fixes**
```bash
git add -A
git commit -m "chore: finalize auth & RBAC overhaul — all tests passing"
```

View File

@@ -0,0 +1,615 @@
# Plan 2: Server-Side License Validation
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add Ed25519-signed license JWT validation to the server, enabling feature gating for MOAT features (debugger, lineage, correlation) by tier.
**Architecture:** The SaaS generates Ed25519-signed license JWTs containing tier, features, limits, and expiry. The server validates the license on startup (from env var or file) or at runtime (via admin API). A `LicenseGate` service checks whether a feature is enabled before serving gated endpoints. The server's existing Ed25519 infrastructure (JDK 17 `java.security`) is reused for verification. In standalone mode without a license, all features are available (open/dev mode).
**Tech Stack:** Java 17, Spring Boot 3.4.3, Ed25519 (JDK built-in), Nimbus JOSE JWT, JUnit 5, AssertJ
**Repo:** `C:\Users\Hendrik\Documents\projects\cameleer3-server`
---
## File Map
### New Files
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseInfo.java`
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseValidator.java`
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseGate.java`
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/Feature.java`
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/LicenseBeanConfig.java`
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/LicenseAdminController.java`
- `cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseValidatorTest.java`
- `cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseGateTest.java`
### Modified Files
- `cameleer3-server-app/src/main/resources/application.yml` — add license config properties
---
### Task 1: Core Domain — LicenseInfo, Feature Enum
**Files:**
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/Feature.java`
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseInfo.java`
- [ ] **Step 1: Create Feature enum**
```java
package com.cameleer3.server.core.license;
public enum Feature {
topology,
lineage,
correlation,
debugger,
replay
}
```
- [ ] **Step 2: Create LicenseInfo record**
```java
package com.cameleer3.server.core.license;
import java.time.Instant;
import java.util.Map;
import java.util.Set;
public record LicenseInfo(
String tier,
Set<Feature> features,
Map<String, Integer> limits,
Instant issuedAt,
Instant expiresAt
) {
public boolean isExpired() {
return expiresAt != null && Instant.now().isAfter(expiresAt);
}
public boolean hasFeature(Feature feature) {
return features.contains(feature);
}
public int getLimit(String key, int defaultValue) {
return limits.getOrDefault(key, defaultValue);
}
/** Open license — all features enabled, no limits. Used when no license is configured. */
public static LicenseInfo open() {
return new LicenseInfo("open", Set.of(Feature.values()), Map.of(), Instant.now(), null);
}
}
```
- [ ] **Step 3: Commit**
```bash
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/Feature.java
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseInfo.java
git commit -m "feat: add LicenseInfo and Feature domain model"
```
---
### Task 2: LicenseValidator — Ed25519 JWT Verification
**Files:**
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseValidator.java`
- Create: `cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseValidatorTest.java`
- [ ] **Step 1: Write tests**
```java
package com.cameleer3.server.core.license;
import org.junit.jupiter.api.Test;
import java.security.*;
import java.security.spec.NamedParameterSpec;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class LicenseValidatorTest {
private KeyPair generateKeyPair() throws Exception {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("Ed25519");
return kpg.generateKeyPair();
}
private String sign(PrivateKey key, String payload) throws Exception {
Signature signer = Signature.getInstance("Ed25519");
signer.initSign(key);
signer.update(payload.getBytes());
return Base64.getEncoder().encodeToString(signer.sign());
}
@Test
void validate_validLicense_returnsLicenseInfo() throws Exception {
KeyPair kp = generateKeyPair();
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
LicenseValidator validator = new LicenseValidator(publicKeyBase64);
Instant expires = Instant.now().plus(365, ChronoUnit.DAYS);
String payload = """
{"tier":"HIGH","features":["topology","lineage","debugger"],"limits":{"max_agents":50,"retention_days":90},"iat":%d,"exp":%d}
""".formatted(Instant.now().getEpochSecond(), expires.getEpochSecond()).trim();
String signature = sign(kp.getPrivate(), payload);
String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + signature;
LicenseInfo info = validator.validate(token);
assertThat(info.tier()).isEqualTo("HIGH");
assertThat(info.hasFeature(Feature.debugger)).isTrue();
assertThat(info.hasFeature(Feature.replay)).isFalse();
assertThat(info.getLimit("max_agents", 0)).isEqualTo(50);
assertThat(info.isExpired()).isFalse();
}
@Test
void validate_expiredLicense_throwsException() throws Exception {
KeyPair kp = generateKeyPair();
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
LicenseValidator validator = new LicenseValidator(publicKeyBase64);
Instant past = Instant.now().minus(1, ChronoUnit.DAYS);
String payload = """
{"tier":"LOW","features":["topology"],"limits":{},"iat":%d,"exp":%d}
""".formatted(past.minus(30, ChronoUnit.DAYS).getEpochSecond(), past.getEpochSecond()).trim();
String signature = sign(kp.getPrivate(), payload);
String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + signature;
assertThatThrownBy(() -> validator.validate(token))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("expired");
}
@Test
void validate_tamperedPayload_throwsException() throws Exception {
KeyPair kp = generateKeyPair();
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
LicenseValidator validator = new LicenseValidator(publicKeyBase64);
String payload = """
{"tier":"LOW","features":["topology"],"limits":{},"iat":0,"exp":9999999999}
""".trim();
String signature = sign(kp.getPrivate(), payload);
// Tamper with payload
String tampered = payload.replace("LOW", "BUSINESS");
String token = Base64.getEncoder().encodeToString(tampered.getBytes()) + "." + signature;
assertThatThrownBy(() -> validator.validate(token))
.isInstanceOf(SecurityException.class)
.hasMessageContaining("signature");
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=LicenseValidatorTest -Dsurefire.failIfNoSpecifiedTests=false`
Expected: Compilation error — LicenseValidator does not exist.
- [ ] **Step 3: Implement LicenseValidator**
```java
package com.cameleer3.server.core.license;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.security.*;
import java.security.spec.X509EncodedKeySpec;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
public class LicenseValidator {
private static final Logger log = LoggerFactory.getLogger(LicenseValidator.class);
private static final ObjectMapper objectMapper = new ObjectMapper();
private final PublicKey publicKey;
public LicenseValidator(String publicKeyBase64) {
try {
byte[] keyBytes = Base64.getDecoder().decode(publicKeyBase64);
KeyFactory kf = KeyFactory.getInstance("Ed25519");
this.publicKey = kf.generatePublic(new X509EncodedKeySpec(keyBytes));
} catch (Exception e) {
throw new IllegalStateException("Failed to load license public key", e);
}
}
public LicenseInfo validate(String token) {
String[] parts = token.split("\\.", 2);
if (parts.length != 2) {
throw new IllegalArgumentException("Invalid license token format: expected payload.signature");
}
byte[] payloadBytes = Base64.getDecoder().decode(parts[0]);
byte[] signatureBytes = Base64.getDecoder().decode(parts[1]);
// Verify signature
try {
Signature verifier = Signature.getInstance("Ed25519");
verifier.initVerify(publicKey);
verifier.update(payloadBytes);
if (!verifier.verify(signatureBytes)) {
throw new SecurityException("License signature verification failed");
}
} catch (SecurityException e) {
throw e;
} catch (Exception e) {
throw new SecurityException("License signature verification failed", e);
}
// Parse payload
try {
JsonNode root = objectMapper.readTree(payloadBytes);
String tier = root.get("tier").asText();
Set<Feature> features = new HashSet<>();
if (root.has("features")) {
for (JsonNode f : root.get("features")) {
try {
features.add(Feature.valueOf(f.asText()));
} catch (IllegalArgumentException e) {
log.warn("Unknown feature in license: {}", f.asText());
}
}
}
Map<String, Integer> limits = new HashMap<>();
if (root.has("limits")) {
root.get("limits").fields().forEachRemaining(entry ->
limits.put(entry.getKey(), entry.getValue().asInt()));
}
Instant issuedAt = root.has("iat") ? Instant.ofEpochSecond(root.get("iat").asLong()) : Instant.now();
Instant expiresAt = root.has("exp") ? Instant.ofEpochSecond(root.get("exp").asLong()) : null;
LicenseInfo info = new LicenseInfo(tier, features, limits, issuedAt, expiresAt);
if (info.isExpired()) {
throw new IllegalArgumentException("License expired at " + expiresAt);
}
return info;
} catch (IllegalArgumentException e) {
throw e;
} catch (Exception e) {
throw new IllegalArgumentException("Failed to parse license payload", e);
}
}
}
```
- [ ] **Step 4: Run tests**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=LicenseValidatorTest`
Expected: All 3 tests PASS.
- [ ] **Step 5: Commit**
```bash
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseValidator.java
git add cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseValidatorTest.java
git commit -m "feat: implement LicenseValidator with Ed25519 signature verification"
```
---
### Task 3: LicenseGate — Feature Check Service
**Files:**
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseGate.java`
- Create: `cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseGateTest.java`
- [ ] **Step 1: Write tests**
```java
package com.cameleer3.server.core.license;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class LicenseGateTest {
@Test
void noLicense_allFeaturesEnabled() {
LicenseGate gate = new LicenseGate();
// No license loaded → open mode
assertThat(gate.isEnabled(Feature.debugger)).isTrue();
assertThat(gate.isEnabled(Feature.replay)).isTrue();
assertThat(gate.isEnabled(Feature.lineage)).isTrue();
assertThat(gate.getTier()).isEqualTo("open");
}
@Test
void withLicense_onlyLicensedFeaturesEnabled() {
LicenseGate gate = new LicenseGate();
LicenseInfo license = new LicenseInfo("MID",
Set.of(Feature.topology, Feature.lineage, Feature.correlation),
Map.of("max_agents", 10, "retention_days", 30),
Instant.now(), Instant.now().plus(365, ChronoUnit.DAYS));
gate.load(license);
assertThat(gate.isEnabled(Feature.topology)).isTrue();
assertThat(gate.isEnabled(Feature.lineage)).isTrue();
assertThat(gate.isEnabled(Feature.debugger)).isFalse();
assertThat(gate.isEnabled(Feature.replay)).isFalse();
assertThat(gate.getTier()).isEqualTo("MID");
assertThat(gate.getLimit("max_agents", 0)).isEqualTo(10);
}
}
```
- [ ] **Step 2: Implement LicenseGate**
```java
package com.cameleer3.server.core.license;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.atomic.AtomicReference;
public class LicenseGate {
private static final Logger log = LoggerFactory.getLogger(LicenseGate.class);
private final AtomicReference<LicenseInfo> current = new AtomicReference<>(LicenseInfo.open());
public void load(LicenseInfo license) {
current.set(license);
log.info("License loaded: tier={}, features={}, expires={}",
license.tier(), license.features(), license.expiresAt());
}
public boolean isEnabled(Feature feature) {
return current.get().hasFeature(feature);
}
public String getTier() {
return current.get().tier();
}
public int getLimit(String key, int defaultValue) {
return current.get().getLimit(key, defaultValue);
}
public LicenseInfo getCurrent() {
return current.get();
}
}
```
- [ ] **Step 3: Run tests**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=LicenseGateTest`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseGate.java
git add cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseGateTest.java
git commit -m "feat: implement LicenseGate for feature checking"
```
---
### Task 4: License Loading — Bean Config and Startup
**Files:**
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/LicenseBeanConfig.java`
- Modify: `cameleer3-server-app/src/main/resources/application.yml`
- [ ] **Step 1: Add license config properties to application.yml**
```yaml
license:
token: ${CAMELEER_LICENSE_TOKEN:}
file: ${CAMELEER_LICENSE_FILE:}
public-key: ${CAMELEER_LICENSE_PUBLIC_KEY:}
```
- [ ] **Step 2: Implement LicenseBeanConfig**
```java
package com.cameleer3.server.app.config;
import com.cameleer3.server.core.license.LicenseGate;
import com.cameleer3.server.core.license.LicenseInfo;
import com.cameleer3.server.core.license.LicenseValidator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.nio.file.Files;
import java.nio.file.Path;
@Configuration
public class LicenseBeanConfig {
private static final Logger log = LoggerFactory.getLogger(LicenseBeanConfig.class);
@Value("${license.token:}")
private String licenseToken;
@Value("${license.file:}")
private String licenseFile;
@Value("${license.public-key:}")
private String licensePublicKey;
@Bean
public LicenseGate licenseGate() {
LicenseGate gate = new LicenseGate();
String token = resolveLicenseToken();
if (token == null || token.isBlank()) {
log.info("No license configured — running in open mode (all features enabled)");
return gate;
}
if (licensePublicKey == null || licensePublicKey.isBlank()) {
log.warn("License token provided but no public key configured (CAMELEER_LICENSE_PUBLIC_KEY). Running in open mode.");
return gate;
}
try {
LicenseValidator validator = new LicenseValidator(licensePublicKey);
LicenseInfo info = validator.validate(token);
gate.load(info);
} catch (Exception e) {
log.error("Failed to validate license: {}. Running in open mode.", e.getMessage());
}
return gate;
}
private String resolveLicenseToken() {
if (licenseToken != null && !licenseToken.isBlank()) {
return licenseToken;
}
if (licenseFile != null && !licenseFile.isBlank()) {
try {
return Files.readString(Path.of(licenseFile)).trim();
} catch (Exception e) {
log.warn("Failed to read license file {}: {}", licenseFile, e.getMessage());
}
}
return null;
}
}
```
- [ ] **Step 3: Commit**
```bash
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/LicenseBeanConfig.java
git add cameleer3-server-app/src/main/resources/application.yml
git commit -m "feat: add license loading at startup from env var or file"
```
---
### Task 5: License Admin API — Runtime License Update
**Files:**
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/LicenseAdminController.java`
- [ ] **Step 1: Implement controller**
```java
package com.cameleer3.server.app.controller;
import com.cameleer3.server.core.license.LicenseGate;
import com.cameleer3.server.core.license.LicenseInfo;
import com.cameleer3.server.core.license.LicenseValidator;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/admin/license")
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "License Admin", description = "License management")
public class LicenseAdminController {
private final LicenseGate licenseGate;
private final String licensePublicKey;
public LicenseAdminController(LicenseGate licenseGate,
@Value("${license.public-key:}") String licensePublicKey) {
this.licenseGate = licenseGate;
this.licensePublicKey = licensePublicKey;
}
@GetMapping
@Operation(summary = "Get current license info")
public ResponseEntity<LicenseInfo> getCurrent() {
return ResponseEntity.ok(licenseGate.getCurrent());
}
record UpdateLicenseRequest(String token) {}
@PostMapping
@Operation(summary = "Update license token at runtime")
public ResponseEntity<?> update(@RequestBody UpdateLicenseRequest request) {
if (licensePublicKey == null || licensePublicKey.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "No license public key configured"));
}
try {
LicenseValidator validator = new LicenseValidator(licensePublicKey);
LicenseInfo info = validator.validate(request.token());
licenseGate.load(info);
return ResponseEntity.ok(info);
} catch (Exception e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
}
```
- [ ] **Step 2: Run full test suite**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean verify`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/LicenseAdminController.java
git commit -m "feat: add license admin API for runtime license updates"
```
---
### Task 6: Feature Gating — Wire LicenseGate Into Endpoints
This task is a placeholder — MOAT feature endpoints don't exist yet. When they're added (debugger, lineage, correlation), they should inject `LicenseGate` and check `isEnabled(Feature.xxx)` before serving:
```java
@GetMapping("/api/v1/debug/sessions")
public ResponseEntity<?> listDebugSessions() {
if (!licenseGate.isEnabled(Feature.debugger)) {
return ResponseEntity.status(403).body(Map.of("error", "Feature 'debugger' requires a HIGH or BUSINESS tier license"));
}
// ... serve debug sessions
}
```
- [ ] **Step 1: No code changes needed now — document the pattern for MOAT feature implementation**
- [ ] **Step 2: Final verification**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean verify`
Expected: All tests PASS.

View File

@@ -0,0 +1,993 @@
# Plan 3: Runtime Management in the Server
> **Status: COMPLETED** — Verified 2026-04-09. All runtime management fully ported to cameleer3-server with enhancements beyond the original plan.
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
**Goal:** Move environment management, app lifecycle, JAR upload, and Docker container orchestration from the SaaS layer into the server, so the server is a self-sufficient product that can deploy and manage Camel applications.
**Architecture:** The server gains Environment/App/AppVersion/Deployment entities stored in its PostgreSQL. A `RuntimeOrchestrator` interface abstracts Docker/K8s/disabled modes, auto-detected at startup. The Docker implementation uses a shared base image + volume-mounted JARs (no per-deployment image builds). Apps are promoted between environments by creating new Deployments pointing to the same AppVersion. Routing supports both path-based and subdomain-based modes via Traefik labels.
**Tech Stack:** Java 17, Spring Boot 3.4.3, docker-java (zerodep transport), PostgreSQL 16, Flyway, JUnit 5, Testcontainers
**Repo:** `C:\Users\Hendrik\Documents\projects\cameleer3-server`
**Source reference:** Code ported from `C:\Users\Hendrik\Documents\projects\cameleer-saas` (environment, app, deployment, runtime packages)
---
## File Map
### New Files — Core Module (`cameleer3-server-core`)
```
src/main/java/com/cameleer3/server/core/runtime/
├── Environment.java Record: id, slug, displayName, status, createdAt
├── EnvironmentStatus.java Enum: ACTIVE, SUSPENDED
├── EnvironmentRepository.java Interface: CRUD + findBySlug
├── EnvironmentService.java Business logic: create, list, delete, enforce limits
├── App.java Record: id, environmentId, slug, displayName, createdAt
├── AppVersion.java Record: id, appId, version, jarPath, sha256, uploadedAt
├── AppRepository.java Interface: CRUD + findByEnvironmentId
├── AppVersionRepository.java Interface: CRUD + findByAppId
├── AppService.java Business logic: create, upload JAR, list, delete
├── Deployment.java Record: id, appId, appVersionId, environmentId, status, containerId
├── DeploymentStatus.java Enum: STARTING, RUNNING, FAILED, STOPPED
├── DeploymentRepository.java Interface: CRUD + findByAppId + findByEnvironmentId
├── DeploymentService.java Business logic: deploy, stop, restart, promote
├── RuntimeOrchestrator.java Interface: startContainer, stopContainer, getStatus, getLogs
├── RuntimeConfig.java Record: jarStoragePath, baseImage, dockerNetwork, routing, etc.
├── ContainerRequest.java Record: containerName, jarPath, envVars, memoryLimit, cpuShares
├── ContainerStatus.java Record: state, running, exitCode, error
└── RoutingMode.java Enum: path, subdomain
```
### New Files — App Module (`cameleer3-server-app`)
```
src/main/java/com/cameleer3/server/app/runtime/
├── DockerRuntimeOrchestrator.java Docker implementation using docker-java
├── DisabledRuntimeOrchestrator.java No-op implementation (observability-only mode)
├── RuntimeOrchestratorAutoConfig.java @Configuration: auto-detects Docker vs K8s vs disabled
├── DeploymentExecutor.java @Service: async deployment pipeline
├── JarStorageService.java File-system JAR storage with versioning
└── ContainerLogCollector.java Collects Docker container stdout/stderr
src/main/java/com/cameleer3/server/app/storage/
├── PostgresEnvironmentRepository.java
├── PostgresAppRepository.java
├── PostgresAppVersionRepository.java
└── PostgresDeploymentRepository.java
src/main/java/com/cameleer3/server/app/controller/
├── EnvironmentAdminController.java CRUD endpoints under /api/v1/admin/environments
├── AppController.java App + version CRUD + JAR upload
└── DeploymentController.java Deploy, stop, restart, promote, logs
src/main/resources/db/migration/
└── V3__runtime_management.sql Environments, apps, app_versions, deployments tables
```
### Modified Files
- `pom.xml` (parent) — add docker-java dependency
- `cameleer3-server-app/pom.xml` — add docker-java dependency
- `application.yml` — add runtime config properties
---
### Task 1: Add docker-java Dependency
**Files:**
- Modify: `cameleer3-server-app/pom.xml`
- [x] **Step 1: Add docker-java dependency**
```xml
<dependency>
<groupId>com.github.docker-java</groupId>
<artifactId>docker-java-core</artifactId>
<version>3.4.1</version>
</dependency>
<dependency>
<groupId>com.github.docker-java</groupId>
<artifactId>docker-java-transport-zerodep</artifactId>
<version>3.4.1</version>
</dependency>
```
- [x] **Step 2: Verify build**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn compile -pl cameleer3-server-app`
Expected: BUILD SUCCESS.
- [x] **Step 3: Commit**
```bash
git add cameleer3-server-app/pom.xml
git commit -m "chore: add docker-java dependency for runtime orchestration"
```
---
### Task 2: Database Migration — Runtime Management Tables
**Files:**
- Create: `cameleer3-server-app/src/main/resources/db/migration/V3__runtime_management.sql`
- [x] **Step 1: Write migration**
```sql
-- V3__runtime_management.sql
-- Runtime management: environments, apps, app versions, deployments
CREATE TABLE environments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug VARCHAR(100) NOT NULL UNIQUE,
display_name VARCHAR(255) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE apps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
environment_id UUID NOT NULL REFERENCES environments(id) ON DELETE CASCADE,
slug VARCHAR(100) NOT NULL,
display_name VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(environment_id, slug)
);
CREATE INDEX idx_apps_environment_id ON apps(environment_id);
CREATE TABLE app_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
jar_path VARCHAR(500) NOT NULL,
jar_checksum VARCHAR(64) NOT NULL,
jar_filename VARCHAR(255),
jar_size_bytes BIGINT,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(app_id, version)
);
CREATE INDEX idx_app_versions_app_id ON app_versions(app_id);
CREATE TABLE deployments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
app_version_id UUID NOT NULL REFERENCES app_versions(id),
environment_id UUID NOT NULL REFERENCES environments(id),
status VARCHAR(20) NOT NULL DEFAULT 'STARTING',
container_id VARCHAR(100),
container_name VARCHAR(255),
error_message TEXT,
deployed_at TIMESTAMPTZ,
stopped_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_deployments_app_id ON deployments(app_id);
CREATE INDEX idx_deployments_env_id ON deployments(environment_id);
-- Default environment (standalone mode always has at least one)
INSERT INTO environments (slug, display_name) VALUES ('default', 'Default');
```
- [x] **Step 2: Commit**
```bash
git add cameleer3-server-app/src/main/resources/db/migration/V3__runtime_management.sql
git commit -m "feat: add runtime management database schema (environments, apps, versions, deployments)"
```
---
### Task 3: Core Domain — Environment, App, AppVersion, Deployment Records
**Files:**
- Create all records in `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/`
- [x] **Step 1: Create all domain records**
```java
// Environment.java
package com.cameleer3.server.core.runtime;
import java.time.Instant;
import java.util.UUID;
public record Environment(UUID id, String slug, String displayName, EnvironmentStatus status, Instant createdAt) {}
// EnvironmentStatus.java
package com.cameleer3.server.core.runtime;
public enum EnvironmentStatus { ACTIVE, SUSPENDED }
// App.java
package com.cameleer3.server.core.runtime;
import java.time.Instant;
import java.util.UUID;
public record App(UUID id, UUID environmentId, String slug, String displayName, Instant createdAt) {}
// AppVersion.java
package com.cameleer3.server.core.runtime;
import java.time.Instant;
import java.util.UUID;
public record AppVersion(UUID id, UUID appId, int version, String jarPath, String jarChecksum,
String jarFilename, Long jarSizeBytes, Instant uploadedAt) {}
// Deployment.java
package com.cameleer3.server.core.runtime;
import java.time.Instant;
import java.util.UUID;
public record Deployment(UUID id, UUID appId, UUID appVersionId, UUID environmentId,
DeploymentStatus status, String containerId, String containerName,
String errorMessage, Instant deployedAt, Instant stoppedAt, Instant createdAt) {
public Deployment withStatus(DeploymentStatus newStatus) {
return new Deployment(id, appId, appVersionId, environmentId, newStatus,
containerId, containerName, errorMessage, deployedAt, stoppedAt, createdAt);
}
}
// DeploymentStatus.java
package com.cameleer3.server.core.runtime;
public enum DeploymentStatus { STARTING, RUNNING, FAILED, STOPPED }
// RoutingMode.java
package com.cameleer3.server.core.runtime;
public enum RoutingMode { path, subdomain }
```
- [x] **Step 2: Commit**
```bash
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/
git commit -m "feat: add runtime management domain records"
```
---
### Task 4: Core — Repository Interfaces and RuntimeOrchestrator
**Files:**
- Create repository interfaces and RuntimeOrchestrator in `core/runtime/`
- [x] **Step 1: Create repository interfaces**
```java
// EnvironmentRepository.java
package com.cameleer3.server.core.runtime;
import java.util.*;
public interface EnvironmentRepository {
List<Environment> findAll();
Optional<Environment> findById(UUID id);
Optional<Environment> findBySlug(String slug);
UUID create(String slug, String displayName);
void updateDisplayName(UUID id, String displayName);
void updateStatus(UUID id, EnvironmentStatus status);
void delete(UUID id);
}
// AppRepository.java
package com.cameleer3.server.core.runtime;
import java.util.*;
public interface AppRepository {
List<App> findByEnvironmentId(UUID environmentId);
Optional<App> findById(UUID id);
Optional<App> findByEnvironmentIdAndSlug(UUID environmentId, String slug);
UUID create(UUID environmentId, String slug, String displayName);
void delete(UUID id);
}
// AppVersionRepository.java
package com.cameleer3.server.core.runtime;
import java.util.*;
public interface AppVersionRepository {
List<AppVersion> findByAppId(UUID appId);
Optional<AppVersion> findById(UUID id);
int findMaxVersion(UUID appId);
UUID create(UUID appId, int version, String jarPath, String jarChecksum, String jarFilename, Long jarSizeBytes);
}
// DeploymentRepository.java
package com.cameleer3.server.core.runtime;
import java.util.*;
public interface DeploymentRepository {
List<Deployment> findByAppId(UUID appId);
List<Deployment> findByEnvironmentId(UUID environmentId);
Optional<Deployment> findById(UUID id);
Optional<Deployment> findActiveByAppIdAndEnvironmentId(UUID appId, UUID environmentId);
UUID create(UUID appId, UUID appVersionId, UUID environmentId, String containerName);
void updateStatus(UUID id, DeploymentStatus status, String containerId, String errorMessage);
void markDeployed(UUID id);
void markStopped(UUID id);
}
```
- [x] **Step 2: Create RuntimeOrchestrator interface**
```java
// RuntimeOrchestrator.java
package com.cameleer3.server.core.runtime;
import java.util.stream.Stream;
public interface RuntimeOrchestrator {
boolean isEnabled();
String startContainer(ContainerRequest request);
void stopContainer(String containerId);
void removeContainer(String containerId);
ContainerStatus getContainerStatus(String containerId);
Stream<String> getLogs(String containerId, int tailLines);
}
// ContainerRequest.java
package com.cameleer3.server.core.runtime;
import java.util.Map;
public record ContainerRequest(
String containerName,
String baseImage,
String jarPath,
String network,
Map<String, String> envVars,
Map<String, String> labels,
long memoryLimitBytes,
int cpuShares,
int healthCheckPort
) {}
// ContainerStatus.java
package com.cameleer3.server.core.runtime;
public record ContainerStatus(String state, boolean running, int exitCode, String error) {
public static ContainerStatus notFound() {
return new ContainerStatus("not_found", false, -1, "Container not found");
}
}
```
- [x] **Step 3: Commit**
```bash
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/
git commit -m "feat: add runtime repository interfaces and RuntimeOrchestrator"
```
---
### Task 5: Core — EnvironmentService, AppService, DeploymentService
**Files:**
- Create service classes in `core/runtime/`
- [x] **Step 1: Create EnvironmentService**
```java
package com.cameleer3.server.core.runtime;
import java.util.List;
import java.util.UUID;
public class EnvironmentService {
private final EnvironmentRepository repo;
public EnvironmentService(EnvironmentRepository repo) {
this.repo = repo;
}
public List<Environment> listAll() { return repo.findAll(); }
public Environment getById(UUID id) { return repo.findById(id).orElseThrow(() -> new IllegalArgumentException("Environment not found: " + id)); }
public Environment getBySlug(String slug) { return repo.findBySlug(slug).orElseThrow(() -> new IllegalArgumentException("Environment not found: " + slug)); }
public UUID create(String slug, String displayName) {
if (repo.findBySlug(slug).isPresent()) {
throw new IllegalArgumentException("Environment with slug '" + slug + "' already exists");
}
return repo.create(slug, displayName);
}
public void delete(UUID id) {
Environment env = getById(id);
if ("default".equals(env.slug())) {
throw new IllegalArgumentException("Cannot delete the default environment");
}
repo.delete(id);
}
}
```
- [x] **Step 2: Create AppService**
```java
package com.cameleer3.server.core.runtime;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.*;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.UUID;
public class AppService {
private static final Logger log = LoggerFactory.getLogger(AppService.class);
private final AppRepository appRepo;
private final AppVersionRepository versionRepo;
private final String jarStoragePath;
public AppService(AppRepository appRepo, AppVersionRepository versionRepo, String jarStoragePath) {
this.appRepo = appRepo;
this.versionRepo = versionRepo;
this.jarStoragePath = jarStoragePath;
}
public List<App> listByEnvironment(UUID environmentId) { return appRepo.findByEnvironmentId(environmentId); }
public App getById(UUID id) { return appRepo.findById(id).orElseThrow(() -> new IllegalArgumentException("App not found: " + id)); }
public List<AppVersion> listVersions(UUID appId) { return versionRepo.findByAppId(appId); }
public UUID createApp(UUID environmentId, String slug, String displayName) {
if (appRepo.findByEnvironmentIdAndSlug(environmentId, slug).isPresent()) {
throw new IllegalArgumentException("App with slug '" + slug + "' already exists in this environment");
}
return appRepo.create(environmentId, slug, displayName);
}
public AppVersion uploadJar(UUID appId, String filename, InputStream jarData, long size) throws IOException {
App app = getById(appId);
int nextVersion = versionRepo.findMaxVersion(appId) + 1;
// Store JAR: {jarStoragePath}/{appId}/v{version}/app.jar
Path versionDir = Path.of(jarStoragePath, appId.toString(), "v" + nextVersion);
Files.createDirectories(versionDir);
Path jarFile = versionDir.resolve("app.jar");
MessageDigest digest;
try { digest = MessageDigest.getInstance("SHA-256"); }
catch (Exception e) { throw new RuntimeException(e); }
try (InputStream in = jarData) {
byte[] buffer = new byte[8192];
int bytesRead;
try (var out = Files.newOutputStream(jarFile)) {
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
digest.update(buffer, 0, bytesRead);
}
}
}
String checksum = HexFormat.of().formatHex(digest.digest());
UUID versionId = versionRepo.create(appId, nextVersion, jarFile.toString(), checksum, filename, size);
log.info("Uploaded JAR for app {}: version={}, size={}, sha256={}", appId, nextVersion, size, checksum);
return versionRepo.findById(versionId).orElseThrow();
}
public String resolveJarPath(UUID appVersionId) {
AppVersion version = versionRepo.findById(appVersionId)
.orElseThrow(() -> new IllegalArgumentException("AppVersion not found: " + appVersionId));
return version.jarPath();
}
public void deleteApp(UUID id) {
appRepo.delete(id);
}
}
```
- [x] **Step 3: Create DeploymentService**
```java
package com.cameleer3.server.core.runtime;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import java.util.UUID;
public class DeploymentService {
private static final Logger log = LoggerFactory.getLogger(DeploymentService.class);
private final DeploymentRepository deployRepo;
private final AppService appService;
private final EnvironmentService envService;
public DeploymentService(DeploymentRepository deployRepo, AppService appService, EnvironmentService envService) {
this.deployRepo = deployRepo;
this.appService = appService;
this.envService = envService;
}
public List<Deployment> listByApp(UUID appId) { return deployRepo.findByAppId(appId); }
public Deployment getById(UUID id) { return deployRepo.findById(id).orElseThrow(() -> new IllegalArgumentException("Deployment not found: " + id)); }
/** Create a deployment record. Actual container start is handled by DeploymentExecutor (async). */
public Deployment createDeployment(UUID appId, UUID appVersionId, UUID environmentId) {
App app = appService.getById(appId);
Environment env = envService.getById(environmentId);
String containerName = env.slug() + "-" + app.slug();
UUID deploymentId = deployRepo.create(appId, appVersionId, environmentId, containerName);
return deployRepo.findById(deploymentId).orElseThrow();
}
/** Promote: deploy the same app version to a different environment. */
public Deployment promote(UUID appId, UUID appVersionId, UUID targetEnvironmentId) {
return createDeployment(appId, appVersionId, targetEnvironmentId);
}
public void markRunning(UUID deploymentId, String containerId) {
deployRepo.updateStatus(deploymentId, DeploymentStatus.RUNNING, containerId, null);
deployRepo.markDeployed(deploymentId);
}
public void markFailed(UUID deploymentId, String errorMessage) {
deployRepo.updateStatus(deploymentId, DeploymentStatus.FAILED, null, errorMessage);
}
public void markStopped(UUID deploymentId) {
deployRepo.updateStatus(deploymentId, DeploymentStatus.STOPPED, null, null);
deployRepo.markStopped(deploymentId);
}
}
```
- [x] **Step 4: Commit**
```bash
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/
git commit -m "feat: add EnvironmentService, AppService, DeploymentService"
```
---
### Task 6: App Module — PostgreSQL Repositories
**Files:**
- Create all Postgres repositories in `app/storage/`
- [x] **Step 1: Implement all four repositories**
Follow the pattern from `PostgresUserRepository.java``JdbcTemplate` with row mappers. Each repository implements its core interface with standard SQL (INSERT, SELECT, UPDATE, DELETE).
Key patterns to follow:
- Constructor injection of `JdbcTemplate`
- RowMapper lambdas returning records
- `UUID.randomUUID()` for ID generation
- `Timestamp.from(Instant)` for timestamp parameters
- [x] **Step 2: Wire beans**
Create `RuntimeBeanConfig.java` in `app/config/`:
```java
@Configuration
public class RuntimeBeanConfig {
@Bean
public EnvironmentRepository environmentRepository(JdbcTemplate jdbc) {
return new PostgresEnvironmentRepository(jdbc);
}
@Bean
public AppRepository appRepository(JdbcTemplate jdbc) {
return new PostgresAppRepository(jdbc);
}
@Bean
public AppVersionRepository appVersionRepository(JdbcTemplate jdbc) {
return new PostgresAppVersionRepository(jdbc);
}
@Bean
public DeploymentRepository deploymentRepository(JdbcTemplate jdbc) {
return new PostgresDeploymentRepository(jdbc);
}
@Bean
public EnvironmentService environmentService(EnvironmentRepository repo) {
return new EnvironmentService(repo);
}
@Bean
public AppService appService(AppRepository appRepo, AppVersionRepository versionRepo,
@Value("${cameleer.runtime.jar-storage-path:/data/jars}") String jarStoragePath) {
return new AppService(appRepo, versionRepo, jarStoragePath);
}
@Bean
public DeploymentService deploymentService(DeploymentRepository deployRepo, AppService appService, EnvironmentService envService) {
return new DeploymentService(deployRepo, appService, envService);
}
}
```
- [x] **Step 3: Run tests**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app`
Expected: PASS (Flyway applies V3 migration, context loads).
- [x] **Step 4: Commit**
```bash
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/Postgres*Repository.java
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/RuntimeBeanConfig.java
git commit -m "feat: implement PostgreSQL repositories for runtime management"
```
---
### Task 7: Docker Runtime Orchestrator
**Files:**
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DockerRuntimeOrchestrator.java`
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DisabledRuntimeOrchestrator.java`
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/RuntimeOrchestratorAutoConfig.java`
- [x] **Step 1: Implement DisabledRuntimeOrchestrator**
```java
package com.cameleer3.server.app.runtime;
import com.cameleer3.server.core.runtime.*;
import java.util.stream.Stream;
public class DisabledRuntimeOrchestrator implements RuntimeOrchestrator {
@Override public boolean isEnabled() { return false; }
@Override public String startContainer(ContainerRequest r) { throw new UnsupportedOperationException("Runtime management disabled"); }
@Override public void stopContainer(String id) { throw new UnsupportedOperationException("Runtime management disabled"); }
@Override public void removeContainer(String id) { throw new UnsupportedOperationException("Runtime management disabled"); }
@Override public ContainerStatus getContainerStatus(String id) { return ContainerStatus.notFound(); }
@Override public Stream<String> getLogs(String id, int tail) { return Stream.empty(); }
}
```
- [x] **Step 2: Implement DockerRuntimeOrchestrator**
Port from SaaS `DockerRuntimeOrchestrator.java`, adapted:
- Uses docker-java `DockerClientImpl` with zerodep transport
- `startContainer()`: creates container from base image with volume mount for JAR (instead of image build), sets env vars, Traefik labels, health check, resource limits
- `stopContainer()`: stops with 30s timeout
- `removeContainer()`: force remove
- `getContainerStatus()`: inspect container state
- `getLogs()`: tail container logs
Key difference from SaaS version: **no image build**. The base image is pre-built. JAR is volume-mounted:
```java
@Override
public String startContainer(ContainerRequest request) {
List<String> envList = request.envVars().entrySet().stream()
.map(e -> e.getKey() + "=" + e.getValue()).toList();
// Volume bind: mount JAR into container
Bind jarBind = new Bind(request.jarPath(), new Volume("/app/app.jar"), AccessMode.ro);
HostConfig hostConfig = HostConfig.newHostConfig()
.withMemory(request.memoryLimitBytes())
.withMemorySwap(request.memoryLimitBytes())
.withCpuShares(request.cpuShares())
.withNetworkMode(request.network())
.withBinds(jarBind);
CreateContainerResponse container = dockerClient.createContainerCmd(request.baseImage())
.withName(request.containerName())
.withEnv(envList)
.withLabels(request.labels())
.withHostConfig(hostConfig)
.withHealthcheck(new HealthCheck()
.withTest(List.of("CMD-SHELL", "wget -qO- http://localhost:" + request.healthCheckPort() + "/cameleer/health || exit 1"))
.withInterval(10_000_000_000L)
.withTimeout(5_000_000_000L)
.withRetries(3)
.withStartPeriod(30_000_000_000L))
.exec();
dockerClient.startContainerCmd(container.getId()).exec();
return container.getId();
}
```
- [x] **Step 3: Implement RuntimeOrchestratorAutoConfig**
```java
package com.cameleer3.server.app.runtime;
import com.cameleer3.server.core.runtime.RuntimeOrchestrator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.nio.file.Files;
import java.nio.file.Path;
@Configuration
public class RuntimeOrchestratorAutoConfig {
private static final Logger log = LoggerFactory.getLogger(RuntimeOrchestratorAutoConfig.class);
@Bean
public RuntimeOrchestrator runtimeOrchestrator() {
// Auto-detect: Docker socket available?
if (Files.exists(Path.of("/var/run/docker.sock"))) {
log.info("Docker socket detected — enabling Docker runtime orchestrator");
return new DockerRuntimeOrchestrator();
}
// TODO: K8s detection (check for service account token)
log.info("No Docker socket or K8s detected — runtime management disabled (observability-only mode)");
return new DisabledRuntimeOrchestrator();
}
}
```
- [x] **Step 4: Commit**
```bash
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/
git commit -m "feat: implement DockerRuntimeOrchestrator with volume-mount JAR deployment"
```
---
### Task 8: DeploymentExecutor — Async Deployment Pipeline
**Files:**
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java`
- [x] **Step 1: Implement async deployment pipeline**
```java
package com.cameleer3.server.app.runtime;
import com.cameleer3.server.core.runtime.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
@Service
public class DeploymentExecutor {
private static final Logger log = LoggerFactory.getLogger(DeploymentExecutor.class);
private final RuntimeOrchestrator orchestrator;
private final DeploymentService deploymentService;
private final AppService appService;
private final EnvironmentService envService;
// Inject runtime config values
public DeploymentExecutor(RuntimeOrchestrator orchestrator, DeploymentService deploymentService,
AppService appService, EnvironmentService envService) {
this.orchestrator = orchestrator;
this.deploymentService = deploymentService;
this.appService = appService;
this.envService = envService;
}
@Async("deploymentExecutor")
public void executeAsync(Deployment deployment) {
try {
// Stop existing deployment in same environment for same app
// ... (find active deployment, stop container)
String jarPath = appService.resolveJarPath(deployment.appVersionId());
App app = appService.getById(deployment.appId());
Environment env = envService.getById(deployment.environmentId());
Map<String, String> envVars = new HashMap<>();
envVars.put("CAMELEER_EXPORT_TYPE", "HTTP");
envVars.put("CAMELEER_EXPORT_ENDPOINT", /* server endpoint */);
envVars.put("CAMELEER_AUTH_TOKEN", /* bootstrap token */);
envVars.put("CAMELEER_APPLICATION_ID", app.slug());
envVars.put("CAMELEER_ENVIRONMENT_ID", env.slug());
envVars.put("CAMELEER_DISPLAY_NAME", deployment.containerName());
Map<String, String> labels = buildTraefikLabels(app, env, deployment);
ContainerRequest request = new ContainerRequest(
deployment.containerName(),
/* baseImage */, jarPath, /* network */,
envVars, labels, /* memoryLimit */, /* cpuShares */, 9464);
String containerId = orchestrator.startContainer(request);
waitForHealthy(containerId, 60);
deploymentService.markRunning(deployment.id(), containerId);
log.info("Deployment {} is RUNNING (container={})", deployment.id(), containerId);
} catch (Exception e) {
log.error("Deployment {} FAILED: {}", deployment.id(), e.getMessage(), e);
deploymentService.markFailed(deployment.id(), e.getMessage());
}
}
private void waitForHealthy(String containerId, int timeoutSeconds) throws InterruptedException {
long deadline = System.currentTimeMillis() + timeoutSeconds * 1000L;
while (System.currentTimeMillis() < deadline) {
ContainerStatus status = orchestrator.getContainerStatus(containerId);
if ("healthy".equalsIgnoreCase(status.state()) || (status.running() && "running".equalsIgnoreCase(status.state()))) {
return;
}
if (!status.running()) {
throw new RuntimeException("Container stopped unexpectedly: " + status.error());
}
Thread.sleep(2000);
}
throw new RuntimeException("Container health check timed out after " + timeoutSeconds + "s");
}
private Map<String, String> buildTraefikLabels(App app, Environment env, Deployment deployment) {
// TODO: implement path-based and subdomain-based Traefik labels based on routing config
return Map.of("traefik.enable", "true");
}
}
```
- [x] **Step 2: Add async config**
Add to `RuntimeBeanConfig.java` or create `AsyncConfig.java`:
```java
@Bean(name = "deploymentExecutor")
public TaskExecutor deploymentTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(25);
executor.setThreadNamePrefix("deploy-");
executor.initialize();
return executor;
}
```
- [x] **Step 3: Commit**
```bash
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java
git commit -m "feat: implement async DeploymentExecutor pipeline"
```
---
### Task 9: REST Controllers — Environment, App, Deployment
**Files:**
- Create: `EnvironmentAdminController.java` (under `/api/v1/admin/environments`, ADMIN role)
- Create: `AppController.java` (under `/api/v1/apps`, OPERATOR role)
- Create: `DeploymentController.java` (under `/api/v1/apps/{appId}/deployments`, OPERATOR role)
- [x] **Step 1: Implement EnvironmentAdminController**
CRUD for environments. Path: `/api/v1/admin/environments`. Requires ADMIN role. Follows existing controller patterns (OpenAPI annotations, ResponseEntity).
- [x] **Step 2: Implement AppController**
App CRUD + JAR upload. Path: `/api/v1/apps`. Requires OPERATOR role. JAR upload via `multipart/form-data`. Returns app versions.
Key endpoint for JAR upload:
```java
@PostMapping(value = "/{appId}/versions", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<AppVersion> uploadJar(@PathVariable UUID appId,
@RequestParam("file") MultipartFile file) throws IOException {
AppVersion version = appService.uploadJar(appId, file.getOriginalFilename(), file.getInputStream(), file.getSize());
return ResponseEntity.status(201).body(version);
}
```
- [x] **Step 3: Implement DeploymentController**
Deploy, stop, restart, promote, logs. Path: `/api/v1/apps/{appId}/deployments`. Requires OPERATOR role.
Key endpoints:
```java
@PostMapping
public ResponseEntity<Deployment> deploy(@PathVariable UUID appId, @RequestBody DeployRequest request) {
// request contains: appVersionId, environmentId
Deployment deployment = deploymentService.createDeployment(appId, request.appVersionId(), request.environmentId());
deploymentExecutor.executeAsync(deployment);
return ResponseEntity.accepted().body(deployment);
}
@PostMapping("/{deploymentId}/promote")
public ResponseEntity<Deployment> promote(@PathVariable UUID appId, @PathVariable UUID deploymentId,
@RequestBody PromoteRequest request) {
Deployment source = deploymentService.getById(deploymentId);
Deployment promoted = deploymentService.promote(appId, source.appVersionId(), request.targetEnvironmentId());
deploymentExecutor.executeAsync(promoted);
return ResponseEntity.accepted().body(promoted);
}
```
- [x] **Step 4: Add security rules to SecurityConfig**
Add to `SecurityConfig.filterChain()`:
```java
// Runtime management (OPERATOR+)
.requestMatchers("/api/v1/apps/**").hasAnyRole("OPERATOR", "ADMIN")
```
- [x] **Step 5: Commit**
```bash
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/EnvironmentAdminController.java
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AppController.java
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DeploymentController.java
git commit -m "feat: add REST controllers for environment, app, and deployment management"
```
---
### Task 10: Configuration and Application Properties
**Files:**
- Modify: `cameleer3-server-app/src/main/resources/application.yml`
- [x] **Step 1: Add runtime config properties**
```yaml
cameleer:
runtime:
enabled: ${CAMELEER_RUNTIME_ENABLED:true}
jar-storage-path: ${CAMELEER_JAR_STORAGE_PATH:/data/jars}
base-image: ${CAMELEER_RUNTIME_BASE_IMAGE:cameleer-runtime-base:latest}
docker-network: ${CAMELEER_DOCKER_NETWORK:cameleer}
agent-health-port: 9464
health-check-timeout: 60
container-memory-limit: ${CAMELEER_CONTAINER_MEMORY_LIMIT:512m}
container-cpu-shares: ${CAMELEER_CONTAINER_CPU_SHARES:512}
routing-mode: ${CAMELEER_ROUTING_MODE:path}
routing-domain: ${CAMELEER_ROUTING_DOMAIN:localhost}
```
- [x] **Step 2: Run full test suite**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean verify`
Expected: PASS.
- [x] **Step 3: Commit**
```bash
git add cameleer3-server-app/src/main/resources/application.yml
git commit -m "feat: add runtime management configuration properties"
```
---
### Task 11: Integration Tests
- [x] **Step 1: Write EnvironmentAdminController integration test**
Test CRUD operations for environments. Follows existing pattern from `AgentRegistrationControllerIT`.
- [x] **Step 2: Write AppController integration test**
Test app creation, JAR upload, version listing.
- [x] **Step 3: Write DeploymentController integration test**
Test deployment creation (with `DisabledRuntimeOrchestrator` — verifies the deployment record is created even if Docker is unavailable). Full Docker tests require Docker-in-Docker and are out of scope for CI.
- [x] **Step 4: Commit**
```bash
git add cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/
git commit -m "test: add integration tests for runtime management API"
```
---
### Task 12: Final Verification
- [x] **Step 1: Run full build**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean verify`
Expected: All tests PASS.
- [x] **Step 2: Verify schema applies cleanly**
Fresh Testcontainers PostgreSQL should apply V1 + V2 + V3 without errors.
- [x] **Step 3: Commit any remaining fixes**
```bash
git add -A
git commit -m "chore: finalize runtime management — all tests passing"
```

View File

@@ -0,0 +1,377 @@
# Plan 4: SaaS Cleanup — Strip to Vendor Management Plane
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Remove all migrated code from the SaaS layer (environments, apps, deployments, ClickHouse access) and strip it down to a thin vendor management plane: tenant lifecycle, license generation, billing, and Logto organization management.
**Architecture:** The SaaS retains only vendor-level concerns. All runtime management, observability, and user management is now in the server. The SaaS communicates with server instances exclusively via REST API (ServerApiClient). ClickHouse dependency is removed entirely.
**Tech Stack:** Java 21, Spring Boot 3.4.3, PostgreSQL 16
**Repo:** `C:\Users\Hendrik\Documents\projects\cameleer-saas`
**Prerequisite:** Plans 1-3 must be implemented in cameleer3-server first.
---
## Summary of Changes
### Files to DELETE (migrated to server or no longer needed)
```
src/main/java/net/siegeln/cameleer/saas/environment/
├── EnvironmentEntity.java
├── EnvironmentService.java
├── EnvironmentController.java
├── EnvironmentRepository.java
├── EnvironmentStatus.java
└── dto/
├── CreateEnvironmentRequest.java
├── UpdateEnvironmentRequest.java
└── EnvironmentResponse.java
src/main/java/net/siegeln/cameleer/saas/app/
├── AppEntity.java
├── AppService.java
├── AppController.java
├── AppRepository.java
└── dto/
├── CreateAppRequest.java
└── AppResponse.java
src/main/java/net/siegeln/cameleer/saas/deployment/
├── DeploymentEntity.java
├── DeploymentService.java
├── DeploymentController.java
├── DeploymentRepository.java
├── DeploymentExecutor.java
├── DesiredStatus.java
├── ObservedStatus.java
└── dto/
└── DeploymentResponse.java
src/main/java/net/siegeln/cameleer/saas/runtime/
├── RuntimeOrchestrator.java
├── DockerRuntimeOrchestrator.java
├── RuntimeConfig.java
├── BuildImageRequest.java
├── StartContainerRequest.java
├── ContainerStatus.java
└── LogConsumer.java
src/main/java/net/siegeln/cameleer/saas/log/
├── ClickHouseConfig.java
├── ClickHouseProperties.java
├── ContainerLogService.java
├── LogController.java
└── dto/
└── LogEntry.java
src/main/java/net/siegeln/cameleer/saas/observability/
├── AgentStatusService.java
├── AgentStatusController.java
└── dto/
├── AgentStatusResponse.java
└── ObservabilityStatusResponse.java
```
### Files to MODIFY
```
src/main/java/net/siegeln/cameleer/saas/config/AsyncConfig.java — remove deploymentExecutor bean
src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java — remove createDefaultForTenant() call
src/main/resources/application.yml — remove clickhouse + runtime config sections
docker-compose.yml — remove Docker socket mount from SaaS, update routing
```
### Files to KEEP (vendor management plane)
```
src/main/java/net/siegeln/cameleer/saas/tenant/ — Tenant CRUD, lifecycle
src/main/java/net/siegeln/cameleer/saas/license/ — License generation
src/main/java/net/siegeln/cameleer/saas/identity/ — Logto org management, ServerApiClient
src/main/java/net/siegeln/cameleer/saas/config/ — SecurityConfig, SpaController
src/main/java/net/siegeln/cameleer/saas/audit/ — Vendor audit logging
src/main/java/net/siegeln/cameleer/saas/apikey/ — API key management (if used)
ui/ — Vendor management dashboard
```
### Flyway Migrations to KEEP
The existing migrations (V001-V009) can remain since they're already applied. Add a new cleanup migration:
```
src/main/resources/db/migration/V010__drop_migrated_tables.sql
```
---
### Task 1: Remove ClickHouse Dependency
- [ ] **Step 1: Delete ClickHouse files**
```bash
rm -rf src/main/java/net/siegeln/cameleer/saas/log/ClickHouseConfig.java
rm -rf src/main/java/net/siegeln/cameleer/saas/log/ClickHouseProperties.java
rm -rf src/main/java/net/siegeln/cameleer/saas/log/ContainerLogService.java
rm -rf src/main/java/net/siegeln/cameleer/saas/log/LogController.java
rm -rf src/main/java/net/siegeln/cameleer/saas/log/dto/
```
- [ ] **Step 2: Remove ClickHouse from AgentStatusService**
Delete `AgentStatusService.java` and `AgentStatusController.java` entirely (agent status is now a server concern).
```bash
rm -rf src/main/java/net/siegeln/cameleer/saas/observability/
```
- [ ] **Step 3: Remove ClickHouse config from application.yml**
Remove the entire `cameleer.clickhouse:` section.
- [ ] **Step 4: Remove ClickHouse JDBC dependency from pom.xml**
Remove:
```xml
<dependency>
<groupId>com.clickhouse</groupId>
<artifactId>clickhouse-jdbc</artifactId>
</dependency>
```
- [ ] **Step 5: Verify build**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-saas && mvn compile`
Expected: BUILD SUCCESS. Fix any remaining import errors.
- [ ] **Step 6: Commit**
```bash
git add -A
git commit -m "feat: remove all ClickHouse dependencies from SaaS layer"
```
---
### Task 2: Remove Environment/App/Deployment Code
- [ ] **Step 1: Delete environment package**
```bash
rm -rf src/main/java/net/siegeln/cameleer/saas/environment/
```
- [ ] **Step 2: Delete app package**
```bash
rm -rf src/main/java/net/siegeln/cameleer/saas/app/
```
- [ ] **Step 3: Delete deployment package**
```bash
rm -rf src/main/java/net/siegeln/cameleer/saas/deployment/
```
- [ ] **Step 4: Delete runtime package**
```bash
rm -rf src/main/java/net/siegeln/cameleer/saas/runtime/
```
- [ ] **Step 5: Remove AsyncConfig deploymentExecutor bean**
In `AsyncConfig.java`, remove the `deploymentExecutor` bean (or delete AsyncConfig if it only had that bean).
- [ ] **Step 6: Update TenantService**
Remove any calls to `EnvironmentService.createDefaultForTenant()` from `TenantService.java`. The server now handles default environment creation.
- [ ] **Step 7: Remove runtime config from application.yml**
Remove the entire `cameleer.runtime:` section.
- [ ] **Step 8: Verify build**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-saas && mvn compile`
Expected: BUILD SUCCESS. Fix any remaining import errors.
- [ ] **Step 9: Commit**
```bash
git add -A
git commit -m "feat: remove migrated environment/app/deployment/runtime code from SaaS"
```
---
### Task 3: Database Cleanup Migration
- [ ] **Step 1: Create cleanup migration**
```sql
-- V010__drop_migrated_tables.sql
-- Drop tables that have been migrated to cameleer3-server
DROP TABLE IF EXISTS deployments CASCADE;
DROP TABLE IF EXISTS apps CASCADE;
DROP TABLE IF EXISTS environments CASCADE;
DROP TABLE IF EXISTS api_keys CASCADE;
```
- [ ] **Step 2: Commit**
```bash
git add src/main/resources/db/migration/V010__drop_migrated_tables.sql
git commit -m "feat: drop migrated tables from SaaS database"
```
---
### Task 4: Remove Docker Socket Dependency
- [ ] **Step 1: Update docker-compose.yml**
Remove from `cameleer-saas` service:
```yaml
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- jardata:/data/jars
group_add:
- "0"
```
The Docker socket mount now belongs to the `cameleer3-server` service instead.
- [ ] **Step 2: Remove docker-java dependency from pom.xml**
Remove:
```xml
<dependency>
<groupId>com.github.docker-java</groupId>
<artifactId>docker-java-core</artifactId>
</dependency>
<dependency>
<groupId>com.github.docker-java</groupId>
<artifactId>docker-java-transport-zerodep</artifactId>
</dependency>
```
- [ ] **Step 3: Commit**
```bash
git add docker-compose.yml pom.xml
git commit -m "feat: remove Docker socket dependency from SaaS layer"
```
---
### Task 5: Update SaaS UI
- [ ] **Step 1: Remove environment/app/deployment pages from SaaS frontend**
Remove pages that now live in the server UI:
- `EnvironmentsPage`
- `EnvironmentDetailPage`
- `AppDetailPage`
The SaaS UI retains:
- `DashboardPage` — vendor overview (tenant list, status)
- `AdminTenantsPage` — tenant management
- `LicensePage` — license management
- [ ] **Step 2: Update navigation**
Remove links to environments/apps/deployments. The SaaS UI should link to the tenant's server instance for those features (e.g., "Open Dashboard" link to `https://{tenant-slug}.cameleer.example.com/server/`).
- [ ] **Step 3: Commit**
```bash
git add ui/
git commit -m "feat: strip SaaS UI to vendor management dashboard"
```
---
### Task 6: Expand ServerApiClient
- [ ] **Step 1: Add provisioning-related API calls**
The `ServerApiClient` should gain methods for tenant provisioning:
```java
public void pushLicense(String serverEndpoint, String licenseToken) {
post(serverEndpoint + "/api/v1/admin/license")
.body(Map.of("token", licenseToken))
.retrieve()
.toBodilessEntity();
}
public Map<String, Object> getHealth(String serverEndpoint) {
return get(serverEndpoint + "/api/v1/health")
.retrieve()
.body(Map.class);
}
```
- [ ] **Step 2: Commit**
```bash
git add src/main/java/net/siegeln/cameleer/saas/identity/ServerApiClient.java
git commit -m "feat: expand ServerApiClient with license push and health check methods"
```
---
### Task 7: Write SAAS-INTEGRATION.md
- [ ] **Step 1: Create integration contract document**
Create `docs/SAAS-INTEGRATION.md` in the cameleer3-server repo documenting:
- Which server API endpoints the SaaS calls
- Required auth (M2M token with `server:admin` scope)
- License injection mechanism (`POST /api/v1/admin/license`)
- Health check endpoint (`GET /api/v1/health`)
- What the server exposes vs what the SaaS must never access directly
- Env vars the SaaS sets when provisioning a server instance
- [ ] **Step 2: Commit**
```bash
cd /c/Users/Hendrik/Documents/projects/cameleer3-server
git add docs/SAAS-INTEGRATION.md
git commit -m "docs: add SaaS integration contract documentation"
```
---
### Task 8: Final Verification
- [ ] **Step 1: Build SaaS**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-saas && mvn clean verify`
Expected: BUILD SUCCESS with reduced dependency footprint.
- [ ] **Step 2: Verify SaaS starts without ClickHouse**
The SaaS should start with only PostgreSQL (and Logto). No ClickHouse required.
- [ ] **Step 3: Verify remaining code footprint**
The SaaS source should now contain approximately:
- `tenant/` — ~4 files
- `license/` — ~5 files
- `identity/` — ~3 files (LogtoConfig, ServerApiClient, M2M token)
- `config/` — ~3 files (SecurityConfig, SpaController, TLS)
- `audit/` — ~3 files
- `ui/` — stripped dashboard
Total: ~20 Java files (down from ~75).
- [ ] **Step 4: Final commit**
```bash
git add -A
git commit -m "chore: finalize SaaS cleanup — vendor management plane only"
```

View File

@@ -0,0 +1,760 @@
# SaaS Platform UX Polish — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix layout bugs, replace hardcoded dark-only colors with design system tokens, improve navigation/header, add error handling, and adopt design system components consistently across the SaaS platform UI.
**Architecture:** All changes are in the existing SaaS platform UI (`ui/src/`) and sign-in page (`ui/sign-in/src/`). The platform uses `@cameleer/design-system` components and Tailwind CSS. The key issue is that pages use hardcoded `text-white` Tailwind classes instead of DS CSS variables, and the DS `TopBar` renders server-specific controls that are irrelevant on platform pages.
**Tech Stack:** React 19, TypeScript, Tailwind CSS, `@cameleer/design-system`, React Router v6, Logto SDK
**Spec:** `docs/superpowers/specs/2026-04-09-saas-ux-polish-design.md`
---
## Task 1: Fix label/value collision and replace hardcoded colors
**Spec items:** 1.1, 1.2
**Files:**
- Create: `ui/src/styles/platform.module.css`
- Modify: `ui/src/pages/DashboardPage.tsx`
- Modify: `ui/src/pages/LicensePage.tsx`
- Modify: `ui/src/pages/AdminTenantsPage.tsx`
- [ ] **Step 1: Create shared platform CSS module**
Create `ui/src/styles/platform.module.css` with DS-variable-based classes replacing the hardcoded Tailwind colors:
```css
.heading {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
}
.textPrimary {
color: var(--text-primary);
}
.textSecondary {
color: var(--text-secondary);
}
.textMuted {
color: var(--text-muted);
}
.mono {
font-family: var(--font-mono);
}
.kvRow {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.kvLabel {
font-size: 0.875rem;
color: var(--text-muted);
}
.kvValue {
font-size: 0.875rem;
color: var(--text-primary);
}
.kvValueMono {
font-size: 0.875rem;
color: var(--text-primary);
font-family: var(--font-mono);
}
.dividerList {
display: flex;
flex-direction: column;
}
.dividerList > * + * {
border-top: 1px solid var(--border-subtle);
}
.dividerRow {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 0;
}
.dividerRow:first-child {
padding-top: 0;
}
.dividerRow:last-child {
padding-bottom: 0;
}
.description {
font-size: 0.875rem;
color: var(--text-muted);
}
.tokenBlock {
margin-top: 0.5rem;
border-radius: var(--radius-sm);
background: var(--bg-inset);
border: 1px solid var(--border-subtle);
padding: 0.75rem;
overflow-x: auto;
}
.tokenCode {
font-size: 0.75rem;
font-family: var(--font-mono);
color: var(--text-secondary);
word-break: break-all;
}
```
- [ ] **Step 2: Update DashboardPage to use CSS module + fix label/value**
In `ui/src/pages/DashboardPage.tsx`:
1. Add import:
```typescript
import s from '../styles/platform.module.css';
```
2. Replace all hardcoded color classes:
- Line 71: `text-2xl font-semibold text-white``className={s.heading}`
- Lines 96, 100, 107: `className="flex justify-between text-white/80"``className={s.kvRow}`
- Inner label spans: wrap with `className={s.kvLabel}`
- Inner value spans: wrap with `className={s.kvValueMono}` (for mono) or `className={s.kvValue}`
- Line 116: `text-sm text-white/60``className={s.description}`
3. The label/value collision fix: the `kvRow` class uses explicit `display: flex; width: 100%; justify-content: space-between` which ensures the flex container stretches to full Card width regardless of Card's inner layout.
- [ ] **Step 3: Update LicensePage to use CSS module**
In `ui/src/pages/LicensePage.tsx`:
1. Add import: `import s from '../styles/platform.module.css';`
2. Replace all hardcoded color classes:
- Line 85: heading → `className={s.heading}`
- Lines 95-115 (Validity rows): `flex items-center justify-between``className={s.kvRow}`, labels → `className={s.kvLabel}`, values → `className={s.kvValue}`
- Lines 121-136 (Features): `divide-y divide-white/10``className={s.dividerList}`, rows → `className={s.dividerRow}`, feature name `text-sm text-white``className={s.textPrimary}` + `text-sm`
- Lines 142-157 (Limits): same dividerList/dividerRow pattern, label → `className={s.kvLabel}`, value → `className={s.kvValueMono}`
- Line 163: description text → `className={s.description}`
- Lines 174-178: token code block → `className={s.tokenBlock}` on outer div, `className={s.tokenCode}` on code element
- [ ] **Step 4: Update AdminTenantsPage to use CSS module**
In `ui/src/pages/AdminTenantsPage.tsx`:
- Line 62: `text-2xl font-semibold text-white``className={s.heading}`
- [ ] **Step 5: Verify in both themes**
1. Open the platform dashboard in browser
2. Check label/value pairs have proper spacing (Slug on left, "default" on right)
3. Toggle to light theme via TopBar toggle
4. Verify all text is readable in light mode (no invisible white-on-white)
5. Toggle back to dark mode — should look the same as before
- [ ] **Step 6: Commit**
```bash
git add ui/src/styles/platform.module.css ui/src/pages/DashboardPage.tsx ui/src/pages/LicensePage.tsx ui/src/pages/AdminTenantsPage.tsx
git commit -m "fix: replace hardcoded text-white with DS variables, fix label/value layout"
```
---
## Task 2: Remove redundant dashboard elements
**Spec items:** 1.3, 2.4
**Files:**
- Modify: `ui/src/pages/DashboardPage.tsx`
- [ ] **Step 1: Remove primary "Open Server Dashboard" button from header**
In `ui/src/pages/DashboardPage.tsx`, find the header area (lines ~75-88). Remove the primary Button for "Open Server Dashboard" (lines ~81-87). Keep:
- The Server Management Card with its secondary button (lines ~113-126)
- The sidebar footer link (in Layout.tsx — don't touch)
The header area should just have the tenant name heading + tier badge, no button.
- [ ] **Step 2: Commit**
```bash
git add ui/src/pages/DashboardPage.tsx
git commit -m "fix: remove redundant Open Server Dashboard button from dashboard header"
```
---
## Task 3: Fix header controls and sidebar navigation
**Spec items:** 2.1, 2.2, 2.3, 2.5
**Files:**
- Modify: `ui/src/components/Layout.tsx`
- Modify: `ui/src/main.tsx` (possibly)
- [ ] **Step 1: Investigate TopBar props for hiding controls**
The DS `TopBar` interface (from types):
```typescript
interface TopBarProps {
breadcrumb: BreadcrumbItem[];
environment?: ReactNode;
user?: { name: string };
onLogout?: () => void;
className?: string;
}
```
The TopBar has NO props to hide status filters, time range, auto-refresh, or search. These are hardcoded inside the component.
**Options:**
1. Check if removing `GlobalFilterProvider` and `CommandPaletteProvider` from `main.tsx` makes TopBar gracefully hide those sections (test this first)
2. If that causes errors, add `display: none` CSS overrides for the irrelevant sections
3. If neither works, build a simplified platform header
Try option 1 first. In `main.tsx`, remove `GlobalFilterProvider` and `CommandPaletteProvider` from the provider stack. Test if the app still renders. If TopBar crashes without them, revert and try option 2.
- [ ] **Step 2: Add sidebar active state**
In `ui/src/components/Layout.tsx`, add route-based active state:
```typescript
import { useLocation } from 'react-router';
// Inside the Layout component:
const location = useLocation();
```
Update each `Sidebar.Section`:
```tsx
<Sidebar.Section
icon={<DashboardIcon />}
label="Dashboard"
open={false}
active={location.pathname === '/' || location.pathname === ''}
onToggle={() => navigate('/')}
>
{null}
</Sidebar.Section>
<Sidebar.Section
icon={<LicenseIcon />}
label="License"
open={false}
active={location.pathname === '/license'}
onToggle={() => navigate('/license')}
>
{null}
</Sidebar.Section>
{scopes.has('platform:admin') && (
<Sidebar.Section
icon={<PlatformIcon />}
label="Platform"
open={false}
active={location.pathname.startsWith('/admin')}
onToggle={() => navigate('/admin/tenants')}
>
{null}
</Sidebar.Section>
)}
```
- [ ] **Step 3: Add breadcrumbs**
In Layout.tsx, compute breadcrumbs from the current route:
```typescript
const breadcrumb = useMemo((): BreadcrumbItem[] => {
const path = location.pathname;
if (path.startsWith('/admin')) return [{ label: 'Admin' }, { label: 'Tenants' }];
if (path.startsWith('/license')) return [{ label: 'License' }];
return [{ label: 'Dashboard' }];
}, [location.pathname]);
```
Pass to TopBar:
```tsx
<TopBar breadcrumb={breadcrumb} ... />
```
Import `BreadcrumbItem` type from `@cameleer/design-system` if needed.
- [ ] **Step 4: Fix sidebar collapse**
Replace the hardcoded collapse state:
```typescript
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
```
```tsx
<Sidebar collapsed={sidebarCollapsed} onCollapseToggle={() => setSidebarCollapsed(c => !c)}>
```
- [ ] **Step 5: Fix username null fallback**
Update the user prop (line ~125):
```tsx
const displayName = username || 'User';
<TopBar
breadcrumb={breadcrumb}
user={{ name: displayName }}
onLogout={logout}
/>
```
This ensures the logout button is always visible.
- [ ] **Step 6: Replace custom SVG icons with lucide-react**
Replace the 4 custom SVG icon components (lines 25-62) with lucide-react icons:
```typescript
import { LayoutDashboard, ShieldCheck, Building, Server } from 'lucide-react';
```
Then update sidebar sections:
```tsx
icon={<LayoutDashboard size={18} />} // was <DashboardIcon />
icon={<ShieldCheck size={18} />} // was <LicenseIcon />
icon={<Building size={18} />} // was <PlatformIcon />
```
Remove the 4 custom SVG component functions (DashboardIcon, LicenseIcon, ObsIcon, PlatformIcon).
- [ ] **Step 7: Verify**
1. Sidebar shows active highlight on current page
2. Breadcrumbs show "Dashboard", "License", or "Admin > Tenants"
3. Sidebar collapse works (click collapse button, sidebar minimizes)
4. User avatar/logout always visible
5. Icons render correctly from lucide-react
6. Check if server controls are hidden (depending on step 1 result)
- [ ] **Step 8: Commit**
```bash
git add ui/src/components/Layout.tsx ui/src/main.tsx
git commit -m "fix: sidebar active state, breadcrumbs, collapse, username fallback, lucide icons"
```
---
## Task 4: Error handling and OrgResolver fix
**Spec items:** 3.1, 3.2, 3.7
**Files:**
- Modify: `ui/src/auth/OrgResolver.tsx`
- Modify: `ui/src/pages/DashboardPage.tsx`
- Modify: `ui/src/pages/AdminTenantsPage.tsx`
- [ ] **Step 1: Fix OrgResolver error state**
In `ui/src/auth/OrgResolver.tsx`, find the error handling (lines 88-90):
```tsx
// BEFORE:
if (isError) {
return null;
}
// AFTER:
if (isError) {
return (
<div className="flex flex-col items-center justify-center h-64 gap-4">
<EmptyState
title="Unable to load account"
description="Failed to retrieve your organization. Please try again or contact support."
/>
<Button variant="secondary" size="sm" onClick={() => refetch()}>
Retry
</Button>
</div>
);
}
```
Add imports: `EmptyState`, `Button` from `@cameleer/design-system`. Ensure `refetch` is available from the query hook (check if `useQuery` returns it).
- [ ] **Step 2: Add error handling to DashboardPage**
In `ui/src/pages/DashboardPage.tsx`, after the loading check (line ~49) and tenant check (line ~57), add error handling:
```tsx
const { data: tenant, isError: tenantError } = useTenant();
const { data: license, isError: licenseError } = useLicense();
// After loading spinner check:
if (tenantError || licenseError) {
return (
<div className="p-6">
<EmptyState
title="Unable to load dashboard"
description="Failed to retrieve tenant information. Please try again later."
/>
</div>
);
}
```
Check how `useTenant()` and `useLicense()` expose error state — they may use `isError` from React Query.
- [ ] **Step 3: Add empty state and date formatting to AdminTenantsPage**
In `ui/src/pages/AdminTenantsPage.tsx`:
1. Add error handling:
```tsx
if (isError) {
return (
<div className="p-6">
<EmptyState
title="Unable to load tenants"
description="You may not have admin permissions, or the server is unavailable."
/>
</div>
);
}
```
2. Format the `createdAt` column (line 31):
```tsx
// BEFORE:
{ key: 'createdAt', header: 'Created' },
// AFTER:
{ key: 'createdAt', header: 'Created', render: (_, row) => new Date(row.createdAt).toLocaleDateString() },
```
3. Add empty state to DataTable (if supported) or show EmptyState when tenants is empty:
```tsx
{(!tenants || tenants.length === 0) ? (
<EmptyState title="No tenants" description="No tenants have been created yet." />
) : (
<DataTable columns={columns} data={tenants} onRowClick={handleRowClick} />
)}
```
- [ ] **Step 4: Commit**
```bash
git add ui/src/auth/OrgResolver.tsx ui/src/pages/DashboardPage.tsx ui/src/pages/AdminTenantsPage.tsx
git commit -m "fix: add error states to OrgResolver, DashboardPage, AdminTenantsPage"
```
---
## Task 5: DS component adoption and license token copy
**Spec items:** 3.3, 3.4
**Files:**
- Modify: `ui/src/pages/LicensePage.tsx`
- [ ] **Step 1: Replace raw button with DS Button**
In `ui/src/pages/LicensePage.tsx`, find the token toggle button (lines ~166-172):
```tsx
// BEFORE:
<button
type="button"
className="text-sm text-primary-400 hover:text-primary-300 underline underline-offset-2 focus:outline-none"
onClick={() => setTokenExpanded((v) => !v)}
>
{tokenExpanded ? 'Hide token' : 'Show token'}
</button>
// AFTER:
<Button variant="ghost" size="sm" onClick={() => setTokenExpanded((v) => !v)}>
{tokenExpanded ? 'Hide token' : 'Show token'}
</Button>
```
Ensure `Button` is imported from `@cameleer/design-system`.
- [ ] **Step 2: Add copy-to-clipboard button**
Add `useToast` import and `Copy` icon:
```typescript
import { useToast } from '@cameleer/design-system';
import { Copy } from 'lucide-react';
```
Add toast hook in component:
```typescript
const { toast } = useToast();
```
Next to the show/hide button, add a copy button (only when expanded):
```tsx
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<Button variant="ghost" size="sm" onClick={() => setTokenExpanded((v) => !v)}>
{tokenExpanded ? 'Hide token' : 'Show token'}
</Button>
{tokenExpanded && (
<Button variant="ghost" size="sm" onClick={() => {
navigator.clipboard.writeText(license.token);
toast({ title: 'Token copied to clipboard', variant: 'success' });
}}>
<Copy size={14} /> Copy
</Button>
)}
</div>
```
- [ ] **Step 3: Commit**
```bash
git add ui/src/pages/LicensePage.tsx
git commit -m "fix: replace raw button with DS Button, add token copy-to-clipboard"
```
---
## Task 6: Sign-in page improvements
**Spec items:** 3.6, 4.5
**Files:**
- Modify: `ui/sign-in/src/SignInPage.tsx`
- [ ] **Step 1: Add password visibility toggle**
In `ui/sign-in/src/SignInPage.tsx`, add state and imports:
```typescript
import { Eye, EyeOff } from 'lucide-react';
const [showPassword, setShowPassword] = useState(false);
```
Update the password FormField (lines ~84-94):
```tsx
<FormField label="Password" htmlFor="login-password">
<div style={{ position: 'relative' }}>
<Input
id="login-password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
autoComplete="current-password"
disabled={loading}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
style={{
position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)',
background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)',
padding: 4, display: 'flex', alignItems: 'center',
}}
tabIndex={-1}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</FormField>
```
Note: Using a raw `<button>` here because the sign-in page may not have the full DS Button available (it's a separate Vite build). Use inline styles for positioning since the sign-in page uses CSS modules.
- [ ] **Step 2: Fix branding text**
In `ui/sign-in/src/SignInPage.tsx`, find the logo text (line ~61):
```tsx
// BEFORE:
<div className={styles.logo}>
<img src={cameleerLogo} alt="" className={styles.logoImg} />
cameleer3
</div>
// AFTER:
<div className={styles.logo}>
<img src={cameleerLogo} alt="" className={styles.logoImg} />
Cameleer
</div>
```
Also update the page title if it's set anywhere (check `index.html` in `ui/sign-in/`):
```html
<title>Sign in — Cameleer</title>
```
- [ ] **Step 3: Commit**
```bash
git add ui/sign-in/src/SignInPage.tsx ui/sign-in/index.html
git commit -m "fix: add password visibility toggle and fix branding to 'Cameleer'"
```
---
## Task 7: Unify tier colors and fix badges
**Spec items:** 4.1, 4.2
**Files:**
- Create: `ui/src/utils/tier.ts`
- Modify: `ui/src/pages/DashboardPage.tsx`
- Modify: `ui/src/pages/LicensePage.tsx`
- [ ] **Step 1: Create shared tier utility**
Create `ui/src/utils/tier.ts`:
```typescript
export type TierColor = 'primary' | 'success' | 'warning' | 'error' | 'auto';
export function tierColor(tier: string): TierColor {
switch (tier?.toUpperCase()) {
case 'BUSINESS':
case 'ENTERPRISE':
return 'success';
case 'HIGH':
case 'PRO':
return 'primary';
case 'MID':
case 'STARTER':
return 'warning';
case 'LOW':
case 'FREE':
return 'auto';
default:
return 'auto';
}
}
```
- [ ] **Step 2: Replace local tierColor in both pages**
In `DashboardPage.tsx`, remove the local `tierColor` function (lines 12-19) and add:
```typescript
import { tierColor } from '../utils/tier';
```
In `LicensePage.tsx`, remove the local `tierColor` function (lines 25-33) and add:
```typescript
import { tierColor } from '../utils/tier';
```
- [ ] **Step 3: Fix feature badge color**
In `LicensePage.tsx`, find the feature badge (line ~131-132):
```tsx
// BEFORE:
color={enabled ? 'success' : 'auto'}
// Check what neutral badge colors the DS supports.
// If 'auto' hashes to inconsistent colors, use a fixed muted option.
// AFTER:
color={enabled ? 'success' : 'warning'}
```
Use `'warning'` (amber/muted) for "Not included" — it's neutral without implying error. If the DS has a better neutral option, use that.
- [ ] **Step 4: Commit**
```bash
git add ui/src/utils/tier.ts ui/src/pages/DashboardPage.tsx ui/src/pages/LicensePage.tsx
git commit -m "fix: unify tier color mapping, fix feature badge colors"
```
---
## Task 8: AdminTenantsPage confirmation and polish
**Spec items:** 4.3
**Files:**
- Modify: `ui/src/pages/AdminTenantsPage.tsx`
- [ ] **Step 1: Add confirmation before tenant context switch**
In `ui/src/pages/AdminTenantsPage.tsx`, add state and import:
```typescript
import { AlertDialog } from '@cameleer/design-system';
const [switchTarget, setSwitchTarget] = useState<TenantResponse | null>(null);
```
Update the row click handler:
```tsx
// BEFORE:
const handleRowClick = (tenant: TenantResponse) => {
const orgs = useOrgStore.getState().organizations;
const match = orgs.find((o) => o.name === tenant.name || o.slug === tenant.slug);
if (match) {
setCurrentOrg(match.id);
navigate('/');
}
};
// AFTER:
const handleRowClick = (tenant: TenantResponse) => {
setSwitchTarget(tenant);
};
const confirmSwitch = () => {
if (!switchTarget) return;
const orgs = useOrgStore.getState().organizations;
const match = orgs.find((o) => o.name === switchTarget.name || o.slug === switchTarget.slug);
if (match) {
setCurrentOrg(match.id);
navigate('/');
}
setSwitchTarget(null);
};
```
Add the AlertDialog at the bottom of the component return:
```tsx
<AlertDialog
open={!!switchTarget}
onCancel={() => setSwitchTarget(null)}
onConfirm={confirmSwitch}
title="Switch tenant?"
description={`Switch to tenant "${switchTarget?.name}"? Your dashboard context will change.`}
confirmLabel="Switch"
variant="warning"
/>
```
- [ ] **Step 2: Commit**
```bash
git add ui/src/pages/AdminTenantsPage.tsx
git commit -m "fix: add confirmation dialog before tenant context switch"
```
---
## Summary
| Task | Batch | Key Changes | Commit |
|------|-------|-------------|--------|
| 1 | Layout | CSS module with DS variables, fix label/value, replace text-white | `fix: replace hardcoded text-white with DS variables, fix label/value layout` |
| 2 | Layout | Remove redundant "Open Server Dashboard" button | `fix: remove redundant Open Server Dashboard button` |
| 3 | Navigation | Sidebar active state, breadcrumbs, collapse, username fallback, lucide icons | `fix: sidebar active state, breadcrumbs, collapse, username fallback, lucide icons` |
| 4 | Error Handling | OrgResolver error UI, DashboardPage error state, AdminTenantsPage error + date format | `fix: add error states to OrgResolver, DashboardPage, AdminTenantsPage` |
| 5 | Components | DS Button for token toggle, copy-to-clipboard with toast | `fix: replace raw button with DS Button, add token copy-to-clipboard` |
| 6 | Sign-in | Password visibility toggle, branding fix to "Cameleer" | `fix: add password visibility toggle and fix branding to 'Cameleer'` |
| 7 | Polish | Shared tierColor(), fix feature badge colors | `fix: unify tier color mapping, fix feature badge colors` |
| 8 | Polish | Confirmation dialog for admin tenant switch | `fix: add confirmation dialog before tenant context switch` |

View File

@@ -0,0 +1,321 @@
# Phase 4: Observability Pipeline + Inbound Routing
**Date:** 2026-04-04
**Status:** Draft
**Depends on:** Phase 3 (Runtime Orchestration + Environments)
**Gitea issue:** #28
## Context
Phase 3 delivered the managed Camel runtime: customers upload a JAR, the platform builds a container with the cameleer3 agent injected, and deploys it. The agent connects to cameleer3-server and sends traces, metrics, diagrams, and logs to ClickHouse. But there is no way for the user to see this data yet, and customer apps that expose HTTP endpoints are not reachable.
Phase 4 completes the loop: deploy an app, hit its endpoint, see the traces in the dashboard.
cameleer3-server already has the complete observability stack — ClickHouse schemas with `tenant_id` partitioning, full search/stats/diagram/log REST APIs, and a React SPA dashboard. Phase 4 is a **wiring phase**, not a build-from-scratch phase.
## Key Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Observability UI | Serve existing cameleer3-server React SPA via Traefik | Already built. SaaS management UI is Phase 9 — observability UI is not SaaS-specific. |
| API access | Traefik routes directly to cameleer3-server with forward-auth | No proxy layer needed. Forward-auth validates user, injects headers. Server API works as-is. |
| Server changes | None | Single-tenant Docker mode works out of the box. `CAMELEER_TENANT_ID` env var already supported. |
| Agent changes | None | Agent already sends `applicationId`, `environmentId`, connects to `CAMELEER_EXPORT_ENDPOINT`. |
| Tenant ID | Set `CAMELEER_TENANT_ID` to tenant slug in Docker Compose | Tags ClickHouse data with the real tenant identity from day one. Avoids `'default'` → real-id migration later. |
| Inbound routing | Optional `exposedPort` on deployment, Traefik labels on customer containers | Thin feature. `{app}.{env}.{tenant}.{domain}` routing via Traefik Host rule. |
## What's Already Working (Phase 3)
- Customer containers on the `cameleer` bridge network
- Agent configured: `CAMELEER_AUTH_TOKEN`, `CAMELEER_EXPORT_ENDPOINT=http://cameleer3-server:8081`, `CAMELEER_APPLICATION_ID`, `CAMELEER_ENVIRONMENT_ID`
- cameleer3-server writes traces/metrics/diagrams/logs to ClickHouse
- Traefik routes `/observe/*` to cameleer3-server with forward-auth middleware
- Forward-auth endpoint at `/auth/verify` validates JWT, returns `X-Tenant-Id`, `X-User-Id`, `X-User-Email` headers
## Component 1: Serve cameleer3-server Dashboard
### Traefik Routing
Add Traefik labels to the cameleer3-server service in `docker-compose.yml` to serve the React SPA:
```yaml
# Existing (Phase 3):
- traefik.http.routers.observe.rule=PathPrefix(`/observe`)
- traefik.http.routers.observe.middlewares=forward-auth
# New (Phase 4):
- traefik.http.routers.dashboard.rule=PathPrefix(`/dashboard`)
- traefik.http.routers.dashboard.middlewares=forward-auth
- traefik.http.services.dashboard.loadbalancer.server.port=8080
```
The cameleer3-server SPA is served from its own embedded web server. The SPA already calls the server's API endpoints at relative paths — the existing `/observe/*` Traefik route handles those requests with forward-auth.
**Note:** If the cameleer3-server SPA expects to be served from `/` rather than `/dashboard`, a Traefik StripPrefix middleware may be needed:
```yaml
- traefik.http.middlewares.dashboard-strip.stripprefix.prefixes=/dashboard
- traefik.http.routers.dashboard.middlewares=forward-auth,dashboard-strip
```
This depends on how the cameleer3-server SPA is configured (base path). To be verified during implementation.
### CAMELEER_TENANT_ID Configuration
Set `CAMELEER_TENANT_ID` on the cameleer3-server service so all ingested data is tagged with the real tenant slug:
```yaml
cameleer3-server:
environment:
CAMELEER_TENANT_ID: ${CAMELEER_TENANT_SLUG:-default}
```
In the Docker single-tenant stack, this is set once during initial setup (e.g., `CAMELEER_TENANT_SLUG=acme` in `.env`). All ClickHouse data is then partitioned under this tenant ID.
Add `CAMELEER_TENANT_SLUG` to `.env.example`.
## Component 2: Agent Connectivity Verification
New endpoint in cameleer-saas to check whether a deployed app's agent has successfully registered with cameleer3-server and is sending data.
### API
```
GET /api/apps/{appId}/agent-status
Returns: 200 + AgentStatusResponse
```
### AgentStatusResponse
```java
public record AgentStatusResponse(
boolean registered,
String state, // ACTIVE, STALE, DEAD, UNKNOWN
Instant lastHeartbeat,
List<String> routeIds,
String applicationId,
String environmentId
) {}
```
### Implementation
`AgentStatusService` in cameleer-saas calls cameleer3-server's agent registry API:
```
GET http://cameleer3-server:8081/api/v1/agents
```
This returns the list of registered agents. The service filters by `applicationId` matching the app's slug and `environmentId` matching the environment's slug.
If the cameleer3-server doesn't expose a public agent listing endpoint, the alternative is to query ClickHouse directly for recent data:
```sql
SELECT max(timestamp) as last_seen
FROM container_logs
WHERE app_id = ? AND deployment_id = ?
LIMIT 1
```
The preferred approach is the agent registry API. If it requires authentication, cameleer-saas can use the shared `CAMELEER_AUTH_TOKEN` as a machine token.
### Integration with Deployment Status
After a deployment reaches `RUNNING` status (container healthy), the platform can poll agent-status to confirm the agent has registered. This could be surfaced as a sub-status:
- `RUNNING` — container is healthy
- `RUNNING_CONNECTED` — container healthy + agent registered with server
- `RUNNING_DISCONNECTED` — container healthy but agent not yet registered (timeout: 30s)
This is a nice-to-have enhancement on top of the basic agent-status endpoint.
## Component 3: Inbound HTTP Routing for Customer Apps
### Data Model
Add `exposed_port` column to the `apps` table:
```sql
ALTER TABLE apps ADD COLUMN exposed_port INTEGER;
```
This is the port the customer's Camel app listens on inside the container (e.g., 8080 for a Spring Boot REST app). When set, Traefik routes external traffic to this port.
### API
```
PATCH /api/apps/{appId}/routing
Body: { "exposedPort": 8080 } // or null to disable routing
Returns: 200 + AppResponse
```
The routable URL is computed and included in `AppResponse`:
```java
// In AppResponse, add:
String routeUrl // e.g., "http://order-svc.default.acme.localhost" or null if no routing
```
### URL Pattern
```
{app-slug}.{env-slug}.{tenant-slug}.{domain}
```
Example: `order-svc.default.acme.localhost`
The `{domain}` comes from the `DOMAIN` env var (already in `.env.example`).
### DockerRuntimeOrchestrator Changes
When starting a container for an app that has `exposedPort` set, add Traefik labels:
```java
var labels = new HashMap<String, String>();
labels.put("traefik.enable", "true");
labels.put("traefik.http.routers." + containerName + ".rule",
"Host(`" + app.getSlug() + "." + env.getSlug() + "." + tenant.getSlug() + "." + domain + "`)");
labels.put("traefik.http.services." + containerName + ".loadbalancer.server.port",
String.valueOf(app.getExposedPort()));
```
These labels are set on the Docker container via docker-java's `withLabels()` on the `CreateContainerCmd`.
Traefik auto-discovers labeled containers on the `cameleer` network (already configured in `traefik.yml` with `exposedByDefault: false`).
### StartContainerRequest Changes
Add optional fields to `StartContainerRequest`:
```java
public record StartContainerRequest(
String imageRef,
String containerName,
String network,
Map<String, String> envVars,
long memoryLimitBytes,
int cpuShares,
int healthCheckPort,
Map<String, String> labels // NEW: Traefik routing labels
) {}
```
### RuntimeConfig Addition
```yaml
cameleer:
runtime:
domain: ${DOMAIN:localhost}
```
## Component 4: End-to-End Connectivity Health
### Startup Verification
On application startup, cameleer-saas verifies that cameleer3-server is reachable:
```java
@EventListener(ApplicationReadyEvent.class)
public void verifyConnectivity() {
// HTTP GET http://cameleer3-server:8081/actuator/health
// Log result: "cameleer3-server connectivity: OK" or "FAILED: ..."
}
```
This is a best-effort check, not a hard dependency. If cameleer3-server is not yet running (e.g., starting up), the SaaS platform still starts. The check is logged for diagnostics.
### ClickHouse Data Verification
Add a lightweight endpoint for checking whether a deployed app is producing observability data:
```
GET /api/apps/{appId}/observability-status
Returns: 200 + ObservabilityStatusResponse
```
```java
public record ObservabilityStatusResponse(
boolean hasTraces,
boolean hasMetrics,
boolean hasDiagrams,
Instant lastTraceAt,
long traceCount24h
) {}
```
Implementation queries ClickHouse:
```sql
SELECT
count() > 0 as has_traces,
max(start_time) as last_trace,
count() as trace_count_24h
FROM executions
WHERE tenant_id = ? AND application_id = ? AND environment = ?
AND start_time > now() - INTERVAL 24 HOUR
```
This requires cameleer-saas to query ClickHouse directly (the `clickHouseDataSource` bean from Phase 3). The query is scoped by tenant + application + environment.
## Docker Compose Changes
### cameleer3-server labels (add dashboard route)
```yaml
cameleer3-server:
environment:
CAMELEER_TENANT_ID: ${CAMELEER_TENANT_SLUG:-default}
labels:
# Existing:
- traefik.enable=true
- traefik.http.routers.observe.rule=PathPrefix(`/observe`)
- traefik.http.routers.observe.middlewares=forward-auth
- traefik.http.services.observe.loadbalancer.server.port=8080
# New:
- traefik.http.routers.dashboard.rule=PathPrefix(`/dashboard`)
- traefik.http.routers.dashboard.middlewares=forward-auth,dashboard-strip
- traefik.http.middlewares.dashboard-strip.stripprefix.prefixes=/dashboard
- traefik.http.services.dashboard.loadbalancer.server.port=8080
```
### .env.example addition
```
CAMELEER_TENANT_SLUG=default
```
## Database Migration
```sql
-- V010__add_exposed_port_to_apps.sql
ALTER TABLE apps ADD COLUMN exposed_port INTEGER;
```
## New Configuration Properties
```yaml
cameleer:
runtime:
domain: ${DOMAIN:localhost}
```
## Verification Plan
1. Deploy a sample Camel REST app with `exposedPort: 8080`
2. `curl http://order-svc.default.acme.localhost` hits the Camel app
3. The Camel route processes the request
4. cameleer3 agent captures the trace and sends to cameleer3-server
5. `GET /api/apps/{appId}/agent-status` shows `registered: true, state: ACTIVE`
6. `GET /api/apps/{appId}/observability-status` shows `hasTraces: true`
7. Open `http://localhost/dashboard` — cameleer3-server SPA loads
8. Traces visible in the dashboard for the deployed app
9. Route topology graph shows the Camel route structure
10. `CAMELEER_TENANT_ID` is set to the tenant slug in ClickHouse data
## What Phase 4 Does NOT Touch
- No changes to cameleer3-server code (works as-is for single-tenant Docker mode)
- No changes to the cameleer3 agent
- No new ClickHouse schemas (cameleer3-server manages its own)
- No SaaS management UI (Phase 9)
- No K8s-specific changes (Phase 5)

View File

@@ -0,0 +1,316 @@
# Phase 9: Frontend React Shell
**Date:** 2026-04-04
**Status:** Draft
**Depends on:** Phase 4 (Observability Pipeline + Inbound Routing)
**Gitea issue:** #31
## Context
Phases 1-4 built the complete backend: tenants, licensing, environments, app deployment with JAR upload, async deployment pipeline, container logs, agent status, observability status, and inbound HTTP routing. The cameleer3-server observability dashboard is already served at `/dashboard`. But there is no management UI — all operations require curl/API calls.
Phase 9 adds the SaaS management shell: a React SPA for managing tenants, environments, apps, and deployments. The observability UI is already handled by cameleer3-server — this shell covers everything else.
## Key Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Location | `ui/` directory in cameleer-saas repo | Matches cameleer3-server pattern. Single build pipeline. Spring Boot serves the SPA. |
| Relationship to dashboard | Two separate SPAs, linked via navigation | SaaS shell at `/`, observability at `/dashboard`. Same design system = cohesive feel. No coupling. |
| Layout | Sidebar navigation | Consistent with cameleer3-server dashboard. Same AppShell pattern from design system. |
| Auth | Shared Logto OIDC session | Same client ID, same localStorage keys. True SSO between SaaS shell and observability dashboard. |
| Tech stack | React 19 + Vite + React Router + Zustand + TanStack Query | Identical to cameleer3-server SPA. Same patterns, same libraries, same conventions. |
| Design system | `@cameleer/design-system` v0.1.31 | Shared component library. CSS Modules + design tokens. Dark theme. |
| RBAC | Frontend role-based visibility | Roles from JWT claims. Hide/disable UI for unauthorized actions. Backend enforces — frontend is UX only. |
## Tech Stack
- **React 19** + TypeScript
- **Vite 8** (bundler + dev server)
- **React Router 7** (client-side routing)
- **Zustand** (auth state store)
- **TanStack React Query** (data fetching + caching)
- **@cameleer/design-system** (UI components)
- **Lucide React** (icons)
## Auth Flow
1. User navigates to `/``ProtectedRoute` checks `useAuthStore.isAuthenticated`
2. If not authenticated, redirect to Logto OIDC authorize endpoint
3. Logto callback at `/callback` — exchange code for tokens
4. Store `accessToken`, `refreshToken`, `username`, `roles` in Zustand + localStorage
5. Tokens stored with same keys as cameleer3-server SPA: `cameleer-access-token`, `cameleer-refresh-token`
6. API client injects `Authorization: Bearer {token}` on all requests
7. On 401, attempt token refresh; on failure, redirect to login
## RBAC Model
Roles from JWT or API response:
| Role | Permissions | UI Access |
|------|------------|-----------|
| **OWNER** | All | Everything + tenant settings |
| **ADMIN** | All except tenant:manage, billing:manage | Environments CRUD, apps CRUD, routing, deploy |
| **DEVELOPER** | apps:deploy, secrets:manage, observe:read, observe:debug | Deploy, stop, restart, re-upload JAR, view logs |
| **VIEWER** | observe:read | View-only: dashboard, app status, logs, deployment history |
Frontend RBAC implementation:
- `usePermissions()` hook reads roles from auth store, returns permission checks
- `<RequirePermission permission="apps:deploy">` wrapper component hides children if unauthorized
- Buttons/actions disabled with tooltip "Insufficient permissions" for unauthorized roles
- Navigation items hidden entirely if user has no access to any action on that page
## Pages
### Login (`/login`)
- Logto OIDC redirect button
- Handles callback at `/callback`
- Stores tokens, redirects to `/`
### Dashboard (`/`)
- Tenant overview: name, tier badge, license expiry
- Environment count, total app count
- Running/failed/stopped app summary (KPI strip)
- Recent deployments table (last 10)
- Quick actions: "New Environment", "View Observability Dashboard"
- **All roles** can view
### Environments (`/environments`)
- Table: name (display_name), slug, app count, status badge
- "Create Environment" button (ADMIN+ only, enforces tier limit)
- Click row → navigate to environment detail
- **All roles** can view list
### Environment Detail (`/environments/:id`)
- Environment name (editable inline for ADMIN+), slug, status
- App list table: name, slug, deployment status, agent status, last deployed
- "New App" button (DEVELOPER+ only) — opens JAR upload dialog
- "Delete Environment" button (ADMIN+ only, disabled if apps exist)
- **All roles** can view
### App Detail (`/environments/:eid/apps/:aid`)
- Header: app name, slug, environment breadcrumb
- **Status card**: current deployment status (BUILDING/STARTING/RUNNING/FAILED/STOPPED) with auto-refresh polling (3s)
- **Agent status card**: registered/not, state, route IDs, link to observability dashboard
- **JAR info**: filename, size, checksum, upload date
- **Routing card**: exposed port, route URL (clickable), edit button (ADMIN+)
- **Actions bar**:
- Deploy (DEVELOPER+) — triggers new deployment
- Stop (DEVELOPER+)
- Restart (DEVELOPER+)
- Re-upload JAR (DEVELOPER+) — file picker dialog
- Delete app (ADMIN+) — confirmation dialog
- **Deployment history**: table with version, status, timestamps, error messages
- **Container logs**: LogViewer component from design system, auto-refresh, stream filter (stdout/stderr)
- **All roles** can view status/logs/history
### License (`/license`)
- Current tier badge, features enabled/disabled, limits
- Expiry date, days remaining
- **All roles** can view
## Sidebar Navigation
```
🐪 Cameleer SaaS
─────────────────
📊 Dashboard
🌍 Environments
└ {env-name} (expandable, shows apps)
└ {app-name}
📄 License
─────────────────
👁 View Dashboard → (links to /dashboard)
─────────────────
🔒 Logged in as {name}
Logout
```
- Sidebar uses `Sidebar` + `TreeView` components from design system
- Environment → App hierarchy is collapsible
- "View Dashboard" is an external link to `/dashboard` (cameleer3-server SPA)
- Sidebar collapses on small screens (responsive)
## API Integration
The SaaS shell talks to cameleer-saas REST API. All endpoints already exist from Phases 1-4.
### API Client Setup
- Vite proxy: `/api``http://localhost:8080` (dev mode)
- Production: Traefik routes `/api` to cameleer-saas
- Auth middleware injects Bearer token
- Handles 401/403 with refresh + redirect
### React Query Hooks
```
useTenant() → GET /api/tenants/{id}
useLicense(tenantId) → GET /api/tenants/{tid}/license
useEnvironments(tenantId) → GET /api/tenants/{tid}/environments
useCreateEnvironment(tenantId) → POST /api/tenants/{tid}/environments
useUpdateEnvironment(tenantId, eid) → PATCH /api/tenants/{tid}/environments/{eid}
useDeleteEnvironment(tenantId, eid) → DELETE /api/tenants/{tid}/environments/{eid}
useApps(environmentId) → GET /api/environments/{eid}/apps
useCreateApp(environmentId) → POST /api/environments/{eid}/apps (multipart)
useDeleteApp(environmentId, appId) → DELETE /api/environments/{eid}/apps/{aid}
useUpdateRouting(environmentId, aid) → PATCH /api/environments/{eid}/apps/{aid}/routing
useDeploy(appId) → POST /api/apps/{aid}/deploy
useDeployments(appId) → GET /api/apps/{aid}/deployments
useDeployment(appId, did) → GET /api/apps/{aid}/deployments/{did} (poll 3s)
useStop(appId) → POST /api/apps/{aid}/stop
useRestart(appId) → POST /api/apps/{aid}/restart
useAgentStatus(appId) → GET /api/apps/{aid}/agent-status
useObservabilityStatus(appId) → GET /api/apps/{aid}/observability-status
useLogs(appId) → GET /api/apps/{aid}/logs
```
## File Structure
```
ui/
├── index.html
├── package.json
├── vite.config.ts
├── tsconfig.json
├── src/
│ ├── main.tsx — React root + providers
│ ├── router.tsx — React Router config
│ ├── auth/
│ │ ├── auth-store.ts — Zustand store (same keys as cameleer3-server)
│ │ ├── LoginPage.tsx
│ │ ├── CallbackPage.tsx
│ │ └── ProtectedRoute.tsx
│ ├── api/
│ │ ├── client.ts — fetch wrapper with auth middleware
│ │ └── hooks.ts — React Query hooks for all endpoints
│ ├── hooks/
│ │ └── usePermissions.ts — RBAC permission checks
│ ├── components/
│ │ ├── RequirePermission.tsx — RBAC wrapper
│ │ ├── Layout.tsx — AppShell + Sidebar + Breadcrumbs
│ │ ├── EnvironmentTree.tsx — Sidebar tree (envs → apps)
│ │ └── DeploymentStatusBadge.tsx
│ ├── pages/
│ │ ├── DashboardPage.tsx
│ │ ├── EnvironmentsPage.tsx
│ │ ├── EnvironmentDetailPage.tsx
│ │ ├── AppDetailPage.tsx
│ │ └── LicensePage.tsx
│ └── types/
│ └── api.ts — TypeScript types matching backend DTOs
```
## Traefik Routing
```yaml
cameleer-saas:
labels:
# Existing API routes:
- traefik.http.routers.api.rule=PathPrefix(`/api`)
- traefik.http.routers.forwardauth.rule=Path(`/auth/verify`)
# New SPA route:
- traefik.http.routers.spa.rule=PathPrefix(`/`)
- traefik.http.routers.spa.priority=1
- traefik.http.services.spa.loadbalancer.server.port=8080
```
Spring Boot serves the SPA from `src/main/resources/static/` (built by Vite into this directory). A catch-all controller returns `index.html` for all non-API routes (SPA client-side routing).
## Build Integration
### Vite Build → Spring Boot Static Resources
```bash
# In ui/
npm run build
# Output: ui/dist/
# Copy to Spring Boot static resources
cp -r ui/dist/* src/main/resources/static/
```
This can be automated in the Maven build via `frontend-maven-plugin` or a simple shell script in CI.
### CI Pipeline
Add a `ui-build` step before `mvn verify`:
1. `cd ui && npm ci && npm run build`
2. Copy `ui/dist/` to `src/main/resources/static/`
3. `mvn clean verify` packages the SPA into the JAR
### Development
```bash
# Terminal 1: backend
mvn spring-boot:run
# Terminal 2: frontend (Vite dev server with API proxy)
cd ui && npm run dev
```
Vite dev server proxies `/api` to `localhost:8080`.
## SPA Catch-All Controller
Spring Boot needs a catch-all to serve `index.html` for SPA routes:
```java
@Controller
public class SpaController {
@GetMapping(value = {"/", "/login", "/callback", "/environments/**", "/license"})
public String spa() {
return "forward:/index.html";
}
}
```
This ensures React Router handles client-side routing. API routes (`/api/**`) are not caught — they go to the existing REST controllers.
## Design System Integration
```json
{
"dependencies": {
"@cameleer/design-system": "0.1.31"
}
}
```
Registry configuration in `.npmrc`:
```
@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/
```
Import in `main.tsx`:
```tsx
import '@cameleer/design-system/style.css';
import { ThemeProvider, ToastProvider, BreadcrumbProvider } from '@cameleer/design-system';
```
## Verification Plan
1. `npm run dev` starts Vite dev server, SPA loads at localhost:5173
2. Login redirects to Logto, callback stores tokens
3. Dashboard shows tenant overview with correct data from API
4. Environment list loads, create/rename/delete works (ADMIN+)
5. App upload (JAR + metadata) works, app appears in list
6. Deploy triggers async deployment, status polls and updates live
7. Agent status shows registered/connected
8. Container logs stream in LogViewer
9. "View Dashboard" link navigates to `/dashboard` (cameleer3-server SPA)
10. Shared auth: no re-login when switching between SPAs
11. RBAC: VIEWER cannot see deploy button, DEVELOPER cannot delete environments
12. Production build: `npm run build` + `mvn package` produces JAR with embedded SPA
## What Phase 9 Does NOT Touch
- No changes to cameleer3-server or its SPA
- No billing UI (Phase 6)
- No team management (Logto org admin — deferred)
- No tenant settings/profile page
- No super-admin multi-tenant view

View File

@@ -0,0 +1,754 @@
# Authentication & Authorization Overhaul
**Date:** 2026-04-05
**Status:** Draft
**Scope:** cameleer-saas (large), cameleer3-server (small), cameleer3 agent (none)
## Problem Statement
The current cameleer-saas authentication implementation has three overlapping identity systems that don't compose: Logto OIDC tokens, a hand-rolled Ed25519 JWT stack, and vestigial local user/role/permission tables. The `ForwardAuthController` validates custom JWTs but users carry Logto tokens. The `machineTokenFilter` is wired into both filter chains. Agent auth appears broken (bootstrap token is a plain string but the filter expects an Ed25519 JWT). Authorization calls Logto's Management API at request time instead of reading JWT claims. Frontend permissions are hardcoded with role names that don't match Logto.
## Design Principles
1. **Logto is the single identity provider** for all human users across all components.
2. **Zero trust** — every service validates tokens independently via JWKS or its own signing key. No identity in HTTP headers. The JWT is the proof.
3. **No custom crypto** — use standard libraries and protocols (OAuth2, OIDC, JWT). No hand-rolled JWT generation or validation.
4. **Server-per-tenant** — each tenant gets their own cameleer3-server instance. The SaaS platform provisions and manages them.
5. **API keys for agents** — per-environment opaque secrets, exchanged for server-issued JWTs via the existing bootstrap registration flow.
6. **Self-hosted compatible** — same stack, single Logto org, single tenant. No special code paths.
## Architecture Overview
```
┌─────────────┐
│ Logto │ ── OIDC Provider (all humans)
│ (self-host) │ ── JWKS endpoint for token validation
└──────┬───────┘
┌─────────────────┼─────────────────┐
│ │ │
▼ ▼ ▼
┌────────────────┐ ┌──────────────┐ ┌───────────────┐
│ cameleer-saas │ │ c3-server │ │ c3-server │
│ (SaaS API) │ │ (tenant A) │ │ (tenant B) │
│ │ │ │ │ │
│ Validates: │ │ Validates: │ │ Validates: │
│ - Logto JWT │ │ - Own HMAC │ │ - Own HMAC │
│ (users) │ │ JWT(agents)│ │ JWT(agents) │
│ - Logto M2M │ │ - Logto JWT │ │ - Logto JWT │
│ (↔ servers) │ │ (M2M+OIDC) │ │ (M2M+OIDC) │
└────────────────┘ └──────────────┘ └───────────────┘
│ API key → register → JWT
┌──────┴───────┐
│ Agent │
│ (per-env) │
└──────────────┘
```
### Token types and who validates what
| Token | Issuer | Algorithm | Validator | Used by |
|-------|--------|-----------|-----------|---------|
| Logto user JWT | Logto | ES384 (asymmetric) | Any service via JWKS | SaaS UI users, server dashboard users |
| Logto M2M JWT | Logto | ES384 (asymmetric) | Any service via JWKS | SaaS platform → server API calls |
| Server internal JWT | cameleer3-server | HS256 (symmetric) | Issuing server only | Agents (after registration) |
| API key (opaque) | SaaS platform | N/A (hashed at rest) | cameleer3-server (bootstrap validator) | Agent initial registration |
| Ed25519 signature | cameleer3-server | EdDSA | Agent | Server → agent command integrity |
### Authentication flows
**Human user → SaaS Platform:**
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's JWKS (Spring OAuth2 Resource Server)
4. `organization_id` claim in JWT → resolves to internal tenant ID
5. Roles come from JWT claims (Logto org roles), not Management API calls
**Human user → cameleer3-server dashboard:**
1. User authenticates with Logto (OIDC flow, server configured via existing admin API)
2. Server exchanges auth code for ID token, validates via provider JWKS
3. Server issues internal HMAC JWT with mapped roles
4. Existing flow, no changes needed
**SaaS platform → cameleer3-server API (M2M):**
1. SaaS platform obtains Logto M2M access token (`client_credentials` grant)
2. Calls tenant server API with `Authorization: Bearer <logto-m2m-token>`
3. Server validates via Logto JWKS (new capability — see server changes below)
4. Server grants ADMIN role to valid M2M tokens
**Agent → cameleer3-server:**
1. Agent reads `CAMELEER_API_KEY` env var (fallback: `CAMELEER_AUTH_TOKEN` for backward compat)
2. Calls `POST /api/v1/agents/register` with `Authorization: Bearer <api-key>`
3. Server validates via `BootstrapTokenValidator` (constant-time comparison, unchanged)
4. Server issues internal HMAC JWT (access + refresh) + Ed25519 public key
5. Agent uses JWT for all subsequent requests, refreshes on expiry
6. Existing flow, no changes needed
**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 nonce (replay protection)
5. Existing flow, no changes needed
---
## Component Changes
### cameleer3 (agent) — NO CHANGES
The agent's authentication flow is correct as designed:
- Reads API key from environment variable
- Exchanges for JWT via registration endpoint
- Uses JWT for all requests, auto-refreshes, re-registers on failure
- Verifies Ed25519 signatures on server commands
The only optional change is renaming `CAMELEER_AUTH_TOKEN` to `CAMELEER_API_KEY` for clarity, with backward-compatible fallback. This is cosmetic and can be done at any time.
### cameleer3-server — SMALL CHANGES
The server needs one new capability: accepting Logto access tokens (asymmetric JWT) in addition to its own internal HMAC JWTs. This enables the SaaS platform to call server APIs using M2M tokens.
#### Change 1: Add `spring-boot-starter-oauth2-resource-server` dependency
**File:** `cameleer3-server-app/pom.xml`
Add:
```xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
```
#### Change 2: Add OIDC resource server properties
**File:** `cameleer3-server-app/src/main/resources/application.yml`
Add under `security:`:
```yaml
security:
# ... existing properties unchanged ...
oidc-issuer-uri: ${CAMELEER_OIDC_ISSUER_URI:}
oidc-audience: ${CAMELEER_OIDC_AUDIENCE:}
```
**File:** `SecurityProperties.java`
Add two new fields:
```java
private String oidcIssuerUri; // Logto issuer URI for M2M token validation
private String oidcAudience; // Expected audience (API resource indicator)
// + getters/setters
```
These are optional — when blank, the server behaves exactly as before (no OIDC resource server). When set, the server accepts Logto tokens in addition to internal tokens.
#### Change 3: Modify `JwtAuthenticationFilter` to try Logto validation as fallback
**File:** `JwtAuthenticationFilter.java`
Current behavior: extracts Bearer token, validates with `JwtService` (HMAC), sets auth context.
New behavior: extracts Bearer token, tries `JwtService` (HMAC) first. If HMAC validation fails AND an OIDC JwtDecoder is configured, try validating as a Logto token via JWKS. If that succeeds, extract claims and set auth context with appropriate roles.
```java
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final AgentRegistryService agentRegistryService;
private final org.springframework.security.oauth2.jwt.JwtDecoder oidcDecoder; // nullable
public JwtAuthenticationFilter(JwtService jwtService,
AgentRegistryService agentRegistryService,
org.springframework.security.oauth2.jwt.JwtDecoder oidcDecoder) {
this.jwtService = jwtService;
this.agentRegistryService = agentRegistryService;
this.oidcDecoder = oidcDecoder;
}
@Override
protected void doFilterInternal(...) {
String token = extractToken(request);
if (token != null) {
// Try internal HMAC token first (agents, local users)
if (tryInternalToken(token, request)) {
chain.doFilter(request, response);
return;
}
// Fall back to OIDC token (SaaS M2M, OIDC users)
if (oidcDecoder != null) {
tryOidcToken(token, request);
}
}
chain.doFilter(request, response);
}
private boolean tryInternalToken(String token, HttpServletRequest request) {
try {
JwtValidationResult result = jwtService.validateAccessToken(token);
// ... existing auth setup (unchanged) ...
return true;
} catch (Exception e) {
return false;
}
}
private void tryOidcToken(String token, HttpServletRequest request) {
try {
var jwt = oidcDecoder.decode(token);
String subject = jwt.getSubject();
// M2M tokens: grant ADMIN role (SaaS platform managing this server)
// OIDC user tokens: map roles from claims
List<String> roles = extractRolesFromOidcToken(jwt);
List<GrantedAuthority> authorities = toAuthorities(roles);
var auth = new UsernamePasswordAuthenticationToken(
"oidc:" + subject, null, authorities);
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (Exception e) {
log.debug("OIDC token validation failed: {}", e.getMessage());
}
}
private List<String> extractRolesFromOidcToken(org.springframework.security.oauth2.jwt.Jwt jwt) {
// M2M tokens (no sub or sub matches client_id) get ADMIN
// User tokens get roles from configured claim path
String sub = jwt.getSubject();
Object clientId = jwt.getClaim("client_id");
if (clientId != null && clientId.toString().equals(sub)) {
// M2M token — grant admin access
return List.of("ADMIN");
}
// User OIDC token — read roles from claim (reuse OidcConfig.rolesClaim)
return List.of("VIEWER"); // safe default, can be enhanced
}
}
```
#### Change 4: Create OIDC JwtDecoder bean (conditional)
**File:** `SecurityBeanConfig.java`
Add a conditional bean that creates a Spring `JwtDecoder` when OIDC issuer is configured:
```java
@Bean
@ConditionalOnProperty(name = "security.oidc-issuer-uri", matchIfMissing = false)
public org.springframework.security.oauth2.jwt.JwtDecoder oidcJwtDecoder(
SecurityProperties properties) {
NimbusJwtDecoder decoder = NimbusJwtDecoder
.withIssuerLocation(properties.getOidcIssuerUri())
.build();
// Logto uses typ "at+jwt" — accept both "JWT" and "at+jwt"
// (same workaround as cameleer-saas SecurityConfig)
// Validate issuer + audience
OAuth2TokenValidator<Jwt> validators;
if (properties.getOidcAudience() != null && !properties.getOidcAudience().isBlank()) {
validators = new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(properties.getOidcIssuerUri()),
new JwtClaimValidator<List<String>>("aud",
aud -> aud != null && aud.contains(properties.getOidcAudience()))
);
} else {
validators = JwtValidators.createDefaultWithIssuer(properties.getOidcIssuerUri());
}
decoder.setJwtValidator(validators);
return decoder;
}
```
When the env var `CAMELEER_OIDC_ISSUER_URI` is not set, no OIDC decoder bean is created, the `JwtAuthenticationFilter` constructor receives `null`, and the server behaves exactly as before. Zero impact on self-hosted customers who don't use the SaaS platform.
#### Change 5: Wire the optional decoder into `SecurityConfig`
**File:** `SecurityConfig.java`
Update the filter chain to pass the optional OIDC decoder:
```java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http,
JwtService jwtService,
AgentRegistryService registryService,
CorsConfigurationSource corsConfigurationSource,
@Autowired(required = false)
org.springframework.security.oauth2.jwt.JwtDecoder oidcDecoder) throws Exception {
// ... existing config unchanged ...
.addFilterBefore(
new JwtAuthenticationFilter(jwtService, registryService, oidcDecoder),
UsernamePasswordAuthenticationFilter.class
);
return http.build();
}
```
#### Change 6: Accepted algorithm for Logto tokens
Logto issues tokens with `typ: at+jwt` (RFC 9068) and signs with ES384. The `NimbusJwtDecoder` created via `withIssuerLocation()` auto-discovers the JWKS and supported algorithms from the OIDC discovery document. The same `at+jwt` type workaround used in cameleer-saas is needed here.
Build the decoder manually instead of using `withIssuerLocation()` to control the JWT processor type verifier:
```java
// In SecurityBeanConfig, replace withIssuerLocation with:
var jwkSetUri = properties.getOidcIssuerUri() + "/jwks"; // or discover from .well-known
var jwkSource = JWKSourceBuilder.create(new URL(jwkSetUri)).build();
var keySelector = new JWSVerificationKeySelector<>(JWSAlgorithm.ES384, jwkSource);
var processor = new DefaultJWTProcessor<>();
processor.setJWSKeySelector(keySelector);
processor.setJWSTypeVerifier((type, ctx) -> { /* accept any type */ });
var decoder = new NimbusJwtDecoder(processor);
```
#### Summary of server file changes
| File | Change |
|------|--------|
| `pom.xml` | Add `spring-boot-starter-oauth2-resource-server` |
| `application.yml` | Add `security.oidc-issuer-uri` and `security.oidc-audience` |
| `SecurityProperties.java` | Add `oidcIssuerUri` and `oidcAudience` fields |
| `SecurityBeanConfig.java` | Add conditional `JwtDecoder` bean |
| `SecurityConfig.java` | Pass optional `JwtDecoder` to filter constructor |
| `JwtAuthenticationFilter.java` | Add OIDC fallback path (try HMAC first, then JWKS) |
All changes are additive. No existing behavior is modified. When `CAMELEER_OIDC_ISSUER_URI` is not set, the server is identical to today.
#### Docker / provisioning
When the SaaS platform provisions a tenant server, it sets:
```
CAMELEER_OIDC_ISSUER_URI=http://logto:3001/oidc
CAMELEER_OIDC_AUDIENCE=https://api.cameleer.local
```
Self-hosted customers who don't use the SaaS platform leave these blank — the server works exactly as before.
---
### cameleer-saas — LARGE CHANGES
#### DELETE: Custom JWT stack
These files are removed entirely:
| File | Reason |
|------|--------|
| `src/main/java/.../auth/JwtService.java` | Hand-rolled Ed25519 JWT. Replaced by Spring OAuth2 Resource Server. |
| `src/main/java/.../auth/JwtAuthenticationFilter.java` | Custom filter. Replaced by Spring's `BearerTokenAuthenticationFilter` + API key filter. |
| `src/main/java/.../config/JwtConfig.java` | Ed25519 key loading. SaaS platform does not sign tokens. |
| `src/main/java/.../auth/UserEntity.java` | Users live in Logto, not local DB. |
| `src/main/java/.../auth/UserRepository.java` | Unused. |
| `src/main/java/.../auth/RoleEntity.java` | Roles live in Logto, not local DB. |
| `src/main/java/.../auth/RoleRepository.java` | Unused. |
| `src/main/java/.../auth/PermissionEntity.java` | Unused. |
| `src/main/java/.../config/ForwardAuthController.java` | Identity-in-headers pattern violates zero trust. |
Remove the `PasswordEncoder` bean from `SecurityConfig.java`.
Database migrations V001 (users table), V002 (roles/permissions tables), V003 (default role seed) are deleted entirely — greenfield, no production data. Replace with clean migrations containing only the tables actually needed.
#### DELETE: Ed25519 key configuration
Remove from `application.yml`:
```yaml
cameleer:
jwt:
expiration: 86400
private-key-path: ${CAMELEER_JWT_PRIVATE_KEY_PATH:}
public-key-path: ${CAMELEER_JWT_PUBLIC_KEY_PATH:}
```
Remove the `keys/` directory mount from `docker-compose.yml`. The SaaS platform does not sign anything — Ed25519 signing lives in cameleer3-server only.
#### REWRITE: `SecurityConfig.java`
Replace the current two-filter-chain setup with a single clean chain:
```java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
private final TenantResolutionFilter tenantResolutionFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(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 -> {}))
.addFilterAfter(tenantResolutionFilter,
BearerTokenAuthenticationFilter.class);
return http.build();
}
@Bean
public JwtDecoder jwtDecoder(
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwkSetUri,
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri:}") String issuerUri)
throws Exception {
// Same Logto at+jwt workaround as current code, minus the custom JWT filter
var jwkSource = JWKSourceBuilder.create(new URL(jwkSetUri)).build();
var keySelector = new JWSVerificationKeySelector<>(JWSAlgorithm.ES384, jwkSource);
var processor = new DefaultJWTProcessor<SecurityContext>();
processor.setJWSKeySelector(keySelector);
processor.setJWSTypeVerifier((type, ctx) -> { /* accept any type */ });
var decoder = new NimbusJwtDecoder(processor);
if (issuerUri != null && !issuerUri.isEmpty()) {
decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuerUri));
}
return decoder;
}
}
```
No more `machineTokenFilter`. No more `PasswordEncoder`. No more dual filter chains. Agent traffic does not reach the SaaS platform — it goes directly to the tenant's server.
#### REWRITE: `MeController.java`
Stop calling Logto Management API on every request. Read everything from the JWT:
```java
@GetMapping("/api/me")
public ResponseEntity<?> me(Authentication authentication) {
if (!(authentication instanceof JwtAuthenticationToken jwtAuth)) {
return ResponseEntity.status(401).build();
}
Jwt jwt = jwtAuth.getToken();
String userId = jwt.getSubject();
// Read org membership from JWT claims (Logto includes this when
// org-scoped token is requested with UserScope.Organizations)
String orgId = jwt.getClaimAsString("organization_id");
List<String> orgRoles = jwt.getClaimAsStringList("organization_roles");
// Check platform admin via Logto global roles in token
// (Logto custom JWT feature puts roles in access token)
List<String> globalRoles = jwt.getClaimAsStringList("roles");
boolean isPlatformAdmin = globalRoles != null
&& globalRoles.contains("platform-admin");
// Resolve tenant from org
var tenant = orgId != null
? tenantService.getByLogtoOrgId(orgId).orElse(null) : null;
List<Map<String, Object>> tenants = tenant != null
? List.of(Map.of(
"id", tenant.getId().toString(),
"name", tenant.getName(),
"slug", tenant.getSlug(),
"logtoOrgId", tenant.getLogtoOrgId()))
: List.of();
return ResponseEntity.ok(Map.of(
"userId", userId,
"isPlatformAdmin", isPlatformAdmin,
"tenants", tenants
));
}
```
Note: If the user has multiple orgs, the frontend requests a separate token per org. The `/api/me` endpoint returns the tenant for the org in the current token. The frontend's `OrgResolver` can call `/api/me` once with a non-org-scoped token to get the list, then switch to org-scoped tokens.
For multi-org enumeration (the `OrgResolver` initial load), `LogtoManagementClient` is still needed — but only on this one cold-start path, not on every request. This is acceptable. Over time, Logto's organization token claims will make this unnecessary.
#### REWRITE: `TenantController.java` authorization
Replace manual role-checking via Management API with `@PreAuthorize`:
```java
@GetMapping
@PreAuthorize("hasAuthority('SCOPE_platform-admin') or hasRole('platform-admin')")
public ResponseEntity<List<TenantResponse>> listAll() {
return ResponseEntity.ok(
tenantService.findAll().stream().map(this::toResponse).toList());
}
```
This requires configuring a `JwtAuthenticationConverter` that maps Logto's role claims to Spring Security authorities. Add to `SecurityConfig`:
```java
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
var converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
List<GrantedAuthority> authorities = new ArrayList<>();
// Global roles (e.g., platform-admin)
var roles = jwt.getClaimAsStringList("roles");
if (roles != null) {
roles.forEach(r -> authorities.add(
new SimpleGrantedAuthority("ROLE_" + r)));
}
// Org roles (e.g., admin, member)
var orgRoles = jwt.getClaimAsStringList("organization_roles");
if (orgRoles != null) {
orgRoles.forEach(r -> authorities.add(
new SimpleGrantedAuthority("ROLE_org_" + r)));
}
return authorities;
});
return converter;
}
```
Then wire it: `.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())))`.
#### REWRITE: `TenantResolutionFilter.java`
Keep the concept, minor cleanup. The current code is correct — extracts `organization_id` from JWT, resolves to internal tenant. No functional change needed, just remove the import of the deleted `JwtAuthenticationFilter`.
#### NEW: API key management
**New entity: `ApiKeyEntity`**
```java
@Entity
@Table(name = "api_keys")
public class ApiKeyEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "environment_id", nullable = false)
private UUID environmentId;
@Column(name = "key_hash", nullable = false, length = 64)
private String keyHash; // SHA-256 hex
@Column(name = "key_prefix", nullable = false, length = 8)
private String keyPrefix; // First 8 chars, for identification
@Column(name = "status", nullable = false, length = 20)
private String status = "ACTIVE"; // ACTIVE, ROTATED, REVOKED
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "revoked_at")
private Instant revokedAt;
}
```
**New migration: `V011__create_api_keys.sql`**
```sql
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
environment_id UUID NOT NULL REFERENCES environments(id) ON DELETE CASCADE,
key_hash VARCHAR(64) NOT NULL,
key_prefix VARCHAR(8) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked_at TIMESTAMPTZ
);
CREATE INDEX idx_api_keys_env ON api_keys(environment_id);
CREATE INDEX idx_api_keys_hash ON api_keys(key_hash);
```
**New service: `ApiKeyService`**
```java
@Service
public class ApiKeyService {
public record GeneratedKey(String plaintext, String keyHash, String prefix) {}
public GeneratedKey generate() {
byte[] bytes = new byte[32];
new SecureRandom().nextBytes(bytes);
String plaintext = "cmk_" + Base64.getUrlEncoder()
.withoutPadding().encodeToString(bytes);
String hash = sha256Hex(plaintext);
String prefix = plaintext.substring(0, 12);
return new GeneratedKey(plaintext, hash, prefix);
}
public Optional<ApiKeyEntity> validate(String plaintext) {
String hash = sha256Hex(plaintext);
return repository.findByKeyHashAndStatus(hash, "ACTIVE");
}
public ApiKeyEntity rotate(UUID environmentId) {
// Mark existing keys as ROTATED (still valid during grace period)
// Create new key
// Return new key entity
}
public void revoke(UUID keyId) {
// Mark as REVOKED, set revokedAt
}
}
```
The `cmk_` prefix (cameleer key) makes API keys visually identifiable and greppable in logs/configs.
**Updated `EnvironmentService.create()`:**
When creating an environment, auto-generate an API key:
```java
var key = apiKeyService.generate();
// Store hash in api_keys table
// Return plaintext to caller (shown once, never stored in plaintext)
```
The `bootstrap_token` column on `EnvironmentEntity` is removed. API keys are managed exclusively through the `api_keys` table. The plaintext is returned once at creation time and injected into server/agent containers.
#### REWRITE: Frontend auth
**`useAuth.ts`** — Read roles from access token, not ID token:
The current code reads `claims?.roles` from `getIdTokenClaims()`. Logto puts roles in access tokens, not ID tokens. The fix: roles come from the `/api/me` endpoint (which reads from the JWT on the backend) and are stored in the org store, not extracted client-side from token claims.
```typescript
export function useAuth() {
const { isAuthenticated, isLoading, signOut, signIn } = useLogto();
const { currentTenantId, isPlatformAdmin, organizations } = useOrgStore();
// Roles come from the org store (populated by OrgResolver from /api/me)
// Not from token claims
const logout = useCallback(() => {
signOut(window.location.origin + '/login');
}, [signOut]);
return {
isAuthenticated,
isLoading,
tenantId: currentTenantId,
isPlatformAdmin,
logout,
signIn,
};
}
```
**`usePermissions.ts`** — Map Logto org roles to permissions:
Replace hardcoded `OWNER/ADMIN/DEVELOPER/VIEWER` with Logto org role names:
```typescript
const ROLE_PERMISSIONS: Record<string, string[]> = {
'admin': ['tenant:manage', 'billing:manage', 'team:manage', 'apps:manage',
'apps:deploy', 'secrets:manage', 'observe:read', 'observe:debug',
'settings:manage'],
'member': ['apps:deploy', 'observe:read', 'observe:debug'],
};
```
Role names must match the Logto organization roles created by the bootstrap script (`admin`, `member`). Additional roles (e.g., `viewer`, `operator`) can be added to Logto and mapped here.
**`OrgResolver.tsx`** — Keep as-is. It calls `/api/me` and populates the org store. The backend now reads from JWT claims instead of calling the Management API, so this is faster.
**`ProtectedRoute.tsx`** — Keep as-is.
**`main.tsx` (TokenSync)** — Keep as-is. Already correctly requests org-scoped tokens.
#### REWRITE: `LogtoManagementClient.java`
Keep this service but reduce its usage. It's still needed for:
- Creating organizations when a new tenant is provisioned
- Adding users to organizations
- Deleting organizations
- Enumerating user organizations (for `OrgResolver` initial load — until Logto puts full org list in token claims)
Remove `getUserRoles()` — roles come from JWT claims now.
#### REWRITE: `PublicConfigController.java`
Keep as-is. Serves frontend configuration. No auth changes needed.
#### REWRITE: Bootstrap script (`docker/logto-bootstrap.sh`)
Update to set `CAMELEER_OIDC_ISSUER_URI` and `CAMELEER_OIDC_AUDIENCE` on the tenant server:
```bash
# Add to the cameleer3-server environment in docker-compose or bootstrap output:
CAMELEER_OIDC_ISSUER_URI=http://logto:3001/oidc
CAMELEER_OIDC_AUDIENCE=https://api.cameleer.local
```
The bootstrap script should also stop reading Logto's internal database for secrets. Instead, create the M2M app via Management API and capture the returned `secret` from the API response (which it already does for new apps — the `psql` fallback is only for retrieving secrets of existing apps). For idempotency, store the M2M secret in the bootstrap JSON file and re-read it on subsequent runs.
#### REMOVE: Traefik ForwardAuth middleware
Remove from `docker-compose.yml`:
```yaml
# DELETE these labels:
traefik.http.routers.forwardauth.rule=Path(`/auth/verify`)
traefik.http.services.forwardauth.loadbalancer.server.port=8080
traefik.http.middlewares.forward-auth.forwardauth.address=http://cameleer-saas:8080/auth/verify
```
Each service validates tokens independently. No proxy-mediated trust.
---
## Logto Configuration Requirements
For the JWT claims to contain the information needed (roles, org_id, org_roles), Logto must be configured to include custom claims in access tokens. This is done via Logto's **Custom JWT** feature:
1. **Global roles in access token**: Configure Logto to include user roles in the access token's `roles` claim. This may require a custom JWT script in Logto's admin console.
2. **Organization roles in access token**: When a token is requested with an organization scope, Logto includes `organization_id` and `organization_roles` in the token by default.
3. **API resource**: The `https://api.cameleer.local` resource must be created in Logto and configured to accept organization tokens.
The bootstrap script already creates the API resource and roles. Verify that the Logto custom JWT configuration includes roles in the access token payload.
---
## Greenfield Approach
This is a new development — no production data exists. All database schemas, migrations, and code are written fresh without backward-compatibility constraints.
### Database
- **Remove** migrations V001 (users), V002 (roles/permissions), V003 (default roles) entirely. These tables are not needed — users and roles live in Logto.
- **Replace** with a single clean migration that creates only the tables needed: `tenants`, `environments`, `api_keys`, `licenses`, `apps`, `deployments`, `audit_log`.
- The `bootstrap_token` column on `environments` is renamed to `api_key_plaintext` or removed in favor of the `api_keys` table exclusively.
### Implementation Order
1. **Phase 1**: Update cameleer3-server (add OIDC resource server support). Deploy.
2. **Phase 2**: Rewrite cameleer-saas backend (clean security config, API key management, Logto-only auth). Deploy with frontend changes atomically.
3. **Phase 3**: Update bootstrap script (set OIDC env vars on server, stop reading Logto DB directly).
---
## Security Properties
| Property | Status |
|----------|--------|
| All human auth via Logto OIDC | Yes |
| Zero trust (JWT validated independently by each service) | Yes |
| No identity in HTTP headers | Yes (ForwardAuth deleted) |
| Server-per-tenant isolation | Yes |
| API keys hashed at rest (SHA-256) | Yes |
| API key rotation with grace period | Yes |
| Short-lived agent JWTs (1h access, 7d refresh) | Yes (server default) |
| Ed25519 command signing (integrity) | Unchanged |
| Nonce protection for destructive commands | Unchanged |
| No custom crypto | Yes (all standard: OIDC, JWKS, HMAC-SHA256, Ed25519 via JCA) |
| Self-hosted compatibility | Yes (OIDC properties optional) |
## Open Questions
None — all design decisions resolved during brainstorming.

View File

@@ -0,0 +1,104 @@
# Single-Domain Routing Design
## Problem
Customers cannot always provision subdomains. The platform must work with a single hostname and one DNS record.
## Solution
Path-based routing on one domain. SaaS app at `/platform`, server-ui at `/server/`, Logto as catch-all. SPA assets moved to `/_app/` to avoid conflict with Logto's `/assets/`.
## Routing (all on `${PUBLIC_HOST}`)
| Path | Target | Priority | Notes |
|------|--------|----------|-------|
| `/platform/*` | cameleer-saas:8080 | default | Spring context-path `/platform` |
| `/server/*` | cameleer3-server-ui:80 | default | Strip-prefix + `BASE_PATH=/server` |
| `/` | redirect → `/platform/` | 100 | Via `docker/traefik-dynamic.yml` |
| `/*` | logto:3001 | 1 (lowest) | Catch-all: sign-in, OIDC, assets |
## Configuration
Two env vars control everything:
```env
PUBLIC_HOST=cameleer.mycompany.com
PUBLIC_PROTOCOL=https
```
## TLS
- **Dev**: `traefik-certs` init container auto-generates self-signed cert on first boot
- **Production**: Traefik ACME (Let's Encrypt)
- HTTP→HTTPS redirect via Traefik entrypoint config
## Logto
- `ENDPOINT` = `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}` (same domain as SPA)
- OIDC issuer = `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}/oidc`
- Same origin — no CORS, cookies work
- Management API only via Docker-internal networking (`http://logto:3001`)
- Bootstrap (`docker/logto-bootstrap.sh`) creates apps, users, orgs, roles, scopes
- Traditional app has `skipConsent: true` for first-party SSO
## SaaS App (cameleer-saas)
- `server.servlet.context-path: /platform` — Spring handles prefix transparently
- Vite `base: '/platform/'`, `assetsDir: '_app'`
- BrowserRouter `basename="/platform"`
- API client: `API_BASE = '/platform/api'`
- Custom `JwtDecoder`: ES384 algorithm, `at+jwt` token type, split issuer-uri / jwk-set-uri
- Redirect URIs: `${PROTO}://${HOST}/platform/callback`
## Server Integration (cameleer3-server)
| Env var | Value | Purpose |
|---------|-------|---------|
| `CAMELEER_OIDC_ISSUER_URI` | `${PROTO}://${HOST}/oidc` | Token issuer claim validation |
| `CAMELEER_OIDC_JWK_SET_URI` | `http://logto:3001/oidc/jwks` | Docker-internal JWK fetch |
| `CAMELEER_OIDC_TLS_SKIP_VERIFY` | `true` | Skip cert verify for OIDC discovery (dev only) |
| `CAMELEER_CORS_ALLOWED_ORIGINS` | `${PROTO}://${HOST}` | Browser requests through Traefik |
Server OIDC requirements:
- ES384 signing algorithm (Logto default)
- `at+jwt` token type acceptance
- `X-Forwarded-Prefix` support for correct redirect_uri construction
- Branding endpoint (`/api/v1/branding/logo`) must be publicly accessible
## Server UI (cameleer3-server-ui)
| Env var | Value | Purpose |
|---------|-------|---------|
| `BASE_PATH` | `/server` | React Router basename + `<base>` tag |
| `CAMELEER_API_URL` | `http://cameleer3-server:8081` | nginx API proxy target |
Traefik strip-prefix removes `/server` before forwarding to nginx. Server-ui injects `<base href="/server/">` via `BASE_PATH`.
## Bootstrap Redirect URIs
```sh
# SPA (cameleer-saas)
SPA_REDIRECT_URIS=["${PROTO}://${HOST}/platform/callback"]
SPA_POST_LOGOUT_URIS=["${PROTO}://${HOST}/platform/login"]
# Traditional (cameleer3-server) — both variants until X-Forwarded-Prefix is consistent
TRAD_REDIRECT_URIS=["${PROTO}://${HOST}/oidc/callback","${PROTO}://${HOST}/server/oidc/callback"]
TRAD_POST_LOGOUT_URIS=["${PROTO}://${HOST}","${PROTO}://${HOST}/server"]
```
## Customer Bootstrap
```bash
# 1. Set domain
echo "PUBLIC_HOST=cameleer.mycompany.com" >> .env
echo "PUBLIC_PROTOCOL=https" >> .env
# 2. Point DNS (1 record)
# cameleer.mycompany.com → server IP
# 3. Start
docker compose up -d
```
## Future: Custom Sign-In UI (Roadmap)
For full auth UX control, build a custom sign-in experience using Logto's Experience API. Eliminates Logto's interaction pages — Logto becomes a pure OIDC/API backend. Separate project.

View File

@@ -0,0 +1,89 @@
# Custom Logto Sign-In UI — IMPLEMENTED
## Problem
Logto's default sign-in page uses Logto branding. While we configured colors and logos via `PATCH /api/sign-in-exp`, control over layout, typography, and components is limited. The sign-in experience is visually inconsistent with the cameleer3-server login page.
## Goal
Replace Logto's sign-in UI with a custom React SPA that visually matches the cameleer3-server login page, using `@cameleer/design-system` components for consistency across all deployment models.
## Scope
**MVP (implemented)**: Username/password sign-in only.
**Later**: Sign-up, forgot password, social login, MFA.
## Architecture
### Source
Separate Vite+React app at `ui/sign-in/`. Own `package.json`, shares `@cameleer/design-system` v0.1.31.
### Build
Custom Logto Docker image (`cameleer-logto`). `ui/sign-in/Dockerfile` has a multi-stage build: node stage builds the SPA, then copies dist over Logto's built-in experience directory at `/etc/logto/packages/experience/dist/`. CI builds and pushes the image.
**Note**: `CUSTOM_UI_PATH` env var does NOT work for Logto OSS — it's a Logto Cloud-only feature. The correct approach for self-hosted is replacing the experience dist directory.
### Deploy
`docker-compose.yml` pulls the pre-built `cameleer-logto` image. No init containers, no shared volumes, no local builds needed.
### Auth Flow
```
User visits /platform/ → LoginPage auto-redirects to Logto OIDC
→ Logto sets interaction cookie, serves custom sign-in UI
→ User enters credentials → Experience API 4-step flow
→ Logto redirects back to /platform/callback with auth code
→ SaaS app exchanges code for token, user lands on dashboard
```
## Experience API
The custom UI communicates with Logto via the Experience API (stateful, cookie-based):
```
1. PUT /api/experience → 204 (init SignIn)
2. POST /api/experience/verification/password → 200 { verificationId }
3. POST /api/experience/identification → 204 (confirm identity)
4. POST /api/experience/submit → 200 { redirectTo }
```
The interaction cookie is set by `/oidc/auth` before the user lands on the sign-in page. All API calls use `credentials: "same-origin"`.
## Visual Design
Matches cameleer3-server LoginPage exactly:
- Centered `Card` (400px max-width, 32px padding)
- Logo: favicon.svg + "cameleer3" text (24px bold)
- Random witty subtitle (13px muted)
- `FormField` + `Input` for username and password
- Amber `Button` (primary variant, full-width)
- `Alert` for errors
- Background: `var(--bg-base)` (light beige)
- Dark mode support via design-system tokens
- Favicon bundled in `ui/sign-in/public/favicon.svg` (served by Logto, not SaaS)
## Files
| File | Purpose |
|------|---------|
| `ui/sign-in/Dockerfile` | Multi-stage: node build + FROM logto:latest + COPY dist |
| `ui/sign-in/package.json` | React 19 + @cameleer/design-system + Vite 6 |
| `ui/sign-in/src/SignInPage.tsx` | Login form with Experience API integration |
| `ui/sign-in/src/SignInPage.module.css` | CSS Modules (matches server LoginPage) |
| `ui/sign-in/src/experience-api.ts` | Typed Experience API client (4-step flow) |
| `ui/sign-in/src/main.tsx` | React mount + design-system CSS import |
| `ui/sign-in/public/favicon.svg` | Cameleer camel logo (bundled in dist) |
| `ui/src/auth/LoginPage.tsx` | Auto-redirects to Logto OIDC (no button) |
| `.gitea/workflows/ci.yml` | Builds and pushes `cameleer-logto` image |
| `docker-compose.yml` | Uses `cameleer-logto` image (no build directive) |
## Future Extensions
- Add React Router for multiple pages (sign-up, forgot password)
- Fetch `GET /api/.well-known/sign-in-exp` for dynamic sign-in method detection
- Social login buttons via `POST /api/experience/verification/social`
- MFA via `POST /api/experience/verification/totp`
- Consent page via `GET /api/interaction/consent`

View File

@@ -0,0 +1,66 @@
# Logto Admin Credentials + Sign-In Branding — IMPLEMENTED
## Problem
1. Logto admin console and SaaS platform have separate credentials — unnecessary complexity for operators
2. Logto's sign-in page uses default Logto branding, not Cameleer's theme
## Solution
### Admin Credentials
Reuse the SaaS admin user for Logto console access. The bootstrap assigns the Logto admin tenant management role to the SaaS admin user, so `SAAS_ADMIN_USER`/`SAAS_ADMIN_PASS` works for both the platform and the Logto console.
**Bootstrap change:** After creating the SaaS admin user, assign them to Logto's `admin` tenant with the management role:
```sh
# Assign admin tenant management role to SaaS owner
ADMIN_MGMT_ROLE_ID=$(api_get "/api/roles" | jq -r '.[] | select(.name == "admin:admin") | .id')
if [ -n "$ADMIN_MGMT_ROLE_ID" ]; then
api_post "/api/users/$ADMIN_USER_ID/roles" "{\"roleIds\": [\"$ADMIN_MGMT_ROLE_ID\"]}"
log "SaaS admin granted Logto console access."
fi
```
### Sign-In Branding
Configure Logto's sign-in experience via `PATCH /api/sign-in-exp` during bootstrap.
**Colors** (from `@cameleer/design-system`):
- Primary: `#C6820E` (amber)
- Dark primary: `#D4941E`
- Dark mode enabled
**Logo**: Served from SaaS app at `/platform/logo.svg` and `/platform/logo-dark.svg`. Files live in `ui/public/`.
**Custom CSS**: Override fonts and button styles to match Cameleer theme.
**Bootstrap API call:**
```sh
api_patch "/api/sign-in-exp" "{
\"color\": {
\"primaryColor\": \"#C6820E\",
\"isDarkModeEnabled\": true,
\"darkPrimaryColor\": \"#D4941E\"
},
\"branding\": {
\"logoUrl\": \"${PROTO}://${HOST}/platform/logo.svg\",
\"darkLogoUrl\": \"${PROTO}://${HOST}/platform/logo-dark.svg\"
}
}"
```
## Files to Modify
- `docker/logto-bootstrap.sh`:
- Add `api_patch` helper function (PATCH method, like `api_put` but with PATCH)
- New phase: assign admin tenant role to SaaS admin user
- New phase: configure sign-in experience branding
- `ui/public/logo.svg` — NEW, Cameleer logo for light mode
- `ui/public/logo-dark.svg` — NEW, Cameleer logo for dark mode
## Customer Experience
Customer sets `SAAS_ADMIN_USER` and `SAAS_ADMIN_PASS` in `.env`. After `docker compose up`:
- Login to SaaS platform at `/platform/` with those credentials
- Login to Logto console at port 3002 with the same credentials
- Sign-in page shows Cameleer branding automatically

View File

@@ -0,0 +1,78 @@
# Server Role Mapping via Logto Scopes — IMPLEMENTED
## Problem
When a Logto user SSOs into the cameleer3-server, they get `VIEWER` role by default (OIDC auto-signup). There's no automatic mapping between Logto organization roles and server roles. A SaaS admin must manually promote users in the server.
## Constraint
SaaS platform roles must NOT map to server roles. A `platform:admin` is not automatically a `server:admin`. The two role systems are independent — only server-specific scopes trigger server role assignment.
## Solution
Add namespaced server scopes (`server:admin`, `server:operator`, `server:viewer`) to the Logto API resource. Assign them to organization roles. The server reads the `scope` claim from the JWT and maps to its RBAC roles.
## Scopes
New scopes on API resource `https://api.cameleer.local`:
| Scope | Server Role | Description |
|-------|-------------|-------------|
| `server:admin` | ADMIN | Full server access |
| `server:operator` | OPERATOR | Deploy, manage apps |
| `server:viewer` | VIEWER | Read-only observability |
Existing SaaS scopes remain unchanged (`platform:admin`, `tenant:manage`, etc.).
## Organization Role Assignments
| Org Role | SaaS Scopes (existing) | Server Scopes (new) |
|----------|----------------------|-------------------|
| `admin` | `platform:admin`, `tenant:manage`, `billing:manage`, `team:manage`, `apps:manage`, `apps:deploy`, `secrets:manage`, `observe:read`, `observe:debug`, `settings:manage` | `server:admin` |
| `member` | `apps:manage`, `apps:deploy`, `observe:read`, `observe:debug` | `server:viewer` |
## Changes
### Bootstrap (`docker/logto-bootstrap.sh`)
1. Add `server:admin`, `server:operator`, `server:viewer` to the `create_scope` calls for the API resource
2. Include them in the org role → API resource scope assignments
### SaaS Frontend (`ui/src/main.tsx` or `PublicConfigController`)
Add the server scopes to the requested scopes list so Logto includes them in access tokens. The SaaS app ignores them; the server reads them.
### Server Team
Update scope-to-role mapping in `JwtAuthenticationFilter`:
```java
// Before:
if (scopes.contains("admin")) return List.of("ADMIN");
// After:
if (scopes.contains("server:admin")) return List.of("ADMIN");
if (scopes.contains("server:operator")) return List.of("OPERATOR");
if (scopes.contains("server:viewer")) return List.of("VIEWER");
```
### OIDC Config (bootstrap Phase 7)
Set `rolesClaim: "scope"` in the server OIDC config so the server reads roles from the scope claim:
```json
{
"rolesClaim": "scope",
"defaultRoles": ["VIEWER"]
}
```
## Token Flow
1. User logs into SaaS at `/platform/` via Logto
2. Token contains: `scope: "platform:admin tenant:manage ... server:admin"`
3. User clicks "View Dashboard" → SSOs into server at `/server/`
4. Server reads token scope → finds `server:admin` → maps to ADMIN role
5. User has full admin access in server — no manual promotion
## Files to Modify
- `docker/logto-bootstrap.sh` — add server scopes, assign to org roles, set rolesClaim in Phase 7
- `src/main/java/.../config/PublicConfigController.java` — add server scopes to the scopes list (so they're requested in tokens)

View File

@@ -0,0 +1,594 @@
# Cameleer Ecosystem Architecture Review
**Date:** 2026-04-07
**Status:** Ready for review
**Scope:** cameleer3 (agent), cameleer3-server, cameleer-saas
**Focus:** Responsibility boundaries, architectural fitness, simplification opportunities
**Not in scope:** Security hardening, code quality, performance
---
## Executive Summary
The cameleer ecosystem has a clear vision: a standalone observability and runtime platform for Apache Camel, optionally managed by a thin SaaS vendor layer. Both deployment modes must be first-class.
The agent (cameleer3) is architecturally clean. Single job, well-defined protocol.
The server (cameleer3-server) is solid for observability but currently lacks runtime management capabilities (deploying and managing Camel application containers). These capabilities exist in the SaaS layer today but belong in the server, since standalone customers also need them.
The SaaS layer (cameleer-saas) has taken on too many responsibilities: environment management, app lifecycle, container orchestration, direct ClickHouse access, and partial auth duplication. It should be a thin vendor management plane: onboard tenants, provision server instances, manage billing. Nothing more.
**The revised direction:**
- **Server layer** = the product. Observability + runtime management + auth/RBAC. Self-sufficient standalone, or managed by SaaS.
- **SaaS layer** = vendor management plane. Owns tenant lifecycle (onboard, offboard, bill), provisions server instances, communicates exclusively via server REST APIs.
- **Strong data separation.** Each layer has its own dedicated PostgreSQL and ClickHouse. No cross-layer database access.
- **Logto as federation hub.** In SaaS mode, Logto handles all user authentication. Customers bring their own OIDC providers via Logto Enterprise SSO connectors.
---
## What's Working Well
### Agent (cameleer3)
- Clean separation: core logic in `cameleer3-core`, protocol models in `cameleer3-common`, delivery mechanisms (agent/extension) as thin wrappers
- Well-defined agent-server protocol (PROTOCOL.md) with versioning
- Dual-mode design (Java agent + Quarkus extension) is elegant
- Compatibility matrix across 40 Camel versions demonstrates maturity
- No changes needed
### Server (cameleer3-server)
- Two-database pattern (PostgreSQL control plane, ClickHouse observability data) is correct
- In-memory agent registry with heartbeat-based auto-recovery is operationally sound
- `cameleer3-server-core` / `cameleer3-server-app` split keeps domain logic framework-free
- SSE command push with Ed25519 signing is well-designed
- The UI is competitive-grade (per UX audit #100)
- Independent user/group/role management works for standalone deployments
### SaaS (cameleer-saas)
- Logto as identity provider was a good buy-vs-build decision
- The async deployment pipeline (DeploymentExecutor) is well-implemented (will migrate to server)
- Tenant isolation interceptor is a solid pattern
- M2M token infrastructure (ServerApiClient) is the right integration pattern
- The dual-deployment architecture document shows strong strategic thinking
### Cross-cutting
- MOAT features (debugger, lineage, correlation) correctly planned as agent+server features
- Design doc discipline provides good decision traceability
- Gitea issues show clear product thinking and prioritization
---
## Current Architectural Problems
### Problem 1: Environment and app management lives in the wrong layer
**What happens today:**
- The SaaS has `EnvironmentEntity`, `AppEntity`, `DeploymentEntity` — full environment/app lifecycle management
- The server treats `applicationId` and `environmentId` as opaque strings from agent heartbeats
- The server has no concept of "deploy an app" or "create an environment"
**Why this is wrong:**
- Standalone customers need environment and app management too. Without the SaaS, they have no way to deploy Camel JARs through a UI.
- The server is the product. Runtime management is a core product capability, not a SaaS add-on.
- The SaaS should provision a server instance for a tenant and then get out of the way. The tenant interacts with their server instance directly.
**What should happen:**
- Environment CRUD, app CRUD, JAR upload, deployment lifecycle — all move to the server.
- The server gains a `RuntimeOrchestrator` interface with auto-detected implementations:
- Docker socket available → Docker mode (image build, container lifecycle)
- K8s service account available → K8s mode (Deployments, Kaniko builds)
- Neither → observability-only mode (agents connect externally, no managed runtime)
- One deployable. Adapts to its environment. Standalone customer mounts Docker socket and gets full runtime management.
---
### Problem 2: SaaS bypasses the server to access its databases
**What happens today:**
- `AgentStatusService` queries the server's ClickHouse directly (`SELECT count(), max(start_time) FROM executions`)
- `ContainerLogService` creates and manages its own `container_logs` table in the server's ClickHouse
- The SaaS has its own ClickHouse connection pool (HikariCP, 10 connections)
**Why this is wrong:**
- Violates the exclusive data ownership principle. The server owns its ClickHouse schema.
- Schema changes in the server silently break the SaaS.
- Creates tight coupling where there should be a clean API boundary.
- Two connection pools to ClickHouse from different services adds unnecessary operational complexity.
**What should happen:**
- The SaaS has zero access to the server's databases. All data flows through the server's REST API.
- Container logs (a runtime concern) move to the server along with runtime management.
- The SaaS has its own PostgreSQL for vendor concerns (tenants, billing, provisioning records). No ClickHouse needed.
---
### Problem 3: Auth architecture doesn't support per-tenant OIDC
**What happens today:**
- The server has one global OIDC configuration
- In SaaS mode, it validates Logto tokens. All tenants use the same Logto instance.
- Customers cannot bring their own OIDC providers (Okta, Azure AD, etc.)
- The server generates its own JWTs after OIDC callback, creating dual-issuer problems (#38)
- `syncOidcRoles` writes shadow copies of roles to PostgreSQL on every login
**Why this matters:**
- Enterprise customers require SSO with their own identity provider. This is table-stakes for B2B SaaS.
- The dual-issuer pattern (server JWTs + Logto JWTs) causes the session synchronization problem (#38).
- Per-tenant OIDC needs a federation hub, not per-server OIDC config changes.
**What should happen:**
- **Standalone mode:** Server manages users/groups/roles independently. Optional OIDC integration pointing to customer's IdP directly. Works exactly as today.
- **SaaS mode:** Logto acts as federation hub via Enterprise SSO connectors. Each tenant/organization configures their own SSO connector (SAML or OIDC). Logto handles federation and issues a single token type. The server validates Logto tokens (one OIDC config). Single token issuer eliminates #38.
- **Server auth behavior (inferred from config, no explicit mode flag):**
- No OIDC configured: Full local auth. Server generates JWTs, manages users/groups/roles.
- OIDC configured: Local + OIDC coexist. Claim mapping available.
- OIDC configured + `cameleer.auth.local.enabled=false`: Pure resource server. No local login, no JWT generation, no shadow role sync. SaaS provisioner sets this.
---
### Problem 4: License validation has no server-side implementation
**What exists today:**
- The SaaS generates Ed25519-signed license JWTs with tier/features/limits
- The server has zero license awareness — no validation, no feature gating, no tier concept
- MOAT features cannot be gated at the server level
**What should happen:**
- Server validates Ed25519-signed license JWTs
- License loaded from: env var, file path, or API endpoint
- MOAT feature endpoints check license before serving data
- In standalone: license file at `/etc/cameleer/license.jwt`
- In SaaS: license injected during tenant provisioning
---
## Revised Architecture
### Layer Separation
```
SAAS LAYER (vendor management plane)
Owns: tenants, billing, provisioning, onboarding/offboarding
Storage: own PostgreSQL (vendor data only)
Auth: Logto (SaaS vendor UI, tenant SSO federation hub)
Communicates with server layer: exclusively via REST API
SERVER LAYER (the product)
Owns: observability, runtime management, environments, apps,
deployments, users, groups, roles, agent protocol, licenses
Storage: own PostgreSQL (RBAC, config, app metadata) + own ClickHouse (traces, metrics, logs)
Auth: standalone (local + optional OIDC) or oidc-only (validates external tokens)
Multi-tenancy: tenant_id-scoped data access in shared PG + CH
AGENT LAYER
Owns: instrumentation, data collection, command execution
Communicates with server: via PROTOCOL.md (HTTP + SSE)
```
### Deployment Models
**Standalone (single tenant):**
```
Customer runs one server instance.
Server manages its own users, apps, environments, deployments.
Server connects to its own PG + CH.
No SaaS involvement. No Logto.
Optional: customer configures OIDC to their corporate IdP.
License: file-based or env var.
```
**SaaS (multi-tenant):**
```
Vendor runs the SaaS layer + Logto + shared PG (vendor) + shared PG (server) + shared CH.
SaaS provisions one server instance per tenant.
Each server instance is scoped to one tenant_id.
All server instances share PG + CH (tenant_id partitioning).
Auth: Logto federation hub. Per-tenant Enterprise SSO connectors.
Server runs in oidc-only mode, validates Logto tokens.
SaaS communicates with each server instance via REST API (M2M token).
License: injected by SaaS during provisioning, pushed via server API.
```
### SaaS Tenant Onboarding Flow
```
1. Vendor creates tenant in SaaS layer (name, slug, tier, billing)
2. SaaS creates Logto organization (maps 1:1 to tenant)
3. SaaS generates Ed25519-signed license JWT (tier, features, limits, expiry)
4. SaaS provisions server instance:
- Docker mode: start container with tenant_id + license + OIDC config
- K8s mode: create Deployment in tenant namespace
5. SaaS calls server API to verify health
6. Tenant admin logs in via Logto (federated to their SSO if configured)
7. Tenant admin uploads Camel JARs, manages environments, deploys apps — all via server UI
8. SaaS only re-engages for: billing, license renewal, tier changes, offboarding
```
### Runtime Orchestration in the Server
The server gains runtime management, auto-detected by environment:
```java
RuntimeOrchestrator (interface)
+ createEnvironment(name, config) -> Environment
+ deployApp(envId, jar, config) -> Deployment
+ stopApp(appId) -> void
+ restartApp(appId) -> void
+ getAppLogs(appId, since) -> Stream<LogLine>
+ getAppStatus(appId) -> AppStatus
DockerRuntimeOrchestrator
- Activated when /var/run/docker.sock is accessible
- docker-java for image build + container lifecycle
- Traefik labels for HTTP routing
KubernetesRuntimeOrchestrator
- Activated when K8s service account is available
- fabric8 for Deployments, Services, ConfigMaps
- Kaniko for image builds
DisabledRuntimeOrchestrator
- Activated when neither Docker nor K8s is available
- Observability-only mode: agents connect externally
- Runtime management endpoints return 404
```
### Auth Architecture
Auth mode is inferred from configuration — no explicit mode flag.
**No OIDC configured → standalone:**
- Server manages users, groups, roles in its own PostgreSQL
- Local login via username/password
- Server generates JWTs (HMAC-SHA256)
- Agent auth: bootstrap token → JWT exchange
**OIDC configured → standalone + OIDC:**
- Local auth still available alongside OIDC
- OIDC users auto-signup on first login
- Claim mapping available for automated role/group assignment
- Server generates JWTs for both local and OIDC users
- Agent auth: bootstrap token → JWT exchange
**OIDC configured + local auth disabled (`cameleer.auth.local.enabled=false`) → OIDC-only:**
- Server is a pure OAuth2 resource server
- Validates external JWTs (Logto or any OIDC provider)
- Reads roles from configurable JWT claim (`roles`, `scope`, etc.)
- No local login, no JWT generation, no user table writes on login
- Agent auth: bootstrap token → JWT exchange (agents always use server-issued tokens)
- In SaaS mode, the SaaS provisioner sets `cameleer.auth.local.enabled=false`
**Per-tenant OIDC in SaaS (via Logto Enterprise SSO):**
```
Tenant A (uses Okta):
User → Logto → detects email domain → redirects to Okta → authenticates
→ Okta returns assertion → Logto issues JWT with org context
→ Server validates Logto JWT (one OIDC config for all tenants)
Tenant B (uses Azure AD):
User → Logto → detects email domain → redirects to Azure AD → authenticates
→ Azure AD returns assertion → Logto issues JWT with org context
→ Server validates same Logto JWT
Tenant C (no enterprise SSO):
User → Logto → authenticates with Logto credentials directly
→ Logto issues JWT with org context
→ Server validates same Logto JWT
```
Single token issuer (Logto). Single server OIDC config. Per-tenant SSO handled entirely in Logto. Eliminates dual-issuer problem (#38).
---
## What Moves Where
### From SaaS to Server
| Component | Current Location | New Home | Notes |
|-----------|-----------------|----------|-------|
| `EnvironmentEntity` + CRUD | SaaS | Server | First-class server concept |
| `AppEntity` + CRUD | SaaS | Server | First-class server concept |
| `DeploymentEntity` + lifecycle | SaaS | Server | First-class server concept |
| `DeploymentExecutor` | SaaS | Server | Async deployment pipeline |
| `DockerRuntimeOrchestrator` | SaaS | Server | Docker mode runtime |
| JAR upload + image build | SaaS | Server | Runtime management |
| Container log collection | SaaS | Server | Part of runtime management |
| `AgentStatusService` | SaaS | Removed | Server already has this natively |
| `ContainerLogService` | SaaS | Server | Logs stored in server's ClickHouse |
### Stays in SaaS
| Component | Why |
|-----------|-----|
| `TenantEntity` + lifecycle | Vendor concern: onboarding, offboarding |
| `LicenseService` (generation) | Vendor signs licenses |
| Billing integration (Stripe) | Vendor concern |
| Logto bootstrap + org management | Vendor concern |
| `ServerApiClient` | SaaS → server communication (grows in importance) |
| Audit logging (vendor actions) | Vendor concern |
### Stays in Server
| Component | Why |
|-----------|-----|
| Agent protocol (registration, heartbeat, SSE) | Core product |
| Observability pipeline (ingestion, storage, querying) | Core product |
| User/group/role management | Must work standalone |
| OIDC integration | Must work standalone |
| Dashboard, route diagrams, execution detail | Core product |
| Ed25519 config signing | Agent security |
| License validation (new) | Feature gating |
### Removed Entirely from SaaS
| Component | Why |
|-----------|-----|
| `ClickHouseConfig` + connection pool | SaaS must not access server's CH |
| `ClickHouseProperties` | No ClickHouse in SaaS |
| `AgentStatusService.getObservabilityStatus()` | Server API replaces this |
| `container_logs` table in CH | Moves to server with runtime management |
| Environment/App/Deployment entities | Move to server |
---
## What the SaaS Becomes
After migration, the SaaS layer is small and focused:
```
cameleer-saas/
├── tenant/ Tenant CRUD, lifecycle (PROVISIONING → ACTIVE → SUSPENDED → DELETED)
├── license/ License generation (Ed25519-signed JWTs)
├── billing/ Stripe integration (subscriptions, webhooks, tier changes)
├── identity/ Logto org management, Enterprise SSO configuration
├── provisioning/ Server instance provisioning (Docker / K8s)
├── config/ Security, SPA routing
├── audit/ Vendor action audit log
└── ui/ Vendor management dashboard (tenant list, billing, provisioning status)
```
**SaaS API surface shrinks to:**
- `POST/GET /api/tenants` — tenant CRUD (vendor admin)
- `POST/GET /api/tenants/{id}/license` — license management
- `POST /api/tenants/{id}/provision` — provision server instance
- `POST /api/tenants/{id}/suspend` — suspend tenant
- `DELETE /api/tenants/{id}` — offboard tenant
- `GET /api/tenants/{id}/status` — server instance health (via server API)
- `GET /api/config` — public config (Logto endpoint, scopes)
- Billing webhooks (Stripe)
Everything else (environments, apps, deployments, observability, user management) is the server's UI and API, accessed directly by the tenant.
---
## Migration Path
| Order | Action | Effort | Notes |
|-------|--------|--------|-------|
| 1 | Add Environment/App/Deployment entities + CRUD to server | Medium | Port from SaaS, adapt to server's patterns |
| 2 | Add RuntimeOrchestrator interface + DockerRuntimeOrchestrator to server | Medium | Port from SaaS, add auto-detection |
| 3 | Add JAR upload + image build pipeline to server | Medium | Port DeploymentExecutor |
| 4 | Add container log collection to server | Small | Part of runtime management |
| 5 | Add server API endpoints for app/env management | Medium | REST controllers + UI pages |
| 6 | Add `oidc-only` auth mode to server | Medium | Resource server mode |
| 7 | Implement server-side license validation | Medium | Ed25519 JWT validation + feature gating |
| 8 | Strip SaaS down to vendor management plane | Medium | Remove migrated code, simplify |
| 9 | Remove ClickHouse dependency from SaaS entirely | Small | Delete config, connection pool, queries |
| 10 | Write SAAS-INTEGRATION.md | Small | Document server API contract for SaaS |
Steps 1-5 can be developed as a "runtime management" feature in the server.
Steps 6-7 are independent server features.
Step 8 is the SaaS cleanup after server capabilities are in place.
---
## Issue Triage Notes
### Issues resolved by this architecture:
- **saas#38 (session management):** Eliminated — single token issuer in SaaS mode
- **server#100 (UX audit):** Server UI gains full runtime management, richer experience
- **server#122 (ClickHouse scaling):** SaaS no longer a ClickHouse client
- **saas#7 (license & feature gating):** Server-side license validation
- **saas#37 (admin tenant creation UI):** SaaS UI becomes vendor-focused, simpler
### Issues that become more important:
- **agent#33 (version cameleer3-common independently):** Critical before server API contract stabilizes
- **server#46 (OIDC PKCE for SPA):** Required for server-ui in oidc-only mode
- **server#101 (onboarding experience):** Server UI needs guided setup for standalone users
### Issues unaffected:
- **MOAT epics (#57-#72):** Correctly scoped as agent+server. License gating is the prerequisite.
- **UX audit P0s (#101-#103):** PMF-critical. Independent of architectural changes.
- **Agent transport/security (#13-#15, #52-#54):** Agent concerns, unrelated.
---
## User Management Model
### RBAC Structure
The server has a classical RBAC model with users, groups, and roles. All stored in the server's PostgreSQL.
**Entities:**
- **Users** — identity records (local or OIDC-sourced)
- **Groups** — organizational units; can nest (parent_group_id). Users belong to groups.
- **Roles** — permission sets. Attached to users (directly) or groups (inherited by members).
- **Permissions** — what a role allows (view executions, send commands, manage config, admin, deploy apps, etc.)
**Built-in roles** (system roles, cannot be deleted):
- `VIEWER` — read-only access to observability data and runtime status
- `OPERATOR` — VIEWER + send commands, edit config, deploy/manage apps
- `ADMIN` — full access including user/group/role management, server settings, license
Custom roles may be defined by the server admin for finer-grained control.
### Assignment Types
Every user-role and user-group assignment has an **origin**:
| Origin | Set by | Lifecycle | On OIDC login |
|--------|--------|-----------|---------------|
| `direct` | Admin manually assigns via UI/API | Persisted until admin removes it | Untouched |
| `managed` | Claim mapping rules evaluate JWT | Recalculated on every OIDC login | Cleared and re-evaluated |
Effective permissions = union of direct roles + managed roles + roles inherited from groups (both direct and managed group memberships).
**Schema:**
```sql
-- user_roles
user_id UUID NOT NULL
role_id UUID NOT NULL
origin VARCHAR NOT NULL -- 'direct' or 'managed'
mapping_id UUID -- NULL for direct; FK to claim_mapping_rules for managed
-- user_groups (same pattern)
user_id UUID NOT NULL
group_id UUID NOT NULL
origin VARCHAR NOT NULL -- 'direct' or 'managed'
mapping_id UUID -- NULL for direct; FK to claim_mapping_rules for managed
```
### Claim Mapping
When OIDC is configured, the server admin can define **claim mapping rules** that automatically assign roles or group memberships based on JWT claims. Rules are server-level config (one set per server instance = effectively per-tenant in SaaS mode).
**Rule structure:**
```sql
-- claim_mapping_rules
id UUID PRIMARY KEY
claim VARCHAR NOT NULL -- JWT claim to read (e.g., 'groups', 'roles', 'department')
match_type VARCHAR NOT NULL -- 'equals', 'contains', 'regex'
match_value VARCHAR NOT NULL -- value to match against
action VARCHAR NOT NULL -- 'assignRole' or 'addToGroup'
target VARCHAR NOT NULL -- role name or group name
priority INT DEFAULT 0 -- evaluation order (higher = later, for conflict resolution)
```
**Examples:**
```json
[
{ "claim": "groups", "match": "contains", "value": "cameleer-admins", "action": "assignRole", "target": "ADMIN" },
{ "claim": "groups", "match": "contains", "value": "integration-team", "action": "addToGroup", "target": "Integration Developers" },
{ "claim": "department", "match": "equals", "value": "ops", "action": "assignRole", "target": "OPERATOR" }
]
```
**Login flow with claim mapping:**
```
1. User authenticates via OIDC
2. Server receives JWT with claims
3. Auto-signup: create user record if not exists (with configurable default role)
4. Clear all MANAGED assignments for this user:
DELETE FROM user_roles WHERE user_id = ? AND origin = 'managed'
DELETE FROM user_groups WHERE user_id = ? AND origin = 'managed'
5. Evaluate claim mapping rules against JWT claims:
For each rule (ordered by priority):
Read claim value from JWT
If match_type matches match_value:
Insert MANAGED assignment (role or group)
6. User's effective permissions are now:
direct roles + managed roles + group-inherited roles
```
### How It Scales
| Size | OIDC | Claim mapping | Admin work |
|------|------|---------------|------------|
| Solo / small (1-10) | No, local auth | N/A | Create users, assign roles manually |
| Small + OIDC (5-20) | Yes | Not needed | Auto-signup with default VIEWER. Admin promotes key people (direct assignments) |
| Medium org (20-100) | Yes | 3-5 claim-to-role rules | One-time setup. Most users get roles automatically. Manual overrides for exceptions |
| Large org (100+) | Yes | Claim-to-group mappings | One-time setup. IdP groups → server groups → roles. Fully automated, self-maintaining |
| SaaS (Logto) | Enforced | Default mapping: `roles` claim → server roles | Vendor sets defaults. Customer configures SSO claim structure, then adds mapping rules |
### Standalone vs SaaS Behavior
**Standalone (no OIDC configured):**
- Full local user management: create users, set passwords, assign roles/groups
- Admin manages everything via server UI
**Standalone + OIDC (OIDC configured and enabled):**
- Local user management still available
- OIDC users auto-signup on first login (configurable: on/off, default role)
- Claim mapping available for automated role/group assignment
- Both local and OIDC users coexist
**SaaS / OIDC-only (OIDC configured and enabled, local auth disabled):**
- Inferred: when OIDC is configured and enabled, the server operates as a pure resource server
- No local user creation or password management
- Users exist only after first OIDC login (auto-signup always on)
- Claim mapping is the primary role assignment mechanism
- Admin can still make direct assignments via UI (for overrides)
- User/password management UI sections hidden
- Logto org roles → JWT `roles` claim → server claim mapping → server roles
**Note:** There is no explicit `cameleer.auth.mode` flag. The server infers its auth behavior from whether OIDC is configured and enabled. If OIDC is present, the server acts as a resource server for user-facing auth (agents always use server-issued tokens regardless).
### SaaS with Enterprise SSO (per-tenant customer IdPs)
```
Customer uses Okta:
Okta JWT contains: { "groups": ["eng", "cameleer-admins"], "department": "platform" }
→ Logto Enterprise SSO federates, forwards claims into Logto JWT
→ Server evaluates claim mapping rules:
"groups contains cameleer-admins" → ADMIN role (managed)
"department equals platform" → add to "Platform Team" group (managed)
→ User gets: ADMIN + Platform Team's inherited roles
Customer uses Azure AD:
Azure AD JWT contains: { "roles": ["CameleerOperator"], "jobTitle": "Integration Developer" }
→ Same flow, different mapping rules configured by that tenant's admin:
"roles contains CameleerOperator" → OPERATOR role (managed)
```
Each tenant configures their own mapping rules in their server instance. The server doesn't care which IdP issued the claims — it just evaluates rules against whatever JWT it receives.
---
## Resolved Design Questions
### Server Instance Topology
- `CAMELEER_TENANT_ID` env var (already exists) scopes all data access in PG and CH
- Standalone: defaults to `"default"`, customer never thinks about it
- SaaS: the SaaS provisioner sets it when starting the server container
- Auth behavior is inferred from OIDC configuration (no explicit mode flag)
- The server doesn't need to "know" it's in SaaS mode — tenant_id + OIDC config is sufficient
### Runtime Orchestrator Scope (Routing)
The server owns routing as part of the RuntimeOrchestrator abstraction. Two routing strategies, configured at the server level:
```
cameleer.routing.mode=path → api.example.com/apps/{env}/{app} (default, works everywhere)
cameleer.routing.mode=subdomain → {app}.{env}.apps.example.com (requires wildcard DNS + TLS)
```
- **Path-based** is the default — no wildcard DNS or TLS required, works in every environment
- **Subdomain-based** is opt-in for customers who prefer it and can provide wildcard infrastructure
- Docker mode: Traefik labels on deployed containers
- K8s mode: Service + Ingress resources
- The routing mechanism is an implementation detail of each RuntimeOrchestrator, not a separate concern
### UI Navigation
The current server navigation structure is preserved. Runtime management integrates as follows:
- **Environment management** → Admin section (high-privilege task, not daily workflow)
- **Applications** → New top-level nav item (app list, deploy, JAR upload, container status, deployment history)
- **Observability pages** (Exchanges, Dashboard, Routes, Logs) → unchanged
Applications page design requires a UI mock before finalizing — to be explored in the frontend design phase.
### Data Migration
Not applicable — greenfield. No existing installations to migrate.
---
## Summary
The cameleer ecosystem is well-conceived but the current SaaS-server boundary is in the wrong place. The SaaS has grown into a second product rather than a thin vendor layer.
The fix is architectural: move runtime management (environments, apps, deployments) into the server, make the SaaS a pure vendor management plane, enforce strict data separation, and use Logto Enterprise SSO as the federation hub for per-tenant OIDC.
The result: **the server is the complete product (observability + runtime + auth). The SaaS is how the vendor manages tenants of that product. Both standalone and SaaS are first-class because the server doesn't depend on the SaaS for any of its capabilities.**

View File

@@ -0,0 +1,32 @@
# Role Model + License Model Redesign
**Date:** 2026-04-07
**Status:** Approved
## Problem
The current role model (platform-admin, org admin, org member) doesn't map cleanly to real-world personas. The member role can deploy but can't manage apps — it's neither a proper operator nor a proper viewer. There's no read-only role. The license model assumes SaaS (per-tenant) with no on-premise consideration.
## Decision
### 4-Role Model
| Role | Logto Type | Scopes | Persona |
|------|-----------|--------|---------|
| SaaS Vendor | Global `saas-vendor` | `platform:admin` + all tenant scopes | SaaS operator (hosted only) |
| Platform Owner | Org `owner` | All 10 tenant scopes + `server:admin` | Customer admin |
| Operator | Org `operator` | `apps:manage`, `apps:deploy`, `observe:read`, `observe:debug`, `server:operator` | DevOps |
| Viewer | Org `viewer` | `observe:read`, `server:viewer` | Read-only stakeholder |
### Deployment Modes
- **SaaS:** Vendor-seed script (separate from bootstrap) creates `saas-vendor` role. Standard bootstrap creates tenants with owner/operator/viewer.
- **On-premise:** Single implicit tenant. First user is `owner`. No vendor role exists.
### License Model
No schema changes. `LicenseEntity.tenantId` works for both modes. On-prem has one tenant = one license. SaaS has per-tenant licenses managed by vendor.
### Vendor-Seed Script
`docker/vendor-seed.sh` — run once on hosted environment, not part of standard bootstrap. Creates saas-vendor global role + vendor user.

View File

@@ -0,0 +1,413 @@
# SaaS Platform UX Polish — Design Spec
**Date:** 2026-04-09
**Scope:** Bug fixes, design consistency, error handling, component quality for the cameleer-saas platform UI
**Out of scope:** Mobile responsiveness (deferred), new features (billing, team management), admin tenant creation (#37)
## Context
Playwright-driven audit of the live SaaS platform (22 screenshots) plus source code audit of `ui/src/` (3 pages, sign-in page, layout, auth components). The platform has only 3 pages — Dashboard, License, Admin Tenants — plus a custom Logto sign-in page. Issues are concentrated and structural.
Audit artifacts in `audit/`:
- `platform-ui-findings.md` — 30 issues from live UI audit
- `source-code-findings.md` — 22 issues from source code analysis
## Implementation Strategy
4 batches, ordered by impact. Smaller scope than the server UI polish (~25 items vs ~52).
---
## Batch 1: Layout Fixes
**Effort:** 0.5 days
### 1.1 Fix Label/Value Collision
**Problem:** Throughout Dashboard and License pages, labels and values run together: "Slugdefault", "Max Agents3", "Issued8. April 2026". The code uses `flex justify-between` but the flex container doesn't stretch to full Card width.
**Root cause (source audit):** The `<div className="flex justify-between">` elements are inside Card components. If the Card's inner container doesn't apply `w-full` or the flex children don't have enough space, `justify-between` collapses.
**Fix:** Ensure the container divs inside Cards have `w-full` (or `className="flex justify-between w-full"`). Check all label/value rows in:
- `DashboardPage.tsx:95-112` — Tenant Information section
- `LicensePage.tsx:94-115` — Validity section
- `LicensePage.tsx:145-158` — Limits section
If the Card component's children wrapper is the constraint, wrap the content in `<div className="w-full space-y-2">`.
**Files:** `ui/src/pages/DashboardPage.tsx`, `ui/src/pages/LicensePage.tsx`
### 1.2 Replace Hardcoded `text-white` with DS Variables
**Problem:** Every page uses Tailwind `text-white`, `text-white/60`, `text-white/80`, `bg-white/5`, `border-white/10` instead of DS CSS variables. This breaks light theme (TopBar has a working theme toggle).
**Fix:** Replace all hardcoded color classes with DS CSS variable equivalents using inline styles or a CSS module:
| Tailwind class | DS variable |
|---------------|-------------|
| `text-white` | `var(--text-primary)` |
| `text-white/80` | `var(--text-secondary)` |
| `text-white/60` | `var(--text-muted)` |
| `text-white/40` | `var(--text-faint)` |
| `bg-white/5` | `var(--bg-hover)` |
| `bg-white/10` | `var(--bg-inset)` |
| `border-white/10` | `var(--border-subtle)` |
| `divide-white/10` | `var(--border-subtle)` |
**Approach:** Create a shared CSS module (`ui/src/styles/platform.module.css`) with classes mapping to DS variables, or switch to inline `style={{ color: 'var(--text-primary)' }}`. The sign-in page already demonstrates the correct pattern with CSS modules + DS variables.
**Files:** `ui/src/pages/DashboardPage.tsx`, `ui/src/pages/LicensePage.tsx`, `ui/src/pages/AdminTenantsPage.tsx`
### 1.3 Reduce Redundant Dashboard Content
**Problem:** "Open Server Dashboard" appears 3 times. Status "ACTIVE" appears 3 times. Tier badge appears 2 times.
**Fix:**
- Remove the "Open Server Dashboard" primary button from the header area (keep the Server Management card + sidebar footer link — 2 locations max)
- Remove the status badge from the header area (keep KPI strip + Tenant Information)
- The tier badge next to the heading is fine (quick context)
**Files:** `ui/src/pages/DashboardPage.tsx`
---
## Batch 2: Header & Navigation
**Effort:** 1 day
### 2.1 Hide Server Controls on Platform Pages
**Problem:** TopBar always renders status filters (OK/Warn/Error/Running), time range pills (1h-7d), auto-refresh toggle, and command palette search. All are irrelevant on platform pages.
**Fix options (pick one):**
**Option A (recommended): Use TopBar props to hide sections.**
Check if the DS `TopBar` component accepts props to control which sections render. If it has `showFilters`, `showTimeRange`, `showAutoRefresh`, `showSearch` props — set them all to `false` in `Layout.tsx`.
**Option B: Remove providers that feed the controls.**
Don't wrap the platform app in `GlobalFilterProvider` and `CommandPaletteProvider` (in `main.tsx`). This may cause runtime errors if TopBar assumes they exist — test carefully.
**Option C: Custom simplified header.**
Replace `TopBar` with a simpler platform-specific header that only renders: breadcrumb, theme toggle, user menu. Use DS primitives (`Breadcrumb`, `Avatar`, `Dropdown`, `Button`) to compose it.
Investigate which option is viable by checking the DS `TopBar` component API.
**Files:** `ui/src/components/Layout.tsx`, possibly `ui/src/main.tsx`
### 2.2 Fix Sidebar Active State
**Problem:** `Sidebar.Section` used as navigation links via `onToggle` hack. No `active` prop set. Users can't tell which page they're on.
**Fix:** Pass `active={true}` to the current page's `Sidebar.Section` based on the route:
```tsx
const location = useLocation();
const isActive = (path: string) => location.pathname === path || location.pathname === path + '/';
<Sidebar.Section
icon={<DashboardIcon />}
label="Dashboard"
open={false}
active={isActive('/') || isActive('/platform')}
onToggle={() => navigate('/')}
>
{null}
</Sidebar.Section>
<Sidebar.Section
icon={<LicenseIcon />}
label="License"
open={false}
active={isActive('/license')}
onToggle={() => navigate('/license')}
>
{null}
</Sidebar.Section>
```
Check the DS `Sidebar.Section` props — if `active` doesn't exist on Section, check if there's a `Sidebar.Link` or `Sidebar.NavItem` component that supports it.
**Files:** `ui/src/components/Layout.tsx`
### 2.3 Add Breadcrumbs
**Problem:** `breadcrumb={[]}` is always empty.
**Fix:** Set breadcrumbs per page:
```tsx
// Layout.tsx:
const location = useLocation();
const breadcrumb = useMemo(() => {
if (location.pathname.includes('/license')) return [{ label: 'License' }];
if (location.pathname.includes('/admin')) return [{ label: 'Admin' }, { label: 'Tenants' }];
return [{ label: 'Dashboard' }];
}, [location.pathname]);
<TopBar breadcrumb={breadcrumb} ... />
```
Check the DS `TopBar` breadcrumb prop type to match the expected shape.
**Files:** `ui/src/components/Layout.tsx`
### 2.4 Remove One "Open Server Dashboard" Button
**Problem:** 3 locations for the same action.
**Fix:** Keep:
1. Sidebar footer link (always accessible)
2. Server Management card on Dashboard (contextual with description)
Remove: The primary "Open Server Dashboard" button in the header area of DashboardPage (line ~81-87).
**Files:** `ui/src/pages/DashboardPage.tsx`
### 2.5 Fix Sidebar Collapse
**Problem:** `collapsed={false}` hardcoded, `onCollapseToggle` is no-op.
**Fix:** Add state:
```tsx
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
<Sidebar collapsed={sidebarCollapsed} onCollapseToggle={() => setSidebarCollapsed(c => !c)}>
```
**Files:** `ui/src/components/Layout.tsx`
---
## Batch 3: Error Handling & Components
**Effort:** 1 day
### 3.1 OrgResolver Error State
**Problem:** Returns `null` on error — blank screen with sidebar/TopBar but no content.
**Fix:** Replace `return null` with an error display:
```tsx
if (isError) return (
<EmptyState
title="Unable to load account"
description="Failed to retrieve your organization. Please try again."
action={<Button onClick={() => refetch()}>Retry</Button>}
/>
);
```
Import `EmptyState` and `Button` from DS.
**Files:** `ui/src/auth/OrgResolver.tsx`
### 3.2 DashboardPage Error Handling
**Problem:** No `isError` check. Silently renders with `-` fallback values.
**Fix:** Add error state similar to LicensePage:
```tsx
if (tenantError || licenseError) return (
<EmptyState
title="Unable to load dashboard"
description="Failed to retrieve tenant information. Please try again."
/>
);
```
**Files:** `ui/src/pages/DashboardPage.tsx`
### 3.3 Replace Raw HTML with DS Components
**Problem:** LicensePage uses raw `<button>` and `<code>` where DS components exist.
**Fix:**
Replace raw button (line ~166-170):
```tsx
// BEFORE:
<button type="button" className="text-sm text-primary-400 ...">Show token</button>
// AFTER:
<Button variant="ghost" size="sm" onClick={() => setTokenExpanded(v => !v)}>
{tokenExpanded ? 'Hide token' : 'Show token'}
</Button>
```
Replace raw code block (line ~174-178) with DS `CodeBlock` if available, or at minimum use DS CSS variables instead of hardcoded Tailwind colors.
**Files:** `ui/src/pages/LicensePage.tsx`
### 3.4 Add Copy-to-Clipboard for License Token
**Problem:** Users must manually select and copy the token.
**Fix:** Add a copy button next to the token:
```tsx
<Button variant="ghost" size="sm" onClick={() => {
navigator.clipboard.writeText(license.token);
toast({ title: 'Token copied', variant: 'success' });
}}>
<Copy size={14} /> Copy
</Button>
```
Import `Copy` from `lucide-react` and `useToast` from DS.
**Files:** `ui/src/pages/LicensePage.tsx`
### 3.5 Fix Username Null = No Logout
**Problem:** When `username` is null, no user indicator or logout button appears.
**Fix:** Always pass a user object to TopBar — fallback to email or "User":
```tsx
const displayName = username || user?.email || 'User';
<TopBar user={{ name: displayName }} onLogout={logout} />
```
**Files:** `ui/src/components/Layout.tsx`
### 3.6 Add Password Visibility Toggle to Sign-In
**Problem:** No eye icon to reveal password.
**Fix:** The DS `Input` component may support a `type` toggle. If not, wrap with a show/hide toggle:
```tsx
const [showPassword, setShowPassword] = useState(false);
<div style={{ position: 'relative' }}>
<Input type={showPassword ? 'text' : 'password'} ... />
<Button variant="ghost" size="sm"
style={{ position: 'absolute', right: 4, top: '50%', transform: 'translateY(-50%)' }}
onClick={() => setShowPassword(!showPassword)}>
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
</Button>
</div>
```
**Files:** `ui/sign-in/src/SignInPage.tsx`
### 3.7 Admin Page Error Fallback
**Problem:** `/platform/admin/tenants` returns HTTP error with no graceful fallback.
**Fix:** Add error boundary or error state in AdminTenantsPage:
```tsx
if (isError) return (
<EmptyState
title="Unable to load tenants"
description="You may not have admin permissions, or the server is unavailable."
/>
);
```
**Files:** `ui/src/pages/AdminTenantsPage.tsx`
---
## Batch 4: Polish
**Effort:** 0.5 days
### 4.1 Unify `tierColor()` Mapping
**Problem:** Defined twice with different tier names:
- `DashboardPage.tsx:12-18` maps enterprise/pro/starter
- `LicensePage.tsx:25-33` maps BUSINESS/HIGH/MID/LOW
**Fix:** Extract a single `tierColor()` to a shared utility (`ui/src/utils/tier.ts`). Map all known tier names:
```typescript
export function tierColor(tier: string): BadgeColor {
switch (tier?.toUpperCase()) {
case 'BUSINESS': case 'ENTERPRISE': return 'success';
case 'HIGH': case 'PRO': return 'primary';
case 'MID': case 'STARTER': return 'warning';
case 'LOW': case 'FREE': return 'auto';
default: return 'auto';
}
}
```
Import from both pages.
**Files:** New `ui/src/utils/tier.ts`, modify `DashboardPage.tsx`, `LicensePage.tsx`
### 4.2 Fix Feature Badge Colors
**Problem:** Disabled features use `color="auto"` (hash-based, inconsistent). Should use muted neutral.
**Fix:** Check if DS Badge supports a `neutral` or `default` color variant. If not, use the closest muted option. The goal: enabled = green success, disabled = gray/muted (not red, not random).
**Files:** `ui/src/pages/LicensePage.tsx`
### 4.3 AdminTenantsPage Improvements
**Problem:** Row click silently switches tenant context. `createdAt` renders raw ISO. No empty state.
**Fix:**
- Add confirmation before tenant switch: `if (!confirm('Switch to tenant "X"?')) return;` (or DS AlertDialog)
- Format date: `new Date(row.createdAt).toLocaleDateString()`
- Add empty state: `<EmptyState title="No tenants" description="Create a tenant to get started." />`
**Files:** `ui/src/pages/AdminTenantsPage.tsx`
### 4.4 Replace Custom SVG Icons with Lucide
**Problem:** Layout.tsx has 4 inline SVG icon components instead of using lucide-react.
**Fix:** Replace with lucide icons:
- `DashboardIcon` -> `<LayoutDashboard size={18} />`
- `LicenseIcon` -> `<ShieldCheck size={18} />`
- `PlatformIcon` -> `<Building size={18} />`
- `ServerIcon` -> `<Server size={18} />`
Import from `lucide-react`.
**Files:** `ui/src/components/Layout.tsx`
### 4.5 Sign-In Branding
**Problem:** Login says "cameleer3" — internal repo name, not product brand.
**Fix:** Change to "Cameleer" (product name). Update the page title from "Sign in — cameleer3" to "Sign in — Cameleer".
**Files:** `ui/sign-in/src/SignInPage.tsx`
---
## Implementation Order
| Order | Batch | Items | Effort |
|-------|-------|-------|--------|
| 1 | **Batch 1: Layout Fixes** | 3 | 0.5 days |
| 2 | **Batch 2: Header & Navigation** | 5 | 1 day |
| 3 | **Batch 3: Error Handling & Components** | 7 | 1 day |
| 4 | **Batch 4: Polish** | 5 | 0.5 days |
**Total: ~20 items across 4 batches, ~3 days of work.**
---
## Related Issues
| Issue | Relevance |
|-------|-----------|
| #1 | Epic: SaaS Management Platform — this spec covers polish only |
| #37 | Admin: Tenant creation UI — not covered (feature work) |
| #38 | Cross-app session management — not covered (parked) |
## Out of Scope
- Mobile responsiveness (deferred per user request)
- New features (billing, team management, tenant creation)
- Admin tenant CRUD workflow (#37)
- Cross-app session sync (#38)

625
docs/user-manual.md Normal file
View File

@@ -0,0 +1,625 @@
# Cameleer SaaS User Manual
## 1. Introduction
Cameleer SaaS is a managed observability and runtime platform for Apache Camel applications. It lets you upload your Camel application JARs, deploy them as managed containers with the Cameleer agent automatically injected, and observe their behavior through route topology graphs, execution traces, processor metrics, and container logs -- all without running your own infrastructure.
### Who This Manual Is For
This manual is written for:
- **SaaS customers** who access a hosted Cameleer instance to deploy and monitor their Camel applications.
- **Self-hosted operators** who run the full Cameleer SaaS stack on their own infrastructure using Docker Compose.
Both audiences share the same UI and workflows. The self-hosted setup section at the end covers the additional steps needed to run the platform yourself.
### Two Deployment Modes
| Mode | You manage | We manage |
|------|-----------|-----------|
| **Managed SaaS** | Your Camel JARs | Infrastructure, upgrades, identity, storage |
| **Self-hosted** | Everything (Docker Compose stack) | Nothing -- you own it all |
---
## 2. Getting Started
### Logging In
Cameleer SaaS uses Logto for single sign-on (SSO). To log in:
1. Navigate to the Cameleer SaaS URL in your browser.
2. You will see the login screen with the title "Cameleer SaaS" and a subtitle "Managed Apache Camel Runtime."
3. Click **Sign in with Logto**.
4. Authenticate with your Logto credentials (username/password or any configured social login).
5. After successful authentication, you are redirected back to the dashboard.
![Login page](screenshots/login-page.png)
> **Tip:** If you are already authenticated, navigating to `/login` automatically redirects you to the dashboard.
### First-Time Experience
After your first login, you land on the **Dashboard**. What you see depends on whether your organization has been set up:
- If your account is linked to a tenant (organization), you see the tenant name, tier badge, and any existing environments.
- If your account is not linked to a tenant, you see a message: "No tenant associated. Please contact your administrator."
For self-hosted deployments, the bootstrap process creates a default tenant and organization automatically (see Section 10).
### Understanding the Dashboard
The dashboard provides an at-a-glance overview of your tenant:
- **Tenant header** -- Your organization name and license tier badge (LOW, MID, HIGH, or BUSINESS).
- **KPI strip** -- Four key metrics: total environments, total apps, running deployments, and stopped apps needing attention.
- **Environments list** -- Each environment shows its name, slug, number of apps, running count, and status (ACTIVE or INACTIVE).
- **Recent Deployments** -- A prompt to select an app to view its deployment history.
![Dashboard overview](screenshots/dashboard-overview.png)
### Sidebar Navigation
The sidebar provides access to all major sections:
| Section | Description |
|---------|-------------|
| **Dashboard** | Tenant overview and KPI metrics |
| **Environments** | Expandable tree showing all environments and their apps |
| **License** | License tier, features, limits, and token |
| **Platform** | Platform-wide tenant management (visible only to platform admins) |
| **View Dashboard** | Opens the observability dashboard (cameleer3-server) in a new tab |
| **Account** | Log out of the current session |
The Environments section in the sidebar renders as a collapsible tree: environments at the top level, with their applications nested underneath. Clicking any item navigates directly to its detail page.
---
## 3. Environments
### What Environments Are
An environment is an isolated runtime context within your tenant. Environments let you separate concerns -- for example, you might create `development`, `staging`, and `production` environments. Each environment has its own set of applications and deployments.
Every environment has:
- A **slug** (URL-safe identifier, e.g., `production`) -- immutable after creation.
- A **display name** (human-readable, e.g., "Production") -- can be renamed.
- A **status** -- typically `ACTIVE`.
### Creating an Environment
> **Note:** Creating environments requires the `apps:manage` scope. If you do not see the "Create Environment" button, contact your organization admin.
To create an environment:
1. Navigate to **Environments** from the sidebar or click **Create Environment** on the dashboard.
2. Click **Create Environment** in the top-right corner.
3. In the modal dialog, enter:
- **Slug** -- A URL-safe identifier (e.g., `staging`). Cannot be changed later.
- **Display Name** -- A human-readable name (e.g., "Staging").
4. Click **Create**.
![Create environment modal](screenshots/create-environment.png)
### Tier Limits on Environments
Your license tier determines how many environments you can create:
| Tier | Max Environments |
|------|-----------------|
| LOW | 1 |
| MID | 2 |
| HIGH | Unlimited |
| BUSINESS | Unlimited |
If you attempt to create an environment beyond your tier limit, the request will be rejected.
### Managing Environments
From the environment detail page you can:
- **Rename** the environment by clicking its display name (inline edit). Requires the `apps:manage` scope.
- **Delete** the environment using the "Delete Environment" button. An environment can only be deleted after all its apps have been removed. Requires the `apps:manage` scope.
---
## 4. Applications
### What an Application Represents
An application in Cameleer SaaS represents one of your Apache Camel applications. It tracks the uploaded JAR file, deployment state, routing configuration, and observability status. Applications live inside environments.
### Creating an Application
> **Note:** Creating applications requires the `apps:deploy` scope.
To create an application:
1. Navigate to the environment where you want to add the app.
2. Click **New App**.
3. In the modal dialog, fill in:
- **Slug** -- A URL-safe identifier (e.g., `order-router`). Cannot be changed later.
- **Display Name** -- A human-readable name (e.g., "Order Router").
- **JAR File** (optional) -- Select your Camel application JAR. You can also upload or re-upload the JAR later.
4. Click **Create App**.
![New app modal](screenshots/new-app.png)
### Uploading a JAR File
You can upload a JAR at creation time or re-upload it later:
1. Navigate to the app detail page.
2. In the **Actions** card on the Overview tab, click **Re-upload JAR**.
3. Select the new JAR file and click **Upload**.
The platform stores the original filename, file size, and a checksum for each upload.
### Understanding Agent Injection
When you deploy an application, the platform builds a Docker image from your JAR with the Cameleer agent automatically injected as a `-javaagent`. This means:
- You do not need to modify your application code or build process.
- The agent instruments your Camel routes at runtime using bytecode manipulation.
- The agent connects to the observability server and begins reporting route topology, execution traces, and processor metrics.
---
## 5. Deployments
### Deploying an Application
> **Note:** Deploying requires the `apps:deploy` scope.
To deploy an application:
1. Navigate to the app detail page (via the sidebar tree or the environment's app list).
2. On the **Overview** tab, in the **Actions** card, click **Deploy**.
3. The deployment starts asynchronously. The status will transition through the deployment lifecycle.
### Viewing Deployment Status
After triggering a deployment, the **Current Deployment** card on the Overview tab shows:
- **Version** -- An incrementing deployment version number.
- **Status** -- The observed status of the deployment.
- **Image** -- The Docker image reference built for this deployment.
- **Deployed** -- The timestamp when the deployment started.
- **Error** -- If the deployment failed, the error message appears here.
The deployment lifecycle follows these status transitions:
```
BUILDING --> STARTING --> RUNNING
|
+--> FAILED
```
- **BUILDING** -- The Docker image is being built with your JAR and the Cameleer agent.
- **STARTING** -- The container has been created and is starting up.
- **RUNNING** -- The container is running and healthy.
- **FAILED** -- The deployment encountered an error (image build failure, container crash, etc.).
### Stopping and Restarting
From the **Actions** card:
- **Stop** -- Stops the running container. A confirmation dialog appears before stopping.
- **Restart** -- Stops and redeploys the application with the same JAR version.
Both actions require the `apps:deploy` scope and are only available when the app has an active deployment.
### Reading Container Logs
To view container logs:
1. Navigate to the app detail page.
2. Click the **Logs** tab.
3. Logs appear in a scrollable viewer with timestamps.
4. Filter by stream using the buttons at the top: **All**, **stdout**, or **stderr**.
![Container logs](screenshots/container-logs.png)
> **Tip:** If no logs appear, the app may not have been deployed yet or the container may have exited immediately.
### Deployment History
To view the full deployment history:
1. Navigate to the app detail page.
2. Click the **Deployments** tab.
3. A table shows all past and current deployments with: version, observed status, desired status, deployed timestamp, stopped timestamp, and any error message.
Failed deployments are highlighted with a red accent for quick identification.
---
## 6. Observability
### Accessing the Observability Dashboard
The observability dashboard is provided by cameleer3-server and opens in a separate browser tab:
1. In the sidebar footer, click **View Dashboard**.
2. The dashboard opens at the cameleer3-server URL.
Alternatively, from any app detail page, the **Agent Status** card includes a "View in Dashboard" link.
### What You Can See
The observability dashboard provides:
- **Route topology** -- Visual graph of your Camel routes showing processors, endpoints, and data flow.
- **Execution traces** -- Individual route execution traces with timing, payload snapshots, and processor-level detail.
- **Processor metrics** -- Throughput, latency, and error rates per processor node.
### Agent Connection Status
The **Agent Status** card on each app detail page shows:
- **Registration state** -- Whether the agent has registered with the observability server.
- **Connection state** -- `CONNECTED` (live, with a pulsing indicator) or `DISCONNECTED`.
- **Last heartbeat** -- Timestamp of the most recent agent heartbeat.
- **Routes** -- List of Camel route IDs discovered by the agent.
- **Observability data** -- Whether traces, metrics, and diagrams are being produced, with a 24-hour trace count.
---
## 7. Licenses
### Understanding Tiers
Cameleer SaaS uses four license tiers that control available features and resource limits:
| Tier | Features | Max Agents | Max Environments | Retention |
|------|----------|-----------|-----------------|-----------|
| **LOW** | Topology | 3 | 1 | 7 days |
| **MID** | Topology, Lineage, Correlation | 10 | 2 | 30 days |
| **HIGH** | All (including Debugger, Replay) | 50 | Unlimited | 90 days |
| **BUSINESS** | All | Unlimited | Unlimited | 365 days |
### Feature Gating
Each feature is either **Enabled** or **Disabled** based on your tier:
| Feature | LOW | MID | HIGH | BUSINESS |
|---------|-----|-----|------|----------|
| Topology | Enabled | Enabled | Enabled | Enabled |
| Lineage | Disabled | Enabled | Enabled | Enabled |
| Correlation | Disabled | Enabled | Enabled | Enabled |
| Debugger | Disabled | Disabled | Enabled | Enabled |
| Replay | Disabled | Disabled | Enabled | Enabled |
### Viewing License Status and Expiry
To view your license:
1. Click **License** in the sidebar.
2. The license page displays:
- **Tier badge** -- Your current tier.
- **Validity** -- Issue date, expiration date, and days remaining. The days-remaining badge turns yellow when 30 or fewer days remain and red when expired.
- **Features** -- Which observability features are enabled or disabled.
- **Limits** -- Max agents, retention days, and max environments.
- **License token** -- Click "Show token" to reveal the token used for agent registration.
![License page](screenshots/license-page.png)
> **Warning:** If your license expires, deployed applications continue to run but new deployments and agent registrations may be restricted. Contact your administrator to renew.
---
## 8. Platform Administration
> **Note:** This section applies only to users with the `platform:admin` scope. The Platform section in the sidebar is not visible to regular users.
### Managing Tenants
Platform administrators can view and manage all tenants across the platform:
1. Click **Platform** in the sidebar.
2. The **All Tenants** page displays a table of every tenant with: name, slug, tier, status, and creation date.
3. Click on a tenant row to switch your active context to that tenant's dashboard.
![Admin tenants page](screenshots/admin-tenants.png)
### Creating Tenants
New tenants can be created via the API (the UI currently provides read-only access to the tenant list):
```
POST /api/tenants
Content-Type: application/json
{
"name": "Acme Corp",
"slug": "acme-corp",
"tier": "MID"
}
```
This requires a valid token with the `platform:admin` scope. A default environment is automatically created with each new tenant.
### Creating Organizations in Logto
Each tenant in Cameleer SaaS corresponds to an organization in Logto. When you create a tenant, a corresponding Logto organization should be created and users assigned to it. For self-hosted deployments, the bootstrap script handles this automatically for the initial tenant.
To create additional organizations:
1. Open the Logto admin console.
2. Navigate to **Organizations** and create a new organization.
3. Add users to the organization and assign them the appropriate role (admin or member).
4. Create the corresponding tenant in Cameleer SaaS via the API with a matching slug.
---
## 9. Roles and Permissions
### Organization Roles
Cameleer SaaS uses two organization-level roles managed in Logto:
| Role | Description |
|------|-------------|
| **admin** | Full access to all tenant operations |
| **member** | Can deploy apps and view observability data |
### What Each Role Can Do
| Scope | Admin | Member | Description |
|-------|-------|--------|-------------|
| `tenant:manage` | Yes | No | Manage tenant settings |
| `billing:manage` | Yes | No | Manage billing |
| `team:manage` | Yes | No | Manage team members |
| `apps:manage` | Yes | No | Create/delete apps and environments |
| `apps:deploy` | Yes | Yes | Deploy, stop, and restart apps |
| `secrets:manage` | Yes | No | Manage secrets |
| `observe:read` | Yes | Yes | View observability data |
| `observe:debug` | Yes | Yes | Debug and replay operations |
| `settings:manage` | Yes | No | Manage settings |
In the UI, buttons and actions that require a scope you do not have are automatically hidden. For example, members will not see "Create Environment" or "Delete App" buttons.
### How Permissions Are Managed
All role and permission management happens in Logto, not in the Cameleer SaaS application itself:
- Organization roles and their scopes are configured in Logto during the bootstrap process.
- To change a user's role, update their organization role assignment in the Logto admin console.
- To add a user to a tenant, add them to the corresponding Logto organization and assign a role.
There is also a global `platform:admin` scope (separate from organization roles) that grants access to the Platform section for cross-tenant administration.
The full list of 10 scopes is also available programmatically via the `GET /api/config` endpoint, which the frontend uses to discover available scopes at runtime.
---
## 10. Self-Hosted Setup
### Prerequisites
- **Docker Desktop** (Windows/Mac) or **Docker Engine 24+** (Linux)
- **Docker Compose** v2 (included with Docker Desktop)
- **Git** for cloning the repository
- **curl** or any HTTP client for verification
### Quick Start
```bash
# 1. Clone the repository
git clone https://gitea.siegeln.net/cameleer/cameleer-saas.git
cd cameleer-saas
# 2. Create your environment file
cp .env.example .env
# 3. Start the stack
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
# 4. Wait for services (~30-60 seconds for first boot)
docker compose logs -f cameleer-saas --since 10s
# Look for: "Started CameleerSaasApplication"
# 5. Verify
curl http://localhost:8080/actuator/health
# Expected: {"status":"UP"}
```
### Environment Variables Reference
Copy `.env.example` to `.env` and configure as needed:
| Variable | Purpose | Default |
|----------|---------|---------|
| `VERSION` | Docker image tag | `latest` |
| `POSTGRES_USER` | PostgreSQL username | `cameleer` |
| `POSTGRES_PASSWORD` | PostgreSQL password | `change_me_in_production` |
| `POSTGRES_DB` | PostgreSQL database name | `cameleer_saas` |
| `LOGTO_ENDPOINT` | Internal Logto URL (container-to-container) | `http://logto:3001` |
| `LOGTO_PUBLIC_ENDPOINT` | Public-facing Logto URL | `http://localhost:3001` |
| `LOGTO_ISSUER_URI` | OIDC issuer URI | `http://localhost:3001/oidc` |
| `LOGTO_JWK_SET_URI` | OIDC JWK set URI | `http://logto:3001/oidc/jwks` |
| `LOGTO_M2M_CLIENT_ID` | Machine-to-machine client ID (auto-set by bootstrap) | _(empty)_ |
| `LOGTO_M2M_CLIENT_SECRET` | Machine-to-machine client secret (auto-set by bootstrap) | _(empty)_ |
| `LOGTO_SPA_CLIENT_ID` | SPA client ID for the frontend | _(empty)_ |
| `CAMELEER_AUTH_TOKEN` | Bootstrap token for agent registration | `change_me_bootstrap_token` |
| `CAMELEER_CONTAINER_MEMORY_LIMIT` | Memory limit for deployed containers | `512m` |
| `CAMELEER_CONTAINER_CPU_SHARES` | CPU shares for deployed containers | `512` |
| `CAMELEER_TENANT_SLUG` | Default tenant slug | `default` |
| `DOMAIN` | Domain for Traefik TLS and route URLs | `localhost` |
| `SAAS_ADMIN_USER` | Platform admin username | `admin` |
| `SAAS_ADMIN_PASS` | Platform admin password | `admin` |
| `TENANT_ADMIN_USER` | Tenant admin username | `camel` |
| `TENANT_ADMIN_PASS` | Tenant admin password | `camel` |
> **Warning:** Change all default passwords before running in production. The defaults (`admin`/`admin`, `camel`/`camel`) are for development only.
### The Services
The platform runs as a Docker Compose stack with these services:
| Service | Purpose | Dev Port |
|---------|---------|----------|
| **traefik** | Reverse proxy and TLS termination | 80, 443 |
| **postgres** | Database for platform data, Logto, and cameleer3-server | 5432 |
| **logto** | Identity provider (OIDC/SSO) | 3001, 3002 |
| **logto-bootstrap** | One-time setup (runs and exits) | -- |
| **cameleer-saas** | SaaS API server and frontend | 8080 |
| **cameleer3-server** | Observability backend | 8081 |
| **clickhouse** | Trace, metrics, and log storage | 8123 |
In production mode (`docker compose up`), only ports 80 and 443 are exposed via Traefik. In development mode (`docker compose -f docker-compose.yml -f docker-compose.dev.yml up`), individual service ports are exposed directly for debugging.
### The Bootstrap Process
On first boot, the `logto-bootstrap` container automatically:
1. Waits for Logto and cameleer3-server to be healthy.
2. Creates three Logto applications:
- **Cameleer SaaS** (SPA) -- for the management UI frontend.
- **Cameleer SaaS Backend** (Machine-to-Machine) -- for server-to-Logto API calls.
- **Cameleer Dashboard** (Traditional Web App) -- for cameleer3-server OIDC login.
3. Creates an API resource (`https://api.cameleer.local`) with 10 OAuth2 scopes (see Section 9).
4. Creates organization roles with **API resource scopes** (not standalone org permissions):
- `admin` -- 9 tenant scopes (all except `platform:admin`).
- `member` -- 3 scopes: `apps:deploy`, `observe:read`, `observe:debug`.
5. Creates two users:
- Platform admin (default: `admin` / `admin`) -- has the `admin` org role plus the global `platform-admin` role (which grants `platform:admin` scope).
- Demo user (default: `camel` / `camel`) -- added to the default organization with the `member` role.
6. Creates a Logto organization ("Example Tenant") and assigns both users.
7. Configures cameleer3-server with Logto OIDC settings for dashboard authentication.
8. Writes all generated IDs and secrets to `/data/logto-bootstrap.json` for the SaaS backend to consume.
The bootstrap is idempotent -- re-running it will skip resources that already exist.
### Logto Admin Console Access
For self-hosted deployments, the Logto admin console is available at:
- **Development:** `http://localhost:3002`
- **Production:** Accessible only if you configure a Traefik route for port 3002.
Use the admin console to:
- Manage users and their roles.
- Configure social login connectors (Google, GitHub, etc.).
- View and manage applications and API resources.
- Create additional organizations for new tenants.
### Connecting to an External OIDC Provider
To use an external identity provider instead of Logto's built-in username/password:
1. Open the Logto admin console.
2. Navigate to **Connectors** > **Social** or **Enterprise SSO**.
3. Configure your provider (e.g., Google, Azure AD, Okta).
4. Users can then sign in through the configured provider on the Logto login page.
The Cameleer SaaS application itself does not need any changes -- all identity configuration is handled in Logto.
---
## 11. Troubleshooting
### Login Fails or Redirect Loop
**Symptoms:** Clicking "Sign in with Logto" redirects you in a loop, or you see an error page.
**Possible causes:**
- The Logto endpoint is unreachable. Verify that Logto is running: `docker compose ps logto`.
- The SPA client ID is incorrect. Check that `VITE_LOGTO_CLIENT_ID` (or the auto-configured value from bootstrap) matches the SPA application ID in Logto.
- The redirect URI is not registered. The SPA app in Logto must have your current URL's `/callback` path as an allowed redirect URI. The bootstrap registers `http://localhost/callback`, `http://localhost:8080/callback`, and `http://localhost:5173/callback` by default.
**Resolution:**
1. Check Logto logs: `docker compose logs logto`.
2. Open the Logto admin console and verify the SPA application's redirect URIs.
3. If running on a custom domain, add your callback URL to the SPA application's redirect URI list.
### 401 Errors After Login
**Symptoms:** You log in successfully but API calls return 401 Unauthorized.
**Possible causes:**
- Token audience mismatch. The backend expects tokens issued for `https://api.cameleer.local`.
- The Logto issuer URI configured in the backend does not match the actual Logto endpoint.
- Clock skew between the Logto container and the backend container.
**Resolution:**
1. Check backend logs: `docker compose logs cameleer-saas`.
2. Verify that `LOGTO_ISSUER_URI` and `LOGTO_JWK_SET_URI` in `.env` are correct.
3. If the issue persists, restart the services: `docker compose restart cameleer-saas logto`.
### Deployment Stuck in BUILDING
**Symptoms:** A deployment stays in `BUILDING` status indefinitely.
**Possible causes:**
- The Docker socket is not mounted. The `cameleer-saas` container needs access to `/var/run/docker.sock` to build images and create containers.
- The base runtime image is not available. The platform builds on top of `cameleer-runtime-base`.
- Insufficient disk space for the Docker image build.
**Resolution:**
1. Check backend logs: `docker compose logs cameleer-saas`.
2. Verify Docker socket access: `docker compose exec cameleer-saas ls -la /var/run/docker.sock`.
3. Pull the runtime base image manually: `docker pull gitea.siegeln.net/cameleer/cameleer-runtime-base:latest`.
4. Check available disk space: `docker system df`.
### Agent Not Connecting to Server
**Symptoms:** The app is running but the Agent Status card shows "Not registered."
**Possible causes:**
- The agent cannot reach the cameleer3-server endpoint. Check network connectivity between the deployed container and the observability server.
- The bootstrap token does not match. The agent uses `CAMELEER_AUTH_TOKEN` to register with the server.
- The cameleer3-server is not healthy.
**Resolution:**
1. Check cameleer3-server health: `docker compose logs cameleer3-server`.
2. Verify the app container's logs for agent connection errors (use the Logs tab on the app detail page).
3. Confirm that `CAMELEER_AUTH_TOKEN` is the same in both the `cameleer-saas` and `cameleer3-server` service configurations.
### Container Health Check Failing
**Symptoms:** A deployed container shows as unhealthy or keeps restarting.
**Possible causes:**
- The Camel application fails to start (missing dependencies, configuration errors).
- The application requires environment variables that are not set.
- The container runs out of memory (default limit is 512 MB).
**Resolution:**
1. Check the container logs from the Logs tab on the app detail page.
2. If the app crashes immediately, verify the JAR file is a valid executable Spring Boot or Camel application.
3. To increase memory limits, set `CAMELEER_CONTAINER_MEMORY_LIMIT` to a higher value (e.g., `1g`) in `.env` and restart the stack.
### Bootstrap Script Errors
**Symptoms:** The `logto-bootstrap` container exits with an error, and the `cameleer-saas` service fails to start.
**Possible causes:**
- PostgreSQL is not ready when the bootstrap runs. This is unusual because the bootstrap waits for Logto, which itself waits for PostgreSQL.
- Network issues between containers.
- Logto database is in an inconsistent state.
**Resolution:**
1. Check bootstrap logs: `docker compose logs logto-bootstrap`.
2. If the error is transient, re-run the bootstrap: `docker compose restart logto-bootstrap`.
3. For a fresh start, remove all volumes and restart:
```bash
docker compose down -v
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
```
> **Warning:** The command above (`docker compose down -v`) destroys all data including databases. Only use it if you want a clean slate.

20
pom.xml
View File

@@ -80,26 +80,6 @@
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Docker Java client -->
<dependency>
<groupId>com.github.docker-java</groupId>
<artifactId>docker-java-core</artifactId>
<version>3.4.1</version>
</dependency>
<dependency>
<groupId>com.github.docker-java</groupId>
<artifactId>docker-java-transport-httpclient5</artifactId>
<version>3.4.1</version>
</dependency>
<!-- ClickHouse JDBC -->
<dependency>
<groupId>com.clickhouse</groupId>
<artifactId>clickhouse-jdbc</artifactId>
<version>0.7.1</version>
<classifier>all</classifier>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>

View File

@@ -1,130 +0,0 @@
package net.siegeln.cameleer.saas.app;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.siegeln.cameleer.saas.app.dto.AppResponse;
import net.siegeln.cameleer.saas.app.dto.CreateAppRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/environments/{environmentId}/apps")
public class AppController {
private final AppService appService;
private final ObjectMapper objectMapper;
public AppController(AppService appService, ObjectMapper objectMapper) {
this.appService = appService;
this.objectMapper = objectMapper;
}
@PostMapping(consumes = "multipart/form-data")
public ResponseEntity<AppResponse> create(
@PathVariable UUID environmentId,
@RequestPart("metadata") String metadataJson,
@RequestPart("file") MultipartFile file,
Authentication authentication) {
try {
var request = objectMapper.readValue(metadataJson, CreateAppRequest.class);
UUID actorId = resolveActorId(authentication);
var entity = appService.create(environmentId, request.slug(), request.displayName(), file, actorId);
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(entity));
} catch (IllegalArgumentException e) {
var msg = e.getMessage();
if (msg != null && (msg.contains("already exists") || msg.contains("slug"))) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
return ResponseEntity.badRequest().build();
} catch (IllegalStateException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
} catch (IOException e) {
return ResponseEntity.badRequest().build();
}
}
@GetMapping
public ResponseEntity<List<AppResponse>> list(@PathVariable UUID environmentId) {
var apps = appService.listByEnvironmentId(environmentId)
.stream()
.map(this::toResponse)
.toList();
return ResponseEntity.ok(apps);
}
@GetMapping("/{appId}")
public ResponseEntity<AppResponse> getById(
@PathVariable UUID environmentId,
@PathVariable UUID appId) {
return appService.getById(appId)
.map(entity -> ResponseEntity.ok(toResponse(entity)))
.orElse(ResponseEntity.notFound().build());
}
@PutMapping(value = "/{appId}/jar", consumes = "multipart/form-data")
public ResponseEntity<AppResponse> reuploadJar(
@PathVariable UUID environmentId,
@PathVariable UUID appId,
@RequestPart("file") MultipartFile file,
Authentication authentication) {
try {
UUID actorId = resolveActorId(authentication);
var entity = appService.reuploadJar(appId, file, actorId);
return ResponseEntity.ok(toResponse(entity));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@DeleteMapping("/{appId}")
public ResponseEntity<Void> delete(
@PathVariable UUID environmentId,
@PathVariable UUID appId,
Authentication authentication) {
try {
UUID actorId = resolveActorId(authentication);
appService.delete(appId, actorId);
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
private UUID resolveActorId(Authentication authentication) {
String sub = authentication.getName();
try {
return UUID.fromString(sub);
} catch (IllegalArgumentException e) {
return UUID.nameUUIDFromBytes(sub.getBytes());
}
}
private AppResponse toResponse(AppEntity entity) {
return new AppResponse(
entity.getId(),
entity.getEnvironmentId(),
entity.getSlug(),
entity.getDisplayName(),
entity.getJarOriginalFilename(),
entity.getJarSizeBytes(),
entity.getJarChecksum(),
entity.getCurrentDeploymentId(),
entity.getPreviousDeploymentId(),
entity.getCreatedAt(),
entity.getUpdatedAt()
);
}
}

View File

@@ -1,81 +0,0 @@
package net.siegeln.cameleer.saas.app;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "apps")
public class AppEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "environment_id", nullable = false)
private UUID environmentId;
@Column(nullable = false, length = 100)
private String slug;
@Column(name = "display_name", nullable = false)
private String displayName;
@Column(name = "jar_storage_path", length = 500)
private String jarStoragePath;
@Column(name = "jar_checksum", length = 64)
private String jarChecksum;
@Column(name = "jar_original_filename")
private String jarOriginalFilename;
@Column(name = "jar_size_bytes")
private Long jarSizeBytes;
@Column(name = "current_deployment_id")
private UUID currentDeploymentId;
@Column(name = "previous_deployment_id")
private UUID previousDeploymentId;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@PrePersist
protected void onCreate() {
createdAt = Instant.now();
updatedAt = Instant.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = Instant.now();
}
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public UUID getEnvironmentId() { return environmentId; }
public void setEnvironmentId(UUID environmentId) { this.environmentId = environmentId; }
public String getSlug() { return slug; }
public void setSlug(String slug) { this.slug = slug; }
public String getDisplayName() { return displayName; }
public void setDisplayName(String displayName) { this.displayName = displayName; }
public String getJarStoragePath() { return jarStoragePath; }
public void setJarStoragePath(String jarStoragePath) { this.jarStoragePath = jarStoragePath; }
public String getJarChecksum() { return jarChecksum; }
public void setJarChecksum(String jarChecksum) { this.jarChecksum = jarChecksum; }
public String getJarOriginalFilename() { return jarOriginalFilename; }
public void setJarOriginalFilename(String jarOriginalFilename) { this.jarOriginalFilename = jarOriginalFilename; }
public Long getJarSizeBytes() { return jarSizeBytes; }
public void setJarSizeBytes(Long jarSizeBytes) { this.jarSizeBytes = jarSizeBytes; }
public UUID getCurrentDeploymentId() { return currentDeploymentId; }
public void setCurrentDeploymentId(UUID currentDeploymentId) { this.currentDeploymentId = currentDeploymentId; }
public UUID getPreviousDeploymentId() { return previousDeploymentId; }
public void setPreviousDeploymentId(UUID previousDeploymentId) { this.previousDeploymentId = previousDeploymentId; }
public Instant getCreatedAt() { return createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
}

View File

@@ -1,24 +0,0 @@
package net.siegeln.cameleer.saas.app;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface AppRepository extends JpaRepository<AppEntity, UUID> {
List<AppEntity> findByEnvironmentId(UUID environmentId);
Optional<AppEntity> findByEnvironmentIdAndSlug(UUID environmentId, String slug);
boolean existsByEnvironmentIdAndSlug(UUID environmentId, String slug);
@Query("SELECT COUNT(a) FROM AppEntity a JOIN EnvironmentEntity e ON a.environmentId = e.id WHERE e.tenantId = :tenantId")
long countByTenantId(UUID tenantId);
long countByEnvironmentId(UUID environmentId);
}

View File

@@ -1,161 +0,0 @@
package net.siegeln.cameleer.saas.app;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
import net.siegeln.cameleer.saas.license.LicenseDefaults;
import net.siegeln.cameleer.saas.license.LicenseRepository;
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
import net.siegeln.cameleer.saas.tenant.Tier;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Service
public class AppService {
private final AppRepository appRepository;
private final EnvironmentRepository environmentRepository;
private final LicenseRepository licenseRepository;
private final AuditService auditService;
private final RuntimeConfig runtimeConfig;
public AppService(AppRepository appRepository,
EnvironmentRepository environmentRepository,
LicenseRepository licenseRepository,
AuditService auditService,
RuntimeConfig runtimeConfig) {
this.appRepository = appRepository;
this.environmentRepository = environmentRepository;
this.licenseRepository = licenseRepository;
this.auditService = auditService;
this.runtimeConfig = runtimeConfig;
}
public AppEntity create(UUID envId, String slug, String displayName, MultipartFile jarFile, UUID actorId) {
validateJarFile(jarFile);
var env = environmentRepository.findById(envId)
.orElseThrow(() -> new IllegalArgumentException("Environment not found: " + envId));
if (appRepository.existsByEnvironmentIdAndSlug(envId, slug)) {
throw new IllegalArgumentException("App slug already exists in this environment: " + slug);
}
var tenantId = env.getTenantId();
enforceTierLimit(tenantId);
var relativePath = "tenants/" + tenantId + "/envs/" + env.getSlug() + "/apps/" + slug + "/app.jar";
var checksum = storeJar(jarFile, relativePath);
var entity = new AppEntity();
entity.setEnvironmentId(envId);
entity.setSlug(slug);
entity.setDisplayName(displayName);
entity.setJarStoragePath(relativePath);
entity.setJarChecksum(checksum);
entity.setJarOriginalFilename(jarFile.getOriginalFilename());
entity.setJarSizeBytes(jarFile.getSize());
var saved = appRepository.save(entity);
auditService.log(actorId, null, tenantId,
AuditAction.APP_CREATE, slug,
null, null, "SUCCESS", null);
return saved;
}
public AppEntity reuploadJar(UUID appId, MultipartFile jarFile, UUID actorId) {
validateJarFile(jarFile);
var entity = appRepository.findById(appId)
.orElseThrow(() -> new IllegalArgumentException("App not found: " + appId));
var checksum = storeJar(jarFile, entity.getJarStoragePath());
entity.setJarChecksum(checksum);
entity.setJarOriginalFilename(jarFile.getOriginalFilename());
entity.setJarSizeBytes(jarFile.getSize());
return appRepository.save(entity);
}
public List<AppEntity> listByEnvironmentId(UUID envId) {
return appRepository.findByEnvironmentId(envId);
}
public Optional<AppEntity> getById(UUID id) {
return appRepository.findById(id);
}
public void delete(UUID appId, UUID actorId) {
var entity = appRepository.findById(appId)
.orElseThrow(() -> new IllegalArgumentException("App not found: " + appId));
appRepository.delete(entity);
var env = environmentRepository.findById(entity.getEnvironmentId()).orElse(null);
var tenantId = env != null ? env.getTenantId() : null;
auditService.log(actorId, null, tenantId,
AuditAction.APP_DELETE, entity.getSlug(),
null, null, "SUCCESS", null);
}
public Path resolveJarPath(String relativePath) {
return Path.of(runtimeConfig.getJarStoragePath()).resolve(relativePath);
}
private void validateJarFile(MultipartFile jarFile) {
var filename = jarFile.getOriginalFilename();
if (filename == null || !filename.toLowerCase().endsWith(".jar")) {
throw new IllegalArgumentException("File must be a .jar file");
}
if (jarFile.getSize() > runtimeConfig.getMaxJarSize()) {
throw new IllegalArgumentException("JAR file exceeds maximum allowed size");
}
}
private String storeJar(MultipartFile file, String relativePath) {
try {
var targetPath = resolveJarPath(relativePath);
Files.createDirectories(targetPath.getParent());
Files.copy(file.getInputStream(), targetPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
return computeSha256(file);
} catch (IOException e) {
throw new IllegalStateException("Failed to store JAR file", e);
}
}
private String computeSha256(MultipartFile file) {
try {
var digest = MessageDigest.getInstance("SHA-256");
var hash = digest.digest(file.getBytes());
return HexFormat.of().formatHex(hash);
} catch (NoSuchAlgorithmException | IOException e) {
throw new IllegalStateException("Failed to compute SHA-256 checksum", e);
}
}
private void enforceTierLimit(UUID tenantId) {
var license = licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId);
if (license.isEmpty()) {
throw new IllegalStateException("No active license");
}
var limits = LicenseDefaults.limitsForTier(Tier.valueOf(license.get().getTier()));
var maxApps = (int) limits.getOrDefault("max_agents", 3);
if (maxApps != -1 && appRepository.countByTenantId(tenantId) >= maxApps) {
throw new IllegalStateException("App limit reached for current tier");
}
}
}

View File

@@ -1,11 +0,0 @@
package net.siegeln.cameleer.saas.app.dto;
import java.time.Instant;
import java.util.UUID;
public record AppResponse(
UUID id, UUID environmentId, String slug, String displayName,
String jarOriginalFilename, Long jarSizeBytes, String jarChecksum,
UUID currentDeploymentId, UUID previousDeploymentId,
Instant createdAt, Instant updatedAt
) {}

View File

@@ -1,13 +0,0 @@
package net.siegeln.cameleer.saas.app.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public record CreateAppRequest(
@NotBlank @Size(min = 2, max = 100)
@Pattern(regexp = "^[a-z0-9][a-z0-9-]*[a-z0-9]$", message = "Slug must be lowercase alphanumeric with hyphens")
String slug,
@NotBlank @Size(max = 255)
String displayName
) {}

View File

@@ -1,60 +0,0 @@
package net.siegeln.cameleer.saas.auth;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
public JwtAuthenticationFilter(JwtService jwtService) {
this.jwtService = jwtService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
if (!jwtService.isTokenValid(token)) {
filterChain.doFilter(request, response);
return;
}
String email = jwtService.extractEmail(token);
var userId = jwtService.extractUserId(token);
var roles = jwtService.extractRoles(token);
var authorities = roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.toList();
var authentication = new UsernamePasswordAuthenticationToken(
email, userId, authorities
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
}
}

View File

@@ -1,120 +0,0 @@
package net.siegeln.cameleer.saas.auth;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.siegeln.cameleer.saas.config.JwtConfig;
import org.springframework.stereotype.Service;
import java.security.Signature;
import java.time.Instant;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
public class JwtService {
private final JwtConfig jwtConfig;
private final ObjectMapper objectMapper;
public JwtService(JwtConfig jwtConfig) {
this.jwtConfig = jwtConfig;
this.objectMapper = new ObjectMapper();
}
public String generateToken(UserEntity user) {
try {
String header = base64UrlEncode(objectMapper.writeValueAsBytes(
Map.of("alg", "EdDSA", "typ", "JWT")
));
Instant now = Instant.now();
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("sub", user.getEmail());
payload.put("uid", user.getId().toString());
payload.put("name", user.getName());
payload.put("roles", user.getRoles().stream()
.map(RoleEntity::getName)
.toList());
payload.put("iat", now.getEpochSecond());
payload.put("exp", now.getEpochSecond() + jwtConfig.getExpirationSeconds());
String payloadEncoded = base64UrlEncode(objectMapper.writeValueAsBytes(payload));
String signingInput = header + "." + payloadEncoded;
Signature sig = Signature.getInstance("Ed25519");
sig.initSign(jwtConfig.getPrivateKey());
sig.update(signingInput.getBytes(java.nio.charset.StandardCharsets.UTF_8));
String signature = base64UrlEncode(sig.sign());
return signingInput + "." + signature;
} catch (Exception e) {
throw new RuntimeException("Failed to generate JWT", e);
}
}
public String extractEmail(String token) {
Map<String, Object> payload = parsePayload(token);
return (String) payload.get("sub");
}
public UUID extractUserId(String token) {
Map<String, Object> payload = parsePayload(token);
return UUID.fromString((String) payload.get("uid"));
}
@SuppressWarnings("unchecked")
public Set<String> extractRoles(String token) {
Map<String, Object> payload = parsePayload(token);
List<String> roles = (List<String>) payload.get("roles");
return roles.stream().collect(Collectors.toSet());
}
public boolean isTokenValid(String token) {
try {
String[] parts = token.split("\\.");
if (parts.length != 3) {
return false;
}
String signingInput = parts[0] + "." + parts[1];
byte[] signatureBytes = base64UrlDecode(parts[2]);
Signature sig = Signature.getInstance("Ed25519");
sig.initVerify(jwtConfig.getPublicKey());
sig.update(signingInput.getBytes(java.nio.charset.StandardCharsets.UTF_8));
if (!sig.verify(signatureBytes)) {
return false;
}
Map<String, Object> payload = parsePayload(token);
long exp = ((Number) payload.get("exp")).longValue();
return Instant.now().getEpochSecond() < exp;
} catch (Exception e) {
return false;
}
}
private Map<String, Object> parsePayload(String token) {
try {
String[] parts = token.split("\\.");
byte[] payloadBytes = base64UrlDecode(parts[1]);
return objectMapper.readValue(payloadBytes, new TypeReference<>() {});
} catch (Exception e) {
throw new RuntimeException("Failed to parse JWT payload", e);
}
}
private String base64UrlEncode(byte[] data) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(data);
}
private byte[] base64UrlDecode(String data) {
return Base64.getUrlDecoder().decode(data);
}
}

View File

@@ -1,45 +0,0 @@
package net.siegeln.cameleer.saas.auth;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.util.UUID;
@Entity
@Table(name = "permissions")
public class PermissionEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "name", nullable = false, unique = true, length = 100)
private String name;
@Column(name = "description")
private String description;
public UUID getId() {
return id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
}

View File

@@ -1,94 +0,0 @@
package net.siegeln.cameleer.saas.auth;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
@Entity
@Table(name = "roles")
public class RoleEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "name", nullable = false, unique = true, length = 50)
private String name;
@Column(name = "description")
private String description;
@Column(name = "built_in", nullable = false)
private boolean builtIn;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "role_permissions",
joinColumns = @JoinColumn(name = "role_id"),
inverseJoinColumns = @JoinColumn(name = "permission_id")
)
private Set<PermissionEntity> permissions = new HashSet<>();
@PrePersist
protected void onCreate() {
if (createdAt == null) {
createdAt = Instant.now();
}
}
public UUID getId() {
return id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public boolean isBuiltIn() {
return builtIn;
}
public void setBuiltIn(boolean builtIn) {
this.builtIn = builtIn;
}
public Instant getCreatedAt() {
return createdAt;
}
public Set<PermissionEntity> getPermissions() {
return permissions;
}
public void setPermissions(Set<PermissionEntity> permissions) {
this.permissions = permissions;
}
}

View File

@@ -1,13 +0,0 @@
package net.siegeln.cameleer.saas.auth;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface RoleRepository extends JpaRepository<RoleEntity, UUID> {
Optional<RoleEntity> findByName(String name);
}

View File

@@ -1,122 +0,0 @@
package net.siegeln.cameleer.saas.auth;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
@Entity
@Table(name = "users")
public class UserEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "email", nullable = false, unique = true)
private String email;
@Column(name = "password", nullable = false)
private String password;
@Column(name = "name", nullable = false)
private String name;
@Column(name = "status", nullable = false, length = 20)
private String status = "ACTIVE";
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
private Set<RoleEntity> roles = new HashSet<>();
@PrePersist
protected void onCreate() {
Instant now = Instant.now();
if (createdAt == null) {
createdAt = now;
}
if (updatedAt == null) {
updatedAt = now;
}
}
@PreUpdate
protected void onUpdate() {
updatedAt = Instant.now();
}
public UUID getId() {
return id;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public Instant getCreatedAt() {
return createdAt;
}
public Instant getUpdatedAt() {
return updatedAt;
}
public Set<RoleEntity> getRoles() {
return roles;
}
public void setRoles(Set<RoleEntity> roles) {
this.roles = roles;
}
}

View File

@@ -1,15 +0,0 @@
package net.siegeln.cameleer.saas.auth;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface UserRepository extends JpaRepository<UserEntity, UUID> {
Optional<UserEntity> findByEmail(String email);
boolean existsByEmail(String email);
}

View File

@@ -1,32 +1,9 @@
package net.siegeln.cameleer.saas.config;
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
@EnableAsync
public class AsyncConfig {
private final RuntimeConfig runtimeConfig;
public AsyncConfig(RuntimeConfig runtimeConfig) {
this.runtimeConfig = runtimeConfig;
}
@Bean(name = "deploymentExecutor")
public Executor deploymentExecutor() {
var executor = new ThreadPoolTaskExecutor();
// Core == max: no burst threads. Deployments beyond pool size queue (up to 25).
executor.setCorePoolSize(runtimeConfig.getDeploymentThreadPoolSize());
executor.setMaxPoolSize(runtimeConfig.getDeploymentThreadPoolSize());
executor.setQueueCapacity(25);
executor.setThreadNamePrefix("deploy-");
executor.initialize();
return executor;
}
}

View File

@@ -0,0 +1,106 @@
package net.siegeln.cameleer.saas.config;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import net.siegeln.cameleer.saas.tenant.TenantRepository;
import net.siegeln.cameleer.saas.tenant.TenantStatus;
import net.siegeln.cameleer.saas.tenant.Tier;
import net.siegeln.cameleer.saas.license.LicenseEntity;
import net.siegeln.cameleer.saas.license.LicenseRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
import java.io.File;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Map;
@Component
public class BootstrapDataSeeder implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(BootstrapDataSeeder.class);
private static final String BOOTSTRAP_FILE = "/data/bootstrap/logto-bootstrap.json";
private final TenantRepository tenantRepository;
private final LicenseRepository licenseRepository;
private final ObjectMapper objectMapper = new ObjectMapper();
public BootstrapDataSeeder(TenantRepository tenantRepository,
LicenseRepository licenseRepository) {
this.tenantRepository = tenantRepository;
this.licenseRepository = licenseRepository;
}
@Override
public void run(ApplicationArguments args) {
File file = new File(BOOTSTRAP_FILE);
if (!file.exists()) {
log.info("No bootstrap file found at {} — skipping data seeding", BOOTSTRAP_FILE);
return;
}
try {
JsonNode config = objectMapper.readTree(file);
String orgId = getField(config, "organizationId");
String tenantName = getField(config, "tenantName");
String tenantSlug = getField(config, "tenantSlug");
if (orgId == null || tenantSlug == null) {
log.info("Bootstrap file missing organizationId or tenantSlug — skipping");
return;
}
// Check if tenant already exists
if (tenantRepository.existsBySlug(tenantSlug)) {
log.info("Tenant '{}' already exists — skipping bootstrap seeding", tenantSlug);
return;
}
log.info("Seeding bootstrap tenant '{}'...", tenantSlug);
// Create tenant
TenantEntity tenant = new TenantEntity();
tenant.setName(tenantName != null ? tenantName : "Example Tenant");
tenant.setSlug(tenantSlug);
tenant.setTier(Tier.LOW);
tenant.setStatus(TenantStatus.ACTIVE);
tenant.setLogtoOrgId(orgId);
tenant = tenantRepository.save(tenant);
log.info("Created tenant: {} ({})", tenant.getSlug(), tenant.getId());
// Create license
LicenseEntity license = new LicenseEntity();
license.setTenantId(tenant.getId());
license.setTier("LOW");
license.setFeatures(Map.of(
"topology", true,
"lineage", false,
"correlation", false,
"debugger", false,
"replay", false
));
license.setLimits(Map.of(
"max_agents", 3,
"retention_days", 7,
"max_environments", 1
));
license.setIssuedAt(Instant.now());
license.setExpiresAt(Instant.now().plus(365, ChronoUnit.DAYS));
license.setToken("bootstrap-license");
licenseRepository.save(license);
log.info("Created license for tenant '{}'", tenantSlug);
log.info("Bootstrap data seeding complete.");
} catch (Exception e) {
log.error("Failed to seed bootstrap data: {}", e.getMessage(), e);
}
}
private String getField(JsonNode node, String field) {
return node.has(field) ? node.get(field).asText() : null;
}
}

View File

@@ -1,43 +0,0 @@
package net.siegeln.cameleer.saas.config;
import net.siegeln.cameleer.saas.auth.JwtService;
import net.siegeln.cameleer.saas.tenant.TenantService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import jakarta.servlet.http.HttpServletRequest;
@RestController
public class ForwardAuthController {
private final JwtService jwtService;
private final TenantService tenantService;
public ForwardAuthController(JwtService jwtService, TenantService tenantService) {
this.jwtService = jwtService;
this.tenantService = tenantService;
}
@GetMapping("/auth/verify")
public ResponseEntity<Void> verify(HttpServletRequest request) {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
return ResponseEntity.status(401).build();
}
String token = authHeader.substring(7);
if (jwtService.isTokenValid(token)) {
String email = jwtService.extractEmail(token);
var userId = jwtService.extractUserId(token);
return ResponseEntity.ok()
.header("X-User-Id", userId.toString())
.header("X-User-Email", email)
.build();
}
return ResponseEntity.status(401).build();
}
}

View File

@@ -1,82 +0,0 @@
package net.siegeln.cameleer.saas.config;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
@Component
public class JwtConfig {
private static final Logger log = LoggerFactory.getLogger(JwtConfig.class);
@Value("${cameleer.jwt.expiration:86400}")
private long expirationSeconds = 86400;
@Value("${cameleer.jwt.private-key-path:}")
private String privateKeyPath = "";
@Value("${cameleer.jwt.public-key-path:}")
private String publicKeyPath = "";
private KeyPair keyPair;
@PostConstruct
public void init() throws NoSuchAlgorithmException, IOException, InvalidKeySpecException {
if (privateKeyPath.isEmpty() || publicKeyPath.isEmpty()) {
log.warn("No Ed25519 key files configured — generating ephemeral keys (dev mode)");
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("Ed25519");
this.keyPair = keyGen.generateKeyPair();
} else {
log.info("Loading Ed25519 keys from {} and {}", privateKeyPath, publicKeyPath);
PrivateKey privateKey = loadPrivateKey(Path.of(privateKeyPath));
PublicKey publicKey = loadPublicKey(Path.of(publicKeyPath));
this.keyPair = new KeyPair(publicKey, privateKey);
}
}
private PrivateKey loadPrivateKey(Path path) throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
String pem = Files.readString(path)
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s+", "");
byte[] decoded = Base64.getDecoder().decode(pem);
return KeyFactory.getInstance("Ed25519").generatePrivate(new PKCS8EncodedKeySpec(decoded));
}
private PublicKey loadPublicKey(Path path) throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
String pem = Files.readString(path)
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replaceAll("\\s+", "");
byte[] decoded = Base64.getDecoder().decode(pem);
return KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(decoded));
}
public PrivateKey getPrivateKey() {
return keyPair.getPrivate();
}
public PublicKey getPublicKey() {
return keyPair.getPublic();
}
public long getExpirationSeconds() {
return expirationSeconds;
}
}

View File

@@ -0,0 +1,68 @@
package net.siegeln.cameleer.saas.config;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.tenant.TenantService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@RestController
public class MeController {
private final TenantService tenantService;
private final LogtoManagementClient logtoClient;
public MeController(TenantService tenantService, LogtoManagementClient logtoClient) {
this.tenantService = tenantService;
this.logtoClient = logtoClient;
}
@GetMapping("/api/me")
public ResponseEntity<Map<String, Object>> me(Authentication authentication) {
if (!(authentication instanceof JwtAuthenticationToken jwtAuth)) {
return ResponseEntity.status(401).build();
}
Jwt jwt = jwtAuth.getToken();
String userId = jwt.getSubject();
String orgId = jwt.getClaimAsString("organization_id");
if (orgId != null) {
var tenant = tenantService.getByLogtoOrgId(orgId).orElse(null);
List<Map<String, Object>> tenants = tenant != null
? List.of(Map.<String, Object>of(
"id", tenant.getId().toString(),
"name", tenant.getName(),
"slug", tenant.getSlug(),
"logtoOrgId", tenant.getLogtoOrgId()))
: List.of();
return ResponseEntity.ok(Map.of(
"userId", userId,
"tenants", tenants));
}
List<Map<String, String>> logtoOrgs = logtoClient.getUserOrganizations(userId);
List<Map<String, Object>> tenants = logtoOrgs.stream()
.map(org -> tenantService.getByLogtoOrgId(org.get("id"))
.map(t -> Map.<String, Object>of(
"id", t.getId().toString(),
"name", t.getName(),
"slug", t.getSlug(),
"logtoOrgId", t.getLogtoOrgId()))
.orElse(null))
.filter(t -> t != null)
.toList();
return ResponseEntity.ok(Map.of(
"userId", userId,
"tenants", tenants));
}
}

View File

@@ -0,0 +1,83 @@
package net.siegeln.cameleer.saas.config;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.File;
import java.util.List;
import java.util.Map;
@RestController
public class PublicConfigController {
private static final Logger log = LoggerFactory.getLogger(PublicConfigController.class);
private static final String BOOTSTRAP_FILE = "/data/bootstrap/logto-bootstrap.json";
@Value("${cameleer.identity.logto-public-endpoint:${cameleer.identity.logto-endpoint:}}")
private String logtoPublicEndpoint;
@Value("${cameleer.identity.spa-client-id:}")
private String spaClientId;
private final ObjectMapper objectMapper = new ObjectMapper();
private static final List<String> SCOPES = List.of(
"platform:admin",
"tenant:manage",
"billing:manage",
"team:manage",
"apps:manage",
"apps:deploy",
"secrets:manage",
"observe:read",
"observe:debug",
"settings:manage",
"server:admin",
"server:operator",
"server:viewer"
);
@GetMapping("/api/config")
public Map<String, Object> config() {
JsonNode bootstrap = readBootstrapFile();
String clientId = spaClientId;
if (clientId == null || clientId.isEmpty()) {
clientId = bootstrap != null && bootstrap.has("spaClientId")
? bootstrap.get("spaClientId").asText() : "";
}
String apiResource = bootstrap != null && bootstrap.has("apiResourceIndicator")
? bootstrap.get("apiResourceIndicator").asText() : "";
// Use public endpoint for browser redirects (not Docker-internal URL)
String endpoint = logtoPublicEndpoint;
if (endpoint == null || endpoint.isEmpty()) {
endpoint = "http://localhost:3001";
}
return Map.of(
"logtoEndpoint", endpoint,
"logtoClientId", clientId != null ? clientId : "",
"logtoResource", apiResource,
"scopes", SCOPES
);
}
private JsonNode readBootstrapFile() {
try {
File file = new File(BOOTSTRAP_FILE);
if (file.exists()) {
return objectMapper.readTree(file);
}
} catch (Exception e) {
log.warn("Failed to read bootstrap config: {}", e.getMessage());
}
return null;
}
}

View File

@@ -1,65 +1,101 @@
package net.siegeln.cameleer.saas.config;
import net.siegeln.cameleer.saas.auth.JwtAuthenticationFilter;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtIssuerValidator;
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter machineTokenFilter;
private final TenantResolutionFilter tenantResolutionFilter;
public SecurityConfig(JwtAuthenticationFilter machineTokenFilter, TenantResolutionFilter tenantResolutionFilter) {
this.machineTokenFilter = machineTokenFilter;
this.tenantResolutionFilter = tenantResolutionFilter;
}
@Bean
@Order(1)
public SecurityFilterChain machineAuthFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/agent/**", "/api/license/verify/**")
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.addFilterBefore(machineTokenFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/auth/verify").permitAll()
.requestMatchers("/api/config").permitAll()
.requestMatchers("/", "/index.html", "/login", "/callback",
"/environments/**", "/license", "/admin/**").permitAll()
.requestMatchers("/_app/**", "/favicon.ico", "/favicon.svg", "/logo.svg", "/logo-dark.svg").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> {}))
.addFilterBefore(machineTokenFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(tenantResolutionFilter, BearerTokenAuthenticationFilter.class);
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt ->
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())));
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
public JwtAuthenticationConverter jwtAuthenticationConverter() {
var converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
List<GrantedAuthority> authorities = new ArrayList<>();
String scope = jwt.getClaimAsString("scope");
if (scope != null) {
for (String s : scope.split(" ")) {
if (!s.isBlank()) {
authorities.add(new SimpleGrantedAuthority("SCOPE_" + s));
}
}
}
return authorities;
});
return converter;
}
@Bean
@ConditionalOnMissingBean
public JwtDecoder jwtDecoder(
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwkSetUri,
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri:}") String issuerUri,
@Value("${cameleer.identity.audience:}") String audience) throws Exception {
var jwkSource = JWKSourceBuilder.create(new URL(jwkSetUri)).build();
var keySelector = new JWSVerificationKeySelector<SecurityContext>(
JWSAlgorithm.ES384, jwkSource);
var processor = new DefaultJWTProcessor<SecurityContext>();
processor.setJWSKeySelector(keySelector);
processor.setJWSTypeVerifier((type, context) -> { /* accept JWT and at+jwt */ });
var decoder = new NimbusJwtDecoder(processor);
var validators = new ArrayList<OAuth2TokenValidator<Jwt>>();
validators.add(new JwtTimestampValidator());
if (issuerUri != null && !issuerUri.isEmpty()) {
validators.add(new JwtIssuerValidator(issuerUri));
}
if (audience != null && !audience.isEmpty()) {
validators.add(new JwtClaimValidator<List<String>>("aud", aud -> aud != null && aud.contains(audience)));
}
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(validators));
return decoder;
}
}

View File

@@ -0,0 +1,13 @@
package net.siegeln.cameleer.saas.config;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class SpaController {
@GetMapping(value = {"/", "/login", "/callback", "/environments/**", "/license"})
public String spa() {
return "forward:/index.html";
}
}

View File

@@ -0,0 +1,78 @@
package net.siegeln.cameleer.saas.config;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import net.siegeln.cameleer.saas.tenant.TenantService;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.HandlerMapping;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.Map;
import java.util.UUID;
/**
* Interceptor handling tenant resolution (JWT org_id to TenantContext)
* and tenant isolation (path variable validation). Fail-closed: any endpoint with
* {tenantId} in its path is automatically isolated.
* Platform admins (SCOPE_platform:admin) bypass all isolation checks.
*/
@Component
public class TenantIsolationInterceptor implements HandlerInterceptor {
private final TenantService tenantService;
public TenantIsolationInterceptor(TenantService tenantService) {
this.tenantService = tenantService;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
var authentication = SecurityContextHolder.getContext().getAuthentication();
if (!(authentication instanceof JwtAuthenticationToken jwtAuth)) return true;
// 1. Resolve: JWT organization_id -> TenantContext
Jwt jwt = jwtAuth.getToken();
String orgId = jwt.getClaimAsString("organization_id");
if (orgId != null) {
tenantService.getByLogtoOrgId(orgId)
.ifPresent(tenant -> TenantContext.setTenantId(tenant.getId()));
}
// 2. Validate: read path variables from Spring's HandlerMapping
@SuppressWarnings("unchecked")
Map<String, String> pathVars = (Map<String, String>) request.getAttribute(
HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
if (pathVars == null || pathVars.isEmpty()) return true;
UUID resolvedTenantId = TenantContext.getTenantId();
boolean isPlatformAdmin = jwtAuth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("SCOPE_platform:admin"));
if (isPlatformAdmin) return true;
// Check tenantId in path (e.g., /api/tenants/{tenantId}/license)
if (pathVars.containsKey("tenantId")) {
if (resolvedTenantId == null) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "No organization context");
return false;
}
UUID pathTenantId = UUID.fromString(pathVars.get("tenantId"));
if (!pathTenantId.equals(resolvedTenantId)) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Tenant mismatch");
return false;
}
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
TenantContext.clear();
}
}

View File

@@ -1,47 +0,0 @@
package net.siegeln.cameleer.saas.config;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import net.siegeln.cameleer.saas.tenant.TenantService;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
public class TenantResolutionFilter extends OncePerRequestFilter {
private final TenantService tenantService;
public TenantResolutionFilter(TenantService tenantService) {
this.tenantService = tenantService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
var authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication instanceof JwtAuthenticationToken jwtAuth) {
Jwt jwt = jwtAuth.getToken();
String orgId = jwt.getClaimAsString("organization_id");
if (orgId != null) {
tenantService.getByLogtoOrgId(orgId)
.ifPresent(tenant -> TenantContext.setTenantId(tenant.getId()));
}
}
filterChain.doFilter(request, response);
} finally {
TenantContext.clear();
}
}
}

View File

@@ -0,0 +1,20 @@
package net.siegeln.cameleer.saas.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final TenantIsolationInterceptor tenantIsolationInterceptor;
public WebConfig(TenantIsolationInterceptor tenantIsolationInterceptor) {
this.tenantIsolationInterceptor = tenantIsolationInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tenantIsolationInterceptor).addPathPatterns("/api/**");
}
}

View File

@@ -1,113 +0,0 @@
package net.siegeln.cameleer.saas.deployment;
import net.siegeln.cameleer.saas.deployment.dto.DeploymentResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/apps/{appId}")
public class DeploymentController {
private final DeploymentService deploymentService;
public DeploymentController(DeploymentService deploymentService) {
this.deploymentService = deploymentService;
}
@PostMapping("/deploy")
public ResponseEntity<DeploymentResponse> deploy(
@PathVariable UUID appId,
Authentication authentication) {
try {
UUID actorId = resolveActorId(authentication);
var entity = deploymentService.deploy(appId, actorId);
return ResponseEntity.status(HttpStatus.ACCEPTED).body(toResponse(entity));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
} catch (IllegalStateException e) {
return ResponseEntity.badRequest().build();
}
}
@GetMapping("/deployments")
public ResponseEntity<List<DeploymentResponse>> listDeployments(@PathVariable UUID appId) {
var deployments = deploymentService.listByAppId(appId)
.stream()
.map(this::toResponse)
.toList();
return ResponseEntity.ok(deployments);
}
@GetMapping("/deployments/{deploymentId}")
public ResponseEntity<DeploymentResponse> getDeployment(
@PathVariable UUID appId,
@PathVariable UUID deploymentId) {
return deploymentService.getById(deploymentId)
.map(entity -> ResponseEntity.ok(toResponse(entity)))
.orElse(ResponseEntity.notFound().build());
}
@PostMapping("/stop")
public ResponseEntity<DeploymentResponse> stop(
@PathVariable UUID appId,
Authentication authentication) {
try {
UUID actorId = resolveActorId(authentication);
var entity = deploymentService.stop(appId, actorId);
return ResponseEntity.ok(toResponse(entity));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
} catch (IllegalStateException e) {
return ResponseEntity.badRequest().build();
}
}
@PostMapping("/restart")
public ResponseEntity<DeploymentResponse> restart(
@PathVariable UUID appId,
Authentication authentication) {
try {
UUID actorId = resolveActorId(authentication);
var entity = deploymentService.restart(appId, actorId);
return ResponseEntity.status(HttpStatus.ACCEPTED).body(toResponse(entity));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
} catch (IllegalStateException e) {
return ResponseEntity.badRequest().build();
}
}
private UUID resolveActorId(Authentication authentication) {
String sub = authentication.getName();
try {
return UUID.fromString(sub);
} catch (IllegalArgumentException e) {
return UUID.nameUUIDFromBytes(sub.getBytes());
}
}
private DeploymentResponse toResponse(DeploymentEntity entity) {
return new DeploymentResponse(
entity.getId(),
entity.getAppId(),
entity.getVersion(),
entity.getImageRef(),
entity.getDesiredStatus().name(),
entity.getObservedStatus().name(),
entity.getErrorMessage(),
entity.getOrchestratorMetadata(),
entity.getDeployedAt(),
entity.getStoppedAt(),
entity.getCreatedAt()
);
}
}

View File

@@ -1,88 +0,0 @@
package net.siegeln.cameleer.saas.deployment;
import jakarta.persistence.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
@Entity
@Table(name = "deployments")
public class DeploymentEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "app_id", nullable = false)
private UUID appId;
@Column(nullable = false)
private int version;
@Column(name = "image_ref", nullable = false, length = 500)
private String imageRef;
@Enumerated(EnumType.STRING)
@Column(name = "desired_status", nullable = false, length = 20)
private DesiredStatus desiredStatus = DesiredStatus.RUNNING;
@Enumerated(EnumType.STRING)
@Column(name = "observed_status", nullable = false, length = 20)
private ObservedStatus observedStatus = ObservedStatus.BUILDING;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "orchestrator_metadata")
private Map<String, Object> orchestratorMetadata = Map.of();
@Column(name = "error_message", columnDefinition = "TEXT")
private String errorMessage;
@Column(name = "deployed_at")
private Instant deployedAt;
@Column(name = "stopped_at")
private Instant stoppedAt;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@PrePersist
protected void onCreate() {
createdAt = Instant.now();
}
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public UUID getAppId() { return appId; }
public void setAppId(UUID appId) { this.appId = appId; }
public int getVersion() { return version; }
public void setVersion(int version) { this.version = version; }
public String getImageRef() { return imageRef; }
public void setImageRef(String imageRef) { this.imageRef = imageRef; }
public DesiredStatus getDesiredStatus() { return desiredStatus; }
public void setDesiredStatus(DesiredStatus desiredStatus) { this.desiredStatus = desiredStatus; }
public ObservedStatus getObservedStatus() { return observedStatus; }
public void setObservedStatus(ObservedStatus observedStatus) { this.observedStatus = observedStatus; }
public Map<String, Object> getOrchestratorMetadata() { return orchestratorMetadata; }
public void setOrchestratorMetadata(Map<String, Object> orchestratorMetadata) { this.orchestratorMetadata = orchestratorMetadata; }
public String getErrorMessage() { return errorMessage; }
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
public Instant getDeployedAt() { return deployedAt; }
public void setDeployedAt(Instant deployedAt) { this.deployedAt = deployedAt; }
public Instant getStoppedAt() { return stoppedAt; }
public void setStoppedAt(Instant stoppedAt) { this.stoppedAt = stoppedAt; }
public Instant getCreatedAt() { return createdAt; }
}

View File

@@ -1,19 +0,0 @@
package net.siegeln.cameleer.saas.deployment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface DeploymentRepository extends JpaRepository<DeploymentEntity, UUID> {
List<DeploymentEntity> findByAppIdOrderByVersionDesc(UUID appId);
@Query("SELECT COALESCE(MAX(d.version), 0) FROM DeploymentEntity d WHERE d.appId = :appId")
int findMaxVersionByAppId(UUID appId);
Optional<DeploymentEntity> findByAppIdAndVersion(UUID appId, int version);
}

View File

@@ -1,242 +0,0 @@
package net.siegeln.cameleer.saas.deployment;
import net.siegeln.cameleer.saas.app.AppEntity;
import net.siegeln.cameleer.saas.app.AppRepository;
import net.siegeln.cameleer.saas.app.AppService;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.environment.EnvironmentEntity;
import net.siegeln.cameleer.saas.environment.EnvironmentRepository;
import net.siegeln.cameleer.saas.runtime.BuildImageRequest;
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
import net.siegeln.cameleer.saas.runtime.RuntimeOrchestrator;
import net.siegeln.cameleer.saas.runtime.StartContainerRequest;
import net.siegeln.cameleer.saas.tenant.TenantRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@Service
public class DeploymentService {
private static final Logger log = LoggerFactory.getLogger(DeploymentService.class);
private final DeploymentRepository deploymentRepository;
private final AppRepository appRepository;
private final AppService appService;
private final EnvironmentRepository environmentRepository;
private final TenantRepository tenantRepository;
private final RuntimeOrchestrator runtimeOrchestrator;
private final RuntimeConfig runtimeConfig;
private final AuditService auditService;
public DeploymentService(DeploymentRepository deploymentRepository,
AppRepository appRepository,
AppService appService,
EnvironmentRepository environmentRepository,
TenantRepository tenantRepository,
RuntimeOrchestrator runtimeOrchestrator,
RuntimeConfig runtimeConfig,
AuditService auditService) {
this.deploymentRepository = deploymentRepository;
this.appRepository = appRepository;
this.appService = appService;
this.environmentRepository = environmentRepository;
this.tenantRepository = tenantRepository;
this.runtimeOrchestrator = runtimeOrchestrator;
this.runtimeConfig = runtimeConfig;
this.auditService = auditService;
}
public DeploymentEntity deploy(UUID appId, UUID actorId) {
var app = appRepository.findById(appId)
.orElseThrow(() -> new IllegalArgumentException("App not found: " + appId));
if (app.getJarStoragePath() == null) {
throw new IllegalStateException("App has no JAR uploaded: " + appId);
}
var env = environmentRepository.findById(app.getEnvironmentId())
.orElseThrow(() -> new IllegalArgumentException("Environment not found: " + app.getEnvironmentId()));
int nextVersion = deploymentRepository.findMaxVersionByAppId(appId) + 1;
var imageRef = "cameleer-runtime-" + env.getSlug() + "-" + app.getSlug() + ":v" + nextVersion;
var deployment = new DeploymentEntity();
deployment.setAppId(appId);
deployment.setVersion(nextVersion);
deployment.setImageRef(imageRef);
deployment.setObservedStatus(ObservedStatus.BUILDING);
deployment.setDesiredStatus(DesiredStatus.RUNNING);
var saved = deploymentRepository.save(deployment);
auditService.log(actorId, null, env.getTenantId(),
AuditAction.APP_DEPLOY, app.getSlug(),
env.getSlug(), null, "SUCCESS", null);
executeDeploymentAsync(saved.getId(), app, env);
return saved;
}
@Async("deploymentExecutor")
public void executeDeploymentAsync(UUID deploymentId, AppEntity app, EnvironmentEntity env) {
var deployment = deploymentRepository.findById(deploymentId).orElse(null);
if (deployment == null) {
log.error("Deployment not found for async execution: {}", deploymentId);
return;
}
try {
var jarPath = appService.resolveJarPath(app.getJarStoragePath());
runtimeOrchestrator.buildImage(new BuildImageRequest(
runtimeConfig.getBaseImage(),
jarPath,
deployment.getImageRef()
));
deployment.setObservedStatus(ObservedStatus.STARTING);
deploymentRepository.save(deployment);
var tenant = tenantRepository.findById(env.getTenantId()).orElse(null);
var tenantSlug = tenant != null ? tenant.getSlug() : env.getTenantId().toString();
var containerName = tenantSlug + "-" + env.getSlug() + "-" + app.getSlug();
if (app.getCurrentDeploymentId() != null) {
deploymentRepository.findById(app.getCurrentDeploymentId()).ifPresent(oldDeployment -> {
var oldMetadata = oldDeployment.getOrchestratorMetadata();
if (oldMetadata != null && oldMetadata.containsKey("containerId")) {
var oldContainerId = (String) oldMetadata.get("containerId");
try {
runtimeOrchestrator.stopContainer(oldContainerId);
} catch (Exception e) {
log.warn("Failed to stop old container {}: {}", oldContainerId, e.getMessage());
}
}
});
}
var containerId = runtimeOrchestrator.startContainer(new StartContainerRequest(
deployment.getImageRef(),
containerName,
runtimeConfig.getDockerNetwork(),
Map.of(
"CAMELEER_AUTH_TOKEN", env.getBootstrapToken(),
"CAMELEER_EXPORT_TYPE", "HTTP",
"CAMELEER_EXPORT_ENDPOINT", runtimeConfig.getCameleer3ServerEndpoint(),
"CAMELEER_APPLICATION_ID", app.getSlug(),
"CAMELEER_ENVIRONMENT_ID", env.getSlug(),
"CAMELEER_DISPLAY_NAME", containerName
),
runtimeConfig.parseMemoryLimitBytes(),
runtimeConfig.getContainerCpuShares(),
runtimeConfig.getAgentHealthPort()
));
deployment.setOrchestratorMetadata(Map.of("containerId", containerId));
deploymentRepository.save(deployment);
boolean healthy = waitForHealthy(containerId, runtimeConfig.getHealthCheckTimeout());
var previousDeploymentId = app.getCurrentDeploymentId();
if (healthy) {
deployment.setObservedStatus(ObservedStatus.RUNNING);
deployment.setDeployedAt(Instant.now());
deploymentRepository.save(deployment);
app.setPreviousDeploymentId(previousDeploymentId);
app.setCurrentDeploymentId(deployment.getId());
appRepository.save(app);
} else {
deployment.setObservedStatus(ObservedStatus.FAILED);
deployment.setErrorMessage("Container did not become healthy within timeout");
deploymentRepository.save(deployment);
app.setCurrentDeploymentId(deployment.getId());
appRepository.save(app);
}
} catch (Exception e) {
log.error("Deployment {} failed: {}", deploymentId, e.getMessage(), e);
deployment.setObservedStatus(ObservedStatus.FAILED);
deployment.setErrorMessage(e.getMessage());
deploymentRepository.save(deployment);
}
}
public DeploymentEntity stop(UUID appId, UUID actorId) {
var app = appRepository.findById(appId)
.orElseThrow(() -> new IllegalArgumentException("App not found: " + appId));
if (app.getCurrentDeploymentId() == null) {
throw new IllegalStateException("App has no active deployment: " + appId);
}
var deployment = deploymentRepository.findById(app.getCurrentDeploymentId())
.orElseThrow(() -> new IllegalArgumentException("Deployment not found: " + app.getCurrentDeploymentId()));
var metadata = deployment.getOrchestratorMetadata();
if (metadata != null && metadata.containsKey("containerId")) {
var containerId = (String) metadata.get("containerId");
runtimeOrchestrator.stopContainer(containerId);
}
deployment.setDesiredStatus(DesiredStatus.STOPPED);
deployment.setObservedStatus(ObservedStatus.STOPPED);
deployment.setStoppedAt(Instant.now());
var saved = deploymentRepository.save(deployment);
var env = environmentRepository.findById(app.getEnvironmentId()).orElse(null);
var tenantId = env != null ? env.getTenantId() : null;
auditService.log(actorId, null, tenantId,
AuditAction.APP_STOP, app.getSlug(),
env != null ? env.getSlug() : null, null, "SUCCESS", null);
return saved;
}
public DeploymentEntity restart(UUID appId, UUID actorId) {
stop(appId, actorId);
return deploy(appId, actorId);
}
public List<DeploymentEntity> listByAppId(UUID appId) {
return deploymentRepository.findByAppIdOrderByVersionDesc(appId);
}
public Optional<DeploymentEntity> getById(UUID deploymentId) {
return deploymentRepository.findById(deploymentId);
}
boolean waitForHealthy(String containerId, int timeoutSeconds) {
var deadline = System.currentTimeMillis() + (timeoutSeconds * 1000L);
while (System.currentTimeMillis() < deadline) {
var status = runtimeOrchestrator.getContainerStatus(containerId);
if (!status.running()) {
return false;
}
if ("healthy".equals(status.state())) {
return true;
}
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
return false;
}
}

View File

@@ -1,5 +0,0 @@
package net.siegeln.cameleer.saas.deployment;
public enum DesiredStatus {
RUNNING, STOPPED
}

View File

@@ -1,5 +0,0 @@
package net.siegeln.cameleer.saas.deployment;
public enum ObservedStatus {
BUILDING, STARTING, RUNNING, FAILED, STOPPED
}

View File

@@ -1,12 +0,0 @@
package net.siegeln.cameleer.saas.deployment.dto;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
public record DeploymentResponse(
UUID id, UUID appId, int version, String imageRef,
String desiredStatus, String observedStatus, String errorMessage,
Map<String, Object> orchestratorMetadata,
Instant deployedAt, Instant stoppedAt, Instant createdAt
) {}

View File

@@ -1,117 +0,0 @@
package net.siegeln.cameleer.saas.environment;
import jakarta.validation.Valid;
import net.siegeln.cameleer.saas.environment.dto.CreateEnvironmentRequest;
import net.siegeln.cameleer.saas.environment.dto.EnvironmentResponse;
import net.siegeln.cameleer.saas.environment.dto.UpdateEnvironmentRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/tenants/{tenantId}/environments")
public class EnvironmentController {
private final EnvironmentService environmentService;
public EnvironmentController(EnvironmentService environmentService) {
this.environmentService = environmentService;
}
@PostMapping
public ResponseEntity<EnvironmentResponse> create(
@PathVariable UUID tenantId,
@Valid @RequestBody CreateEnvironmentRequest request,
Authentication authentication) {
try {
UUID actorId = resolveActorId(authentication);
var entity = environmentService.create(tenantId, request.slug(), request.displayName(), actorId);
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(entity));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
} catch (IllegalStateException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
}
@GetMapping
public ResponseEntity<List<EnvironmentResponse>> list(@PathVariable UUID tenantId) {
var environments = environmentService.listByTenantId(tenantId)
.stream()
.map(this::toResponse)
.toList();
return ResponseEntity.ok(environments);
}
@GetMapping("/{environmentId}")
public ResponseEntity<EnvironmentResponse> getById(
@PathVariable UUID tenantId,
@PathVariable UUID environmentId) {
return environmentService.getById(environmentId)
.map(entity -> ResponseEntity.ok(toResponse(entity)))
.orElse(ResponseEntity.notFound().build());
}
@PatchMapping("/{environmentId}")
public ResponseEntity<EnvironmentResponse> update(
@PathVariable UUID tenantId,
@PathVariable UUID environmentId,
@Valid @RequestBody UpdateEnvironmentRequest request,
Authentication authentication) {
try {
UUID actorId = resolveActorId(authentication);
var entity = environmentService.updateDisplayName(environmentId, request.displayName(), actorId);
return ResponseEntity.ok(toResponse(entity));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@DeleteMapping("/{environmentId}")
public ResponseEntity<Void> delete(
@PathVariable UUID tenantId,
@PathVariable UUID environmentId,
Authentication authentication) {
try {
UUID actorId = resolveActorId(authentication);
environmentService.delete(environmentId, actorId);
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
} catch (IllegalStateException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
}
private UUID resolveActorId(Authentication authentication) {
String sub = authentication.getName();
try {
return UUID.fromString(sub);
} catch (IllegalArgumentException e) {
return UUID.nameUUIDFromBytes(sub.getBytes());
}
}
private EnvironmentResponse toResponse(EnvironmentEntity entity) {
return new EnvironmentResponse(
entity.getId(),
entity.getTenantId(),
entity.getSlug(),
entity.getDisplayName(),
entity.getStatus().name(),
entity.getCreatedAt(),
entity.getUpdatedAt()
);
}
}

View File

@@ -1,62 +0,0 @@
package net.siegeln.cameleer.saas.environment;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "environments")
public class EnvironmentEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "tenant_id", nullable = false)
private UUID tenantId;
@Column(nullable = false, length = 100)
private String slug;
@Column(name = "display_name", nullable = false)
private String displayName;
@Column(name = "bootstrap_token", nullable = false, columnDefinition = "TEXT")
private String bootstrapToken;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private EnvironmentStatus status = EnvironmentStatus.ACTIVE;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@PrePersist
protected void onCreate() {
createdAt = Instant.now();
updatedAt = Instant.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = Instant.now();
}
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public UUID getTenantId() { return tenantId; }
public void setTenantId(UUID tenantId) { this.tenantId = tenantId; }
public String getSlug() { return slug; }
public void setSlug(String slug) { this.slug = slug; }
public String getDisplayName() { return displayName; }
public void setDisplayName(String displayName) { this.displayName = displayName; }
public String getBootstrapToken() { return bootstrapToken; }
public void setBootstrapToken(String bootstrapToken) { this.bootstrapToken = bootstrapToken; }
public EnvironmentStatus getStatus() { return status; }
public void setStatus(EnvironmentStatus status) { this.status = status; }
public Instant getCreatedAt() { return createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
}

View File

@@ -1,20 +0,0 @@
package net.siegeln.cameleer.saas.environment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface EnvironmentRepository extends JpaRepository<EnvironmentEntity, UUID> {
List<EnvironmentEntity> findByTenantId(UUID tenantId);
Optional<EnvironmentEntity> findByTenantIdAndSlug(UUID tenantId, String slug);
long countByTenantId(UUID tenantId);
boolean existsByTenantIdAndSlug(UUID tenantId, String slug);
}

View File

@@ -1,109 +0,0 @@
package net.siegeln.cameleer.saas.environment;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.license.LicenseDefaults;
import net.siegeln.cameleer.saas.license.LicenseRepository;
import net.siegeln.cameleer.saas.runtime.RuntimeConfig;
import net.siegeln.cameleer.saas.tenant.Tier;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Service
public class EnvironmentService {
private final EnvironmentRepository environmentRepository;
private final LicenseRepository licenseRepository;
private final AuditService auditService;
private final RuntimeConfig runtimeConfig;
public EnvironmentService(EnvironmentRepository environmentRepository,
LicenseRepository licenseRepository,
AuditService auditService,
RuntimeConfig runtimeConfig) {
this.environmentRepository = environmentRepository;
this.licenseRepository = licenseRepository;
this.auditService = auditService;
this.runtimeConfig = runtimeConfig;
}
public EnvironmentEntity create(UUID tenantId, String slug, String displayName, UUID actorId) {
if (environmentRepository.existsByTenantIdAndSlug(tenantId, slug)) {
throw new IllegalArgumentException("Slug already exists for this tenant: " + slug);
}
enforceTierLimit(tenantId);
var entity = new EnvironmentEntity();
entity.setTenantId(tenantId);
entity.setSlug(slug);
entity.setDisplayName(displayName);
entity.setBootstrapToken(runtimeConfig.getBootstrapToken());
var saved = environmentRepository.save(entity);
auditService.log(actorId, null, tenantId,
AuditAction.ENVIRONMENT_CREATE, slug,
null, null, "SUCCESS", null);
return saved;
}
public EnvironmentEntity createDefaultForTenant(UUID tenantId) {
return environmentRepository.findByTenantIdAndSlug(tenantId, "default")
.orElseGet(() -> create(tenantId, "default", "Default", null));
}
public List<EnvironmentEntity> listByTenantId(UUID tenantId) {
return environmentRepository.findByTenantId(tenantId);
}
public Optional<EnvironmentEntity> getById(UUID id) {
return environmentRepository.findById(id);
}
public EnvironmentEntity updateDisplayName(UUID environmentId, String displayName, UUID actorId) {
var entity = environmentRepository.findById(environmentId)
.orElseThrow(() -> new IllegalArgumentException("Environment not found: " + environmentId));
entity.setDisplayName(displayName);
var saved = environmentRepository.save(entity);
auditService.log(actorId, null, entity.getTenantId(),
AuditAction.ENVIRONMENT_UPDATE, entity.getSlug(),
null, null, "SUCCESS", null);
return saved;
}
public void delete(UUID environmentId, UUID actorId) {
var entity = environmentRepository.findById(environmentId)
.orElseThrow(() -> new IllegalArgumentException("Environment not found: " + environmentId));
if ("default".equals(entity.getSlug())) {
throw new IllegalStateException("Cannot delete the default environment");
}
environmentRepository.delete(entity);
auditService.log(actorId, null, entity.getTenantId(),
AuditAction.ENVIRONMENT_DELETE, entity.getSlug(),
null, null, "SUCCESS", null);
}
private void enforceTierLimit(UUID tenantId) {
var license = licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId);
if (license.isEmpty()) {
throw new IllegalStateException("No active license");
}
var limits = LicenseDefaults.limitsForTier(Tier.valueOf(license.get().getTier()));
var maxEnvs = (int) limits.getOrDefault("max_environments", 1);
var currentCount = environmentRepository.countByTenantId(tenantId);
if (maxEnvs != -1 && currentCount >= maxEnvs) {
throw new IllegalStateException("Environment limit reached for current tier");
}
}
}

View File

@@ -1,5 +0,0 @@
package net.siegeln.cameleer.saas.environment;
public enum EnvironmentStatus {
ACTIVE, SUSPENDED
}

View File

@@ -1,13 +0,0 @@
package net.siegeln.cameleer.saas.environment.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public record CreateEnvironmentRequest(
@NotBlank @Size(min = 2, max = 100)
@Pattern(regexp = "^[a-z0-9][a-z0-9-]*[a-z0-9]$", message = "Slug must be lowercase alphanumeric with hyphens")
String slug,
@NotBlank @Size(max = 255)
String displayName
) {}

View File

@@ -1,14 +0,0 @@
package net.siegeln.cameleer.saas.environment.dto;
import java.time.Instant;
import java.util.UUID;
public record EnvironmentResponse(
UUID id,
UUID tenantId,
String slug,
String displayName,
String status,
Instant createdAt,
Instant updatedAt
) {}

View File

@@ -1,9 +0,0 @@
package net.siegeln.cameleer.saas.environment.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record UpdateEnvironmentRequest(
@NotBlank @Size(max = 255)
String displayName
) {}

View File

@@ -1,11 +1,21 @@
package net.siegeln.cameleer.saas.identity;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import java.io.File;
@Configuration
public class LogtoConfig {
private static final Logger log = LoggerFactory.getLogger(LogtoConfig.class);
private static final String BOOTSTRAP_FILE = "/data/bootstrap/logto-bootstrap.json";
@Value("${cameleer.identity.logto-endpoint:}")
private String logtoEndpoint;
@@ -15,11 +25,39 @@ public class LogtoConfig {
@Value("${cameleer.identity.m2m-client-secret:}")
private String m2mClientSecret;
@Value("${cameleer.identity.server-endpoint:http://cameleer3-server:8081}")
private String serverEndpoint;
@PostConstruct
public void init() {
if (isConfigured()) return;
// Fall back to bootstrap file for M2M credentials
try {
File file = new File(BOOTSTRAP_FILE);
if (file.exists()) {
JsonNode node = new ObjectMapper().readTree(file);
if ((m2mClientId == null || m2mClientId.isEmpty()) && node.has("m2mClientId")) {
m2mClientId = node.get("m2mClientId").asText();
}
if ((m2mClientSecret == null || m2mClientSecret.isEmpty()) && node.has("m2mClientSecret")) {
m2mClientSecret = node.get("m2mClientSecret").asText();
}
log.info("Loaded M2M credentials from bootstrap file");
}
} catch (Exception e) {
log.warn("Failed to read bootstrap config for M2M credentials: {}", e.getMessage());
}
}
public String getLogtoEndpoint() { return logtoEndpoint; }
public String getM2mClientId() { return m2mClientId; }
public String getM2mClientSecret() { return m2mClientSecret; }
public String getServerEndpoint() { return serverEndpoint; }
public boolean isConfigured() {
return !logtoEndpoint.isEmpty() && !m2mClientId.isEmpty() && !m2mClientSecret.isEmpty();
return logtoEndpoint != null && !logtoEndpoint.isEmpty()
&& m2mClientId != null && !m2mClientId.isEmpty()
&& m2mClientSecret != null && !m2mClientSecret.isEmpty();
}
}

View File

@@ -8,6 +8,8 @@ import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Service
@@ -73,6 +75,34 @@ public class LogtoManagementClient {
.toBodilessEntity();
}
public List<Map<String, String>> getUserOrganizations(String userId) {
if (!isAvailable()) return List.of();
try {
var response = restClient.get()
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/organizations")
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.body(JsonNode.class);
List<Map<String, String>> orgs = new ArrayList<>();
if (response != null && response.isArray()) {
for (var node : response) {
orgs.add(Map.of(
"id", node.get("id").asText(),
"name", node.get("name").asText()
));
}
}
return orgs;
} catch (Exception e) {
log.warn("Failed to get user organizations for {}: {}", userId, e.getMessage());
return List.of();
}
}
private static final String MGMT_API_RESOURCE = "https://default.logto.app/api";
private synchronized String getAccessToken() {
if (cachedToken != null && Instant.now().isBefore(tokenExpiry.minusSeconds(60))) {
return cachedToken;
@@ -85,7 +115,7 @@ public class LogtoManagementClient {
.body("grant_type=client_credentials"
+ "&client_id=" + config.getM2mClientId()
+ "&client_secret=" + config.getM2mClientSecret()
+ "&resource=" + config.getLogtoEndpoint() + "/api"
+ "&resource=" + MGMT_API_RESOURCE
+ "&scope=all")
.retrieve()
.body(JsonNode.class);

View File

@@ -0,0 +1,115 @@
package net.siegeln.cameleer.saas.identity;
import com.fasterxml.jackson.databind.JsonNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.time.Instant;
import java.util.Map;
/**
* Authenticated client for cameleer3-server API calls.
* Uses Logto M2M client_credentials grant with the Cameleer API resource.
*/
@Service
public class ServerApiClient {
private static final Logger log = LoggerFactory.getLogger(ServerApiClient.class);
private static final String API_RESOURCE = "https://api.cameleer.local";
private final LogtoConfig config;
private final RestClient tokenClient;
private volatile String cachedToken;
private volatile Instant tokenExpiry = Instant.MIN;
public ServerApiClient(LogtoConfig config) {
this.config = config;
this.tokenClient = RestClient.builder().build();
}
public boolean isAvailable() {
return config.isConfigured();
}
/**
* Returns a RestClient pre-configured with server base URL and auth headers for GET requests.
*/
public RestClient.RequestHeadersSpec<?> get(String uri) {
return RestClient.create(config.getServerEndpoint())
.get()
.uri(uri)
.header("Authorization", "Bearer " + getAccessToken())
.header("X-Cameleer-Protocol-Version", "1");
}
/**
* Returns a RestClient pre-configured with server base URL and auth headers for POST requests.
*/
public RestClient.RequestBodySpec post(String uri) {
return RestClient.create(config.getServerEndpoint())
.post()
.uri(uri)
.header("Authorization", "Bearer " + getAccessToken())
.header("X-Cameleer-Protocol-Version", "1");
}
/**
* Push a license token to a server instance.
*/
public void pushLicense(String serverEndpoint, String licenseToken) {
RestClient.create(serverEndpoint)
.post()
.uri("/api/v1/admin/license")
.header("Authorization", "Bearer " + getAccessToken())
.header("X-Cameleer-Protocol-Version", "1")
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("token", licenseToken))
.retrieve()
.toBodilessEntity();
}
/**
* Check server health.
*/
@SuppressWarnings("unchecked")
public Map<String, Object> getHealth(String serverEndpoint) {
return RestClient.create(serverEndpoint)
.get()
.uri("/api/v1/health")
.retrieve()
.body(Map.class);
}
private synchronized String getAccessToken() {
if (cachedToken != null && Instant.now().isBefore(tokenExpiry.minusSeconds(60))) {
return cachedToken;
}
try {
var response = tokenClient.post()
.uri(config.getLogtoEndpoint() + "/oidc/token")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body("grant_type=client_credentials"
+ "&client_id=" + config.getM2mClientId()
+ "&client_secret=" + config.getM2mClientSecret()
+ "&resource=" + API_RESOURCE
+ "&scope=server:admin")
.retrieve()
.body(JsonNode.class);
cachedToken = response.get("access_token").asText();
long expiresIn = response.get("expires_in").asLong();
tokenExpiry = Instant.now().plusSeconds(expiresIn);
log.info("Acquired cameleer3-server M2M access token");
return cachedToken;
} catch (Exception e) {
log.error("Failed to get server API M2M token", e);
throw new RuntimeException("Server API authentication failed", e);
}
}
}

View File

@@ -4,6 +4,7 @@ import net.siegeln.cameleer.saas.license.dto.LicenseResponse;
import net.siegeln.cameleer.saas.tenant.TenantService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -27,6 +28,7 @@ public class LicenseController {
}
@PostMapping
@PreAuthorize("hasAuthority('SCOPE_billing:manage')")
public ResponseEntity<LicenseResponse> generate(@PathVariable UUID tenantId,
Authentication authentication) {
var tenant = tenantService.getById(tenantId).orElse(null);

View File

@@ -11,4 +11,5 @@ import java.util.UUID;
public interface LicenseRepository extends JpaRepository<LicenseEntity, UUID> {
List<LicenseEntity> findByTenantIdOrderByCreatedAtDesc(UUID tenantId);
Optional<LicenseEntity> findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(UUID tenantId);
Optional<LicenseEntity> findByToken(String token);
}

View File

@@ -1,18 +1,12 @@
package net.siegeln.cameleer.saas.license;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.config.JwtConfig;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import org.springframework.stereotype.Service;
import java.security.Signature;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@@ -21,13 +15,10 @@ import java.util.UUID;
public class LicenseService {
private final LicenseRepository licenseRepository;
private final JwtConfig jwtConfig;
private final AuditService auditService;
private final ObjectMapper objectMapper = new ObjectMapper();
public LicenseService(LicenseRepository licenseRepository, JwtConfig jwtConfig, AuditService auditService) {
public LicenseService(LicenseRepository licenseRepository, AuditService auditService) {
this.licenseRepository = licenseRepository;
this.jwtConfig = jwtConfig;
this.auditService = auditService;
}
@@ -37,7 +28,7 @@ public class LicenseService {
Instant now = Instant.now();
Instant expiresAt = now.plus(validity);
String token = signLicenseJwt(tenant.getId(), tenant.getTier().name(), features, limits, now, expiresAt);
String token = UUID.randomUUID().toString();
var entity = new LicenseEntity();
entity.setTenantId(tenant.getId());
@@ -61,61 +52,20 @@ public class LicenseService {
return licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId);
}
/**
* Verifies a license token by checking its existence and validity in the database.
* Returns the license entity's metadata as a map if found and not expired/revoked,
* or empty if the token is unknown or invalid.
*/
public Optional<Map<String, Object>> verifyLicenseToken(String token) {
try {
String[] parts = token.split("\\.");
if (parts.length != 3) return Optional.empty();
String signingInput = parts[0] + "." + parts[1];
byte[] signatureBytes = Base64.getUrlDecoder().decode(parts[2]);
Signature sig = Signature.getInstance("Ed25519");
sig.initVerify(jwtConfig.getPublicKey());
sig.update(signingInput.getBytes(java.nio.charset.StandardCharsets.UTF_8));
if (!sig.verify(signatureBytes)) return Optional.empty();
byte[] payloadBytes = Base64.getUrlDecoder().decode(parts[1]);
Map<String, Object> payload = objectMapper.readValue(payloadBytes, new TypeReference<>() {});
long exp = ((Number) payload.get("exp")).longValue();
if (Instant.now().getEpochSecond() >= exp) return Optional.empty();
return Optional.of(payload);
} catch (Exception e) {
return Optional.empty();
}
}
private String signLicenseJwt(UUID tenantId, String tier, Map<String, Object> features,
Map<String, Object> limits, Instant issuedAt, Instant expiresAt) {
try {
String header = base64UrlEncode(objectMapper.writeValueAsBytes(
Map.of("alg", "EdDSA", "typ", "JWT", "kid", "license")));
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("tenant_id", tenantId.toString());
payload.put("tier", tier);
payload.put("features", features);
payload.put("limits", limits);
payload.put("iat", issuedAt.getEpochSecond());
payload.put("exp", expiresAt.getEpochSecond());
String payloadEncoded = base64UrlEncode(objectMapper.writeValueAsBytes(payload));
String signingInput = header + "." + payloadEncoded;
Signature sig = Signature.getInstance("Ed25519");
sig.initSign(jwtConfig.getPrivateKey());
sig.update(signingInput.getBytes(java.nio.charset.StandardCharsets.UTF_8));
String signature = base64UrlEncode(sig.sign());
return signingInput + "." + signature;
} catch (Exception e) {
throw new RuntimeException("Failed to sign license JWT", e);
}
}
private String base64UrlEncode(byte[] data) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(data);
return licenseRepository.findByToken(token)
.filter(e -> e.getRevokedAt() == null)
.filter(e -> e.getExpiresAt() == null || Instant.now().isBefore(e.getExpiresAt()))
.map(e -> Map.<String, Object>of(
"tenant_id", e.getTenantId().toString(),
"tier", e.getTier(),
"features", e.getFeatures(),
"limits", e.getLimits()
));
}
}

View File

@@ -1,23 +0,0 @@
package net.siegeln.cameleer.saas.log;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import com.clickhouse.jdbc.ClickHouseDataSource;
import java.util.Properties;
@Configuration
@Profile("!test")
public class ClickHouseConfig {
@Value("${cameleer.clickhouse.url:jdbc:clickhouse://clickhouse:8123/cameleer}")
private String url;
@Bean(name = "clickHouseDataSource")
public ClickHouseDataSource clickHouseDataSource() throws Exception {
var properties = new Properties();
return new ClickHouseDataSource(url, properties);
}
}

View File

@@ -1,137 +0,0 @@
package net.siegeln.cameleer.saas.log;
import net.siegeln.cameleer.saas.log.dto.LogEntry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import javax.sql.DataSource;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ConcurrentLinkedQueue;
@Service
public class ContainerLogService {
private static final Logger log = LoggerFactory.getLogger(ContainerLogService.class);
private static final int FLUSH_THRESHOLD = 100;
private final DataSource clickHouseDataSource;
private final ConcurrentLinkedQueue<Object[]> buffer = new ConcurrentLinkedQueue<>();
@Autowired
public ContainerLogService(
@Autowired(required = false) @Qualifier("clickHouseDataSource") DataSource clickHouseDataSource) {
this.clickHouseDataSource = clickHouseDataSource;
if (clickHouseDataSource == null) {
log.warn("ClickHouse data source not available — ContainerLogService running in no-op mode");
} else {
initSchema();
}
}
void initSchema() {
if (clickHouseDataSource == null) return;
try (var conn = clickHouseDataSource.getConnection();
var stmt = conn.createStatement()) {
stmt.execute("""
CREATE TABLE IF NOT EXISTS container_logs (
tenant_id UUID,
environment_id UUID,
app_id UUID,
deployment_id UUID,
timestamp DateTime64(3),
stream String,
message String
) ENGINE = MergeTree()
ORDER BY (tenant_id, environment_id, app_id, timestamp)
""");
} catch (Exception e) {
log.error("Failed to initialize ClickHouse schema", e);
}
}
public void write(UUID tenantId, UUID envId, UUID appId, UUID deploymentId,
String stream, String message, long timestampMillis) {
if (clickHouseDataSource == null) return;
buffer.add(new Object[]{tenantId, envId, appId, deploymentId, timestampMillis, stream, message});
if (buffer.size() >= FLUSH_THRESHOLD) {
flush();
}
}
public void flush() {
if (clickHouseDataSource == null || buffer.isEmpty()) return;
List<Object[]> batch = new ArrayList<>(FLUSH_THRESHOLD);
Object[] row;
while ((row = buffer.poll()) != null) {
batch.add(row);
}
if (batch.isEmpty()) return;
String sql = "INSERT INTO container_logs (tenant_id, environment_id, app_id, deployment_id, timestamp, stream, message) VALUES (?, ?, ?, ?, ?, ?, ?)";
try (var conn = clickHouseDataSource.getConnection();
var ps = conn.prepareStatement(sql)) {
for (Object[] entry : batch) {
ps.setObject(1, entry[0]); // tenant_id
ps.setObject(2, entry[1]); // environment_id
ps.setObject(3, entry[2]); // app_id
ps.setObject(4, entry[3]); // deployment_id
ps.setTimestamp(5, new Timestamp((Long) entry[4]));
ps.setString(6, (String) entry[5]);
ps.setString(7, (String) entry[6]);
ps.addBatch();
}
ps.executeBatch();
} catch (Exception e) {
log.error("Failed to flush log batch to ClickHouse ({} entries)", batch.size(), e);
}
}
public List<LogEntry> query(UUID appId, Instant since, Instant until, int limit, String stream) {
if (clickHouseDataSource == null) return List.of();
StringBuilder sql = new StringBuilder(
"SELECT app_id, deployment_id, timestamp, stream, message FROM container_logs WHERE app_id = ?");
List<Object> params = new ArrayList<>();
params.add(appId);
if (since != null) {
sql.append(" AND timestamp >= ?");
params.add(Timestamp.from(since));
}
if (until != null) {
sql.append(" AND timestamp <= ?");
params.add(Timestamp.from(until));
}
if (stream != null && !"both".equalsIgnoreCase(stream)) {
sql.append(" AND stream = ?");
params.add(stream);
}
sql.append(" ORDER BY timestamp LIMIT ?");
params.add(limit);
List<LogEntry> results = new ArrayList<>();
try (var conn = clickHouseDataSource.getConnection();
var ps = conn.prepareStatement(sql.toString())) {
for (int i = 0; i < params.size(); i++) {
ps.setObject(i + 1, params.get(i));
}
try (var rs = ps.executeQuery()) {
while (rs.next()) {
results.add(new LogEntry(
UUID.fromString(rs.getString("app_id")),
UUID.fromString(rs.getString("deployment_id")),
rs.getTimestamp("timestamp").toInstant(),
rs.getString("stream"),
rs.getString("message")
));
}
}
} catch (Exception e) {
log.error("Failed to query container logs for appId={}", appId, e);
}
return results;
}
}

View File

@@ -1,36 +0,0 @@
package net.siegeln.cameleer.saas.log;
import net.siegeln.cameleer.saas.log.dto.LogEntry;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/apps/{appId}/logs")
public class LogController {
private final ContainerLogService containerLogService;
public LogController(ContainerLogService containerLogService) {
this.containerLogService = containerLogService;
}
@GetMapping
public ResponseEntity<List<LogEntry>> query(
@PathVariable UUID appId,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant since,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant until,
@RequestParam(defaultValue = "500") int limit,
@RequestParam(defaultValue = "both") String stream) {
List<LogEntry> entries = containerLogService.query(appId, since, until, limit, stream);
return ResponseEntity.ok(entries);
}
}

View File

@@ -1,8 +0,0 @@
package net.siegeln.cameleer.saas.log.dto;
import java.time.Instant;
import java.util.UUID;
public record LogEntry(
UUID appId, UUID deploymentId, Instant timestamp, String stream, String message
) {}

View File

@@ -1,9 +0,0 @@
package net.siegeln.cameleer.saas.runtime;
import java.nio.file.Path;
public record BuildImageRequest(
String baseImage,
Path jarPath,
String imageTag
) {}

View File

@@ -1,8 +0,0 @@
package net.siegeln.cameleer.saas.runtime;
public record ContainerStatus(
String state,
boolean running,
int exitCode,
String error
) {}

View File

@@ -1,167 +0,0 @@
package net.siegeln.cameleer.saas.runtime;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.async.ResultCallback;
import com.github.dockerjava.api.command.BuildImageResultCallback;
import com.github.dockerjava.api.model.*;
import com.github.dockerjava.core.DefaultDockerClientConfig;
import com.github.dockerjava.core.DockerClientImpl;
import com.github.dockerjava.httpclient5.ApacheDockerHttpClient;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
@Component
public class DockerRuntimeOrchestrator implements RuntimeOrchestrator {
private static final Logger log = LoggerFactory.getLogger(DockerRuntimeOrchestrator.class);
private DockerClient dockerClient;
@PostConstruct
public void init() {
var config = DefaultDockerClientConfig.createDefaultConfigBuilder().build();
var httpClient = new ApacheDockerHttpClient.Builder()
.dockerHost(config.getDockerHost())
.build();
dockerClient = DockerClientImpl.getInstance(config, httpClient);
log.info("Docker client initialized, host: {}", config.getDockerHost());
}
@PreDestroy
public void close() throws IOException {
if (dockerClient != null) {
dockerClient.close();
}
}
@Override
public String buildImage(BuildImageRequest request) {
Path buildDir = null;
try {
buildDir = Files.createTempDirectory("cameleer-build-");
var dockerfile = buildDir.resolve("Dockerfile");
Files.writeString(dockerfile,
"FROM " + request.baseImage() + "\nCOPY app.jar /app/app.jar\n");
Files.copy(request.jarPath(), buildDir.resolve("app.jar"), StandardCopyOption.REPLACE_EXISTING);
var imageId = dockerClient.buildImageCmd(buildDir.toFile())
.withTags(Set.of(request.imageTag()))
.exec(new BuildImageResultCallback())
.awaitImageId();
log.info("Built image {} -> {}", request.imageTag(), imageId);
return imageId;
} catch (IOException e) {
throw new RuntimeException("Failed to build image: " + e.getMessage(), e);
} finally {
if (buildDir != null) {
deleteDirectory(buildDir);
}
}
}
@Override
public String startContainer(StartContainerRequest request) {
var envList = request.envVars().entrySet().stream()
.map(e -> e.getKey() + "=" + e.getValue())
.toList();
var hostConfig = HostConfig.newHostConfig()
.withMemory(request.memoryLimitBytes())
.withMemorySwap(request.memoryLimitBytes())
.withCpuShares(request.cpuShares())
.withNetworkMode(request.network());
var container = dockerClient.createContainerCmd(request.imageRef())
.withName(request.containerName())
.withEnv(envList)
.withHostConfig(hostConfig)
.withHealthcheck(new HealthCheck()
.withTest(List.of("CMD-SHELL",
"wget -qO- http://localhost:" + request.healthCheckPort() + "/health || exit 1"))
.withInterval(10_000_000_000L) // 10s
.withTimeout(5_000_000_000L) // 5s
.withRetries(3)
.withStartPeriod(30_000_000_000L)) // 30s
.exec();
dockerClient.startContainerCmd(container.getId()).exec();
log.info("Started container {} ({})", request.containerName(), container.getId());
return container.getId();
}
@Override
public void stopContainer(String containerId) {
try {
dockerClient.stopContainerCmd(containerId).withTimeout(30).exec();
log.info("Stopped container {}", containerId);
} catch (Exception e) {
log.warn("Failed to stop container {}: {}", containerId, e.getMessage());
}
}
@Override
public void removeContainer(String containerId) {
try {
dockerClient.removeContainerCmd(containerId).withForce(true).exec();
log.info("Removed container {}", containerId);
} catch (Exception e) {
log.warn("Failed to remove container {}: {}", containerId, e.getMessage());
}
}
@Override
public ContainerStatus getContainerStatus(String containerId) {
try {
var inspection = dockerClient.inspectContainerCmd(containerId).exec();
var state = inspection.getState();
return new ContainerStatus(
state.getStatus(),
Boolean.TRUE.equals(state.getRunning()),
state.getExitCodeLong() != null ? state.getExitCodeLong().intValue() : 0,
state.getError());
} catch (Exception e) {
return new ContainerStatus("not_found", false, -1, e.getMessage());
}
}
@Override
public void streamLogs(String containerId, LogConsumer consumer) {
dockerClient.logContainerCmd(containerId)
.withStdOut(true)
.withStdErr(true)
.withFollowStream(true)
.withTimestamps(true)
.exec(new ResultCallback.Adapter<Frame>() {
@Override
public void onNext(Frame frame) {
var stream = frame.getStreamType() == StreamType.STDERR ? "stderr" : "stdout";
consumer.accept(stream, new String(frame.getPayload()).trim(),
System.currentTimeMillis());
}
});
}
private void deleteDirectory(Path dir) {
try {
Files.walk(dir)
.sorted(Comparator.reverseOrder())
.map(Path::toFile)
.forEach(File::delete);
} catch (IOException e) {
log.warn("Failed to clean up build directory: {}", dir, e);
}
}
}

View File

@@ -1,6 +0,0 @@
package net.siegeln.cameleer.saas.runtime;
@FunctionalInterface
public interface LogConsumer {
void accept(String stream, String message, long timestampMillis);
}

View File

@@ -1,63 +0,0 @@
package net.siegeln.cameleer.saas.runtime;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class RuntimeConfig {
@Value("${cameleer.runtime.max-jar-size:209715200}")
private long maxJarSize;
@Value("${cameleer.runtime.jar-storage-path:/data/jars}")
private String jarStoragePath;
@Value("${cameleer.runtime.base-image:cameleer-runtime-base:latest}")
private String baseImage;
@Value("${cameleer.runtime.docker-network:cameleer}")
private String dockerNetwork;
@Value("${cameleer.runtime.agent-health-port:9464}")
private int agentHealthPort;
@Value("${cameleer.runtime.health-check-timeout:60}")
private int healthCheckTimeout;
@Value("${cameleer.runtime.deployment-thread-pool-size:4}")
private int deploymentThreadPoolSize;
@Value("${cameleer.runtime.container-memory-limit:512m}")
private String containerMemoryLimit;
@Value("${cameleer.runtime.container-cpu-shares:512}")
private int containerCpuShares;
@Value("${cameleer.runtime.bootstrap-token:${CAMELEER_AUTH_TOKEN:}}")
private String bootstrapToken;
@Value("${cameleer.runtime.cameleer3-server-endpoint:http://cameleer3-server:8081}")
private String cameleer3ServerEndpoint;
public long getMaxJarSize() { return maxJarSize; }
public String getJarStoragePath() { return jarStoragePath; }
public String getBaseImage() { return baseImage; }
public String getDockerNetwork() { return dockerNetwork; }
public int getAgentHealthPort() { return agentHealthPort; }
public int getHealthCheckTimeout() { return healthCheckTimeout; }
public int getDeploymentThreadPoolSize() { return deploymentThreadPoolSize; }
public String getContainerMemoryLimit() { return containerMemoryLimit; }
public int getContainerCpuShares() { return containerCpuShares; }
public String getBootstrapToken() { return bootstrapToken; }
public String getCameleer3ServerEndpoint() { return cameleer3ServerEndpoint; }
public long parseMemoryLimitBytes() {
var limit = containerMemoryLimit.trim().toLowerCase();
if (limit.endsWith("g")) {
return Long.parseLong(limit.substring(0, limit.length() - 1)) * 1024 * 1024 * 1024;
} else if (limit.endsWith("m")) {
return Long.parseLong(limit.substring(0, limit.length() - 1)) * 1024 * 1024;
}
return Long.parseLong(limit);
}
}

View File

@@ -1,10 +0,0 @@
package net.siegeln.cameleer.saas.runtime;
public interface RuntimeOrchestrator {
String buildImage(BuildImageRequest request);
String startContainer(StartContainerRequest request);
void stopContainer(String containerId);
void removeContainer(String containerId);
ContainerStatus getContainerStatus(String containerId);
void streamLogs(String containerId, LogConsumer consumer);
}

View File

@@ -1,13 +0,0 @@
package net.siegeln.cameleer.saas.runtime;
import java.util.Map;
public record StartContainerRequest(
String imageRef,
String containerName,
String network,
Map<String, String> envVars,
long memoryLimitBytes,
int cpuShares,
int healthCheckPort
) {}

View File

@@ -5,6 +5,7 @@ import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
import net.siegeln.cameleer.saas.tenant.dto.TenantResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -13,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.UUID;
@RestController
@@ -25,11 +27,19 @@ public class TenantController {
this.tenantService = tenantService;
}
@GetMapping
@PreAuthorize("hasAuthority('SCOPE_platform:admin')")
public ResponseEntity<List<TenantResponse>> listAll() {
List<TenantResponse> tenants = tenantService.findAll().stream()
.map(this::toResponse).toList();
return ResponseEntity.ok(tenants);
}
@PostMapping
@PreAuthorize("hasAuthority('SCOPE_platform:admin')")
public ResponseEntity<TenantResponse> create(@Valid @RequestBody CreateTenantRequest request,
Authentication authentication) {
try {
// Extract actor ID from JWT subject (Logto OIDC: sub may be a non-UUID string)
String sub = authentication.getName();
UUID actorId;
try {

View File

@@ -2,7 +2,6 @@ package net.siegeln.cameleer.saas.tenant;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.environment.EnvironmentService;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
import org.springframework.stereotype.Service;
@@ -17,13 +16,11 @@ public class TenantService {
private final TenantRepository tenantRepository;
private final AuditService auditService;
private final LogtoManagementClient logtoClient;
private final EnvironmentService environmentService;
public TenantService(TenantRepository tenantRepository, AuditService auditService, LogtoManagementClient logtoClient, EnvironmentService environmentService) {
public TenantService(TenantRepository tenantRepository, AuditService auditService, LogtoManagementClient logtoClient) {
this.tenantRepository = tenantRepository;
this.auditService = auditService;
this.logtoClient = logtoClient;
this.environmentService = environmentService;
}
public TenantEntity create(CreateTenantRequest request, UUID actorId) {
@@ -47,8 +44,6 @@ public class TenantService {
}
}
environmentService.createDefaultForTenant(saved.getId());
auditService.log(actorId, null, saved.getId(),
AuditAction.TENANT_CREATE, saved.getSlug(),
null, null, "SUCCESS", null);
@@ -68,6 +63,10 @@ public class TenantService {
return tenantRepository.findByLogtoOrgId(logtoOrgId);
}
public List<TenantEntity> findAll() {
return tenantRepository.findAll();
}
public List<TenantEntity> listActive() {
return tenantRepository.findByStatus(TenantStatus.ACTIVE);
}

View File

@@ -1,7 +1,3 @@
spring:
datasource:
url: jdbc:postgresql://localhost:5432/cameleer_saas
username: cameleer
password: cameleer_dev
jpa:
show-sql: true
show-sql: false

View File

@@ -0,0 +1,23 @@
## Profile for running outside Docker (mvn spring-boot:run -Dspring-boot.run.profiles=dev,local)
## Overrides docker hostnames with localhost for Postgres, ClickHouse, cameleer3-server
spring:
datasource:
url: jdbc:postgresql://localhost:5432/cameleer_saas
username: cameleer
password: cameleer_dev
flyway:
url: jdbc:postgresql://localhost:5432/cameleer_saas
user: cameleer
password: cameleer_dev
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:3001/oidc
jwk-set-uri: http://localhost:3001/oidc/jwks
cameleer:
clickhouse:
url: jdbc:clickhouse://localhost:8123/cameleer
runtime:
cameleer3-server-endpoint: http://localhost:8081

Some files were not shown because too many files have changed in this diff Show More