364 Commits

Author SHA1 Message Date
hsiegeln
11dd6a354f feat(installer): add PowerShell installer for Windows
Some checks failed
CI / build (push) Successful in 1m24s
CI / docker (push) Failing after 25s
Mirrors install.sh structure and produces identical output files.
Uses native PowerShell idioms for parameters, prompts, and crypto.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:39:24 +02:00
hsiegeln
7f15177310 feat(installer): add main function and complete install.sh
Appends the main() entry point that wires together all installer phases:
arg parsing, config loading, rerun detection, prerequisites, auto-detect,
interactive prompts, config merge/validate, password generation, file
generation, docker pull/up, health verification, and output printing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 16:33:15 +02:00
hsiegeln
b01f6e5109 feat(installer): add re-run, upgrade, and reinstall logic 2026-04-13 16:32:02 +02:00
hsiegeln
8146f072df feat(installer): add output file generation (credentials, INSTALL.md, config) 2026-04-13 16:31:38 +02:00
hsiegeln
f13fd3faf0 feat(installer): add docker operations and health verification 2026-04-13 16:30:53 +02:00
hsiegeln
5e5bc97bf5 feat(installer): add .env and docker-compose.yml generation 2026-04-13 16:30:32 +02:00
hsiegeln
7fc80cad58 feat(installer): add config merge, validation, and password generation 2026-04-13 16:25:34 +02:00
hsiegeln
6eabd0cf2e feat(installer): add interactive prompts for simple and expert modes 2026-04-13 16:25:16 +02:00
hsiegeln
4debee966a feat(installer): add prerequisite checks and auto-detection 2026-04-13 16:24:55 +02:00
hsiegeln
1e348eb8ca feat(installer): add argument parsing and config file handling 2026-04-13 16:24:35 +02:00
hsiegeln
f136502a35 feat(installer): scaffold install.sh with constants and utilities
Creates the installer skeleton (Phase 2, Task 8) with version/registry
constants, color codes, default values, _ENV_* variable capture pattern,
config/state variable declarations, and utility functions (log_*, print_banner,
prompt, prompt_password, prompt_yesno, generate_password).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 16:22:21 +02:00
hsiegeln
bf367b1db7 ci: add builds for cameleer-postgres, cameleer-clickhouse, cameleer-traefik
Update Logto build to use repo root context for bootstrap script access.
2026-04-13 16:20:37 +02:00
hsiegeln
f5165add13 feat: consolidate docker-compose.yml for baked-in images
Remove all bind-mounted config files and init containers. Services
reduced from 7 to 5. All configuration via environment variables.
2026-04-13 16:19:29 +02:00
hsiegeln
ec38d0b1c2 feat: merge bootstrap into cameleer-logto image
Adds logto-entrypoint.sh that seeds DB, starts Logto, waits for health,
runs bootstrap, then keeps Logto running. Eliminates the separate
logto-bootstrap init container.
2026-04-13 16:17:13 +02:00
hsiegeln
6cd82de5f9 fix: update traefik-dynamic.yml cert paths to /certs/
The entrypoint writes certs to /certs/ but the dynamic config
referenced /etc/traefik/certs/. Since both are baked into the image,
align the paths so only one volume mount is needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:15:39 +02:00
hsiegeln
0a0898b2f7 feat: create cameleer-traefik image with cert generation and config baked in
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 16:14:47 +02:00
hsiegeln
6864081550 feat: create cameleer-clickhouse image with init and config baked in
Bakes init.sql, users.xml (with from_env password), and prometheus.xml
into a custom ClickHouse image to eliminate 3 bind-mounted config files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 16:13:06 +02:00
hsiegeln
fe5838b40f feat: create cameleer-postgres image with init script baked in
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 16:12:02 +02:00
hsiegeln
1b57f03973 Add install script implementation plan
18 tasks across 3 phases:
- Phase 1 (Tasks 1-7): Platform image consolidation — bake init
  scripts into cameleer-postgres, cameleer-clickhouse, cameleer-traefik,
  merge bootstrap into cameleer-logto, update compose and CI
- Phase 2 (Tasks 8-17): Bash installer with simple/expert/silent modes,
  config precedence, health verification, idempotent re-run
- Phase 3 (Task 18): PowerShell port for Windows

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:08:50 +02:00
hsiegeln
0a06615ae2 Fix spec self-review issues in install script design
Resolve TBD placeholder (Docker minimum versions), clarify TLS cert
flow after traefik-certs init container merge, note Traefik env var
substitution for dynamic config, and document Docker socket path
differences between Linux and Windows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:38:59 +02:00
hsiegeln
16a2ff3174 Add install script design spec
Defines a professional installer for the Cameleer SaaS platform with
dual native scripts (bash + PowerShell), three installation modes
(simple/expert/silent), and a platform simplification that consolidates
7 services into 5 by baking all init logic into Docker images.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:37:23 +02:00
hsiegeln
c2ccf9d233 feat: enable Prometheus metrics for ClickHouse and tenant servers
Some checks failed
CI / build (push) Successful in 1m46s
CI / docker (push) Successful in 55s
SonarQube Analysis / sonarqube (push) Failing after 1m19s
ClickHouse: enable built-in Prometheus exporter at :9363/metrics via
config.d/prometheus.xml with metrics, events, and async_metrics.
Docker labels added for docker_sd_configs auto-discovery.

Tenant servers: add prometheus.scrape/path/port labels to provisioned
server containers pointing to /api/v1/prometheus:8081.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:24:08 +02:00
hsiegeln
06c85edd8e chore: update design system to 0.1.45 (sidebar version styling)
All checks were successful
CI / build (push) Successful in 1m54s
CI / docker (push) Successful in 1m29s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:28:49 +02:00
hsiegeln
9514ab69c8 fix: update test constructors for ProvisioningProperties arity change
All checks were successful
CI / build (push) Successful in 1m17s
CI / docker (push) Successful in 41s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:48:53 +02:00
hsiegeln
d3a9be8f2e fix: remove vendor-to-tenant-org addition on tenant creation
Some checks failed
CI / build (push) Failing after 50s
CI / docker (push) Has been skipped
Vendor has platform:admin scope globally and manages tenants through the
SaaS console — no need to be a member of each tenant's Logto org.
Removes the step that failed with Logto's varchar(21) user ID limit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:30:56 +02:00
hsiegeln
85e0d6156a fix: remove :ro from clickhouse-users.xml mount
Some checks failed
CI / build (push) Failing after 58s
CI / docker (push) Has been skipped
ClickHouse entrypoint needs write access to resolve from_env attribute
and apply CLICKHOUSE_PASSWORD to the default user config.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:27:35 +02:00
hsiegeln
96aa6579b0 fix: use separate CH credentials, remove dead bootstrap code
Some checks failed
CI / build (push) Failing after 41s
CI / docker (push) Has been skipped
- ClickHouse: pass user/password via ProvisioningProperties instead of
  baking into JDBC URLs. All consumers (InfrastructureService,
  TenantDataCleanupService, DockerTenantProvisioner) use the same source.
- Bootstrap: remove dead tenant config (CAMELEER_AUTH_TOKEN, t-default
  org, example tenant vars) — tenants are created dynamically by vendor.
- Bootstrap JSON: remove unused fields (tenantName, tenantSlug,
  bootstrapToken, tenantAdminUser, organizationId).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:12:42 +02:00
hsiegeln
da4a263cd7 fix: add ClickHouse password authentication
All checks were successful
CI / build (push) Successful in 1m14s
CI / docker (push) Successful in 42s
ClickHouse default user had no password, causing auth failures on recent
CH versions. Set password via from_env in clickhouse-users.xml, pass
credentials in JDBC URLs to SaaS services and tenant server containers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:59:59 +02:00
hsiegeln
879accfc7f fix: allow tenant slug reuse after soft-delete
All checks were successful
CI / build (push) Successful in 1m24s
CI / docker (push) Successful in 44s
Replace absolute UNIQUE constraint on tenants.slug with a partial unique
index that excludes DELETED rows. This allows re-creating a tenant with
the same slug after deletion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:39:13 +02:00
hsiegeln
35a62463b3 docs: document vendor Infrastructure page and env var
Some checks failed
CI / build (push) Successful in 1m58s
CI / docker (push) Successful in 1m21s
SonarQube Analysis / sonarqube (push) Failing after 1m51s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 23:20:06 +02:00
hsiegeln
92503a1061 feat: add vendor infrastructure page with PG/CH per-tenant view
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:18:59 +02:00
hsiegeln
95a92ae9e5 feat: add vendor InfrastructureController for platform:admin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 23:16:18 +02:00
hsiegeln
5aa8586940 feat: add InfrastructureService with PG and CH queries
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 23:15:18 +02:00
hsiegeln
776a01d87b feat: set INFRASTRUCTUREENDPOINTS=false on tenant server containers
Adds CAMELEER_SERVER_SECURITY_INFRASTRUCTUREENDPOINTS=false to the env
var list injected into provisioned tenant server containers, disabling
the Database and ClickHouse admin endpoints (returns 404) on SaaS-
managed instances. The server defaults to true (standalone mode).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 23:13:28 +02:00
hsiegeln
0b736a92f9 docs: update env var references to new naming convention
All checks were successful
CI / build (push) Successful in 1m51s
CI / docker (push) Successful in 19s
architecture.md runtime/deployment section rewritten with correct
CAMELEER_SAAS_PROVISIONING_* and CAMELEER_SERVER_* env vars.
user-manual.md updated container resource env vars and removed
stale CAMELEER_TENANT_SLUG reference. HOWTO.md cleaned up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:56:21 +02:00
hsiegeln
df90814cc3 Update OIDC env vars for server's nested oidc.* grouping
All checks were successful
CI / build (push) Successful in 1m47s
CI / docker (push) Successful in 1m2s
Align DockerTenantProvisioner env vars with the server's new
cameleer.server.security.oidc.* namespace:
  CAMELEER_SERVER_SECURITY_OIDC_ISSUERURI
  CAMELEER_SERVER_SECURITY_OIDC_JWKSETURI
  CAMELEER_SERVER_SECURITY_OIDC_AUDIENCE
  CAMELEER_SERVER_SECURITY_OIDC_TLSSKIPVERIFY

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:30:41 +02:00
hsiegeln
8cf44f6e2c Migrate config to cameleer.saas.* naming convention
All checks were successful
CI / build (push) Successful in 1m49s
CI / docker (push) Successful in 55s
Move all SaaS configuration properties under the cameleer.saas.*
namespace with all-lowercase dot-separated names and mechanical env var
mapping. Aligns with the server (cameleer.server.*) and agent
(cameleer.agent.*) conventions.

Changes:
- Move cameleer.identity.* → cameleer.saas.identity.*
- Move cameleer.provisioning.* → cameleer.saas.provisioning.*
- Move cameleer.certs.* → cameleer.saas.certs.*
- Rename kebab-case properties to concatenated lowercase
- Update all env vars to CAMELEER_SAAS_* mechanical mapping
- Update DockerTenantProvisioner to pass CAMELEER_SERVER_* env vars
  to provisioned server containers (matching server's new convention)
- Spring JWT config now derives from SaaS properties via cross-reference
- Clean up orphaned properties in application-local.yml
- Update docker-compose.yml, docker-compose.dev.yml, .env.example
- Update CLAUDE.md, HOWTO.md, architecture.md, user-manual.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:11:21 +02:00
hsiegeln
5e69628a51 docs: update CLAUDE.md with upgrade, password mgmt, TLS, cleanup
All checks were successful
CI / build (push) Successful in 1m50s
CI / docker (push) Successful in 19s
- VendorTenantService: upgrade server (force-pull + re-provision)
- TenantPortalService: password management, server upgrade
- DockerTenantProvisioner: upgrade(), full cleanup in remove(), GDPR
- Traefik TLS: default cert in dynamic config (v3 compatibility)
- CA trust: server entrypoint imports ca.pem into JVM truststore
- LogtoManagementClient: password updates via correct endpoint
- ServerApiClient: server admin password reset
- UI: tenant dashboard/settings password and upgrade controls

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:07:41 +02:00
hsiegeln
9163f919c8 fix: move TLS default cert config to Traefik dynamic config
All checks were successful
CI / build (push) Successful in 1m17s
CI / docker (push) Successful in 13s
Traefik v3 ignores tls.stores.default in the static config, causing it
to serve its auto-generated fallback cert instead of the platform cert.
Moving the default certificate store to the dynamic config (file
provider) fixes this — Traefik now serves the correct cert and also
picks up cert rotations without a restart.

This was the root cause of OIDC PKIX failures: the server imported the
CA into its JVM truststore, but Traefik was serving a different cert
entirely.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:45:02 +02:00
hsiegeln
3b8b76d53e chore: update @cameleer/design-system to 0.1.44
All checks were successful
CI / build (push) Successful in 1m10s
CI / docker (push) Successful in 55s
Error toasts now persist until manually dismissed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:01:44 +02:00
hsiegeln
e5523c969e fix: use correct Logto endpoint for password updates
All checks were successful
CI / build (push) Successful in 1m12s
CI / docker (push) Successful in 43s
PATCH /api/users/{id}/password, not /api/users/{id}. The general user
update endpoint rejected the password field with 422.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 10:57:28 +02:00
hsiegeln
e2e5c794a2 feat: add server upgrade action — force-pull latest images and re-provision
All checks were successful
CI / build (push) Successful in 1m19s
CI / docker (push) Successful in 48s
Restart only stops/starts existing containers with the same image. The new
upgrade action removes server + UI containers, force-pulls the latest
Docker images, then re-provisions (preserving app containers, volumes, and
networks). Available to both vendor (tenant detail) and tenant admin
(dashboard).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 10:45:45 +02:00
hsiegeln
d5eead888d feat: server admin password reset via tenant portal
All checks were successful
CI / build (push) Successful in 2m23s
CI / docker (push) Successful in 1m8s
- POST /api/tenant/server/admin-password — resets server's built-in
  admin password via M2M API call to the tenant's server
- Settings page: "Server Admin Password" card
- ServerApiClient.resetServerAdminPassword() calls server's password
  reset endpoint with M2M token

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 09:46:30 +02:00
hsiegeln
4121bd64b2 feat: password management for tenant portal
All checks were successful
CI / build (push) Successful in 1m15s
CI / docker (push) Successful in 47s
- POST /api/tenant/password — change own Logto password
- POST /api/tenant/team/{userId}/password — reset team member password
- Settings page: "Change Password" card with confirm field
- Team page: "Reset Password" button per member with inline form
- LogtoManagementClient.updateUserPassword() via Logto Management API

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 09:19:48 +02:00
hsiegeln
dd8553a8b4 feat: full tenant cleanup on delete — Docker resources, PG schema, CH data (#55)
All checks were successful
CI / build (push) Successful in 2m23s
CI / docker (push) Successful in 1m6s
DockerTenantProvisioner.remove() now cleans up all tenant Docker resources:
containers (by cameleer.tenant label), env networks, tenant network, JAR volume.
TenantDataCleanupService drops the tenant's PostgreSQL schema and deletes all
ClickHouse data for GDPR compliance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 09:10:47 +02:00
hsiegeln
3284304c1f fix: remove dead /server/ fallback redirect
All checks were successful
CI / build (push) Successful in 1m14s
CI / docker (push) Successful in 46s
When no org is resolved, redirect to /tenant instead of the
non-existent /server/ path. Fixes login redirect loop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 08:39:14 +02:00
hsiegeln
6f8b84fb1a fix: re-provision containers when restart finds them missing
All checks were successful
CI / build (push) Successful in 1m22s
CI / docker (push) Successful in 39s
When Docker containers have been removed (e.g. manual cleanup or image
update), restart now falls back to full re-provisioning instead of
failing with 404. Applies to both vendor and tenant portal restart.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 08:37:04 +02:00
hsiegeln
d2caa737b9 chore: update @cameleer/design-system to v0.1.43 (FileInput)
All checks were successful
CI / build (push) Successful in 1m10s
CI / docker (push) Successful in 29s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 08:07:39 +02:00
hsiegeln
875b07fb3a feat: use FileInput DS component for file uploads, fix certs volume perms
All checks were successful
CI / build (push) Successful in 1m24s
CI / docker (push) Successful in 1m12s
- Replace inline FileField and native <input type="file"> with
  FileInput from @cameleer/design-system (drag-and-drop, icons, clear)
- Update CertificatesPage and SsoPage to use FileInput + FormField
- Fix /certs volume permissions (chmod 775) so cameleer user can write

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 08:04:47 +02:00
hsiegeln
4fdf171912 fix: don't show stale CA banner when no CA bundle exists
Some checks failed
CI / build (push) Successful in 1m39s
CI / docker (push) Successful in 37s
SonarQube Analysis / sonarqube (push) Failing after 1m44s
The self-signed bootstrap cert has no CA bundle, so newly created tenants
with ca_applied_at=NULL are not actually stale. Skip the count when the
active cert has hasCa=false.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:21:26 +02:00
hsiegeln
2239d3d980 docs: update CLAUDE.md and HOWTO.md for fleet health and recent changes
All checks were successful
CI / build (push) Successful in 2m13s
CI / docker (push) Successful in 11s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:10:11 +02:00
hsiegeln
8eef7e170b feat: show agent/env counts in vendor tenant list
All checks were successful
CI / build (push) Successful in 1m20s
CI / docker (push) Successful in 48s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:02:46 +02:00
hsiegeln
d7ce0aaf8c feat: add agent/env counts to vendor tenant list endpoint
Extend VendorTenantSummary with agentCount, environmentCount, and
agentLimit fields. Fetch counts in parallel using CompletableFuture
per tenant, only calling server API for ACTIVE tenants with RUNNING
servers. Agent limit extracted from license limits JSONB.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:01:02 +02:00
hsiegeln
a0c12b8ee6 chore: update DS to v0.1.42
All checks were successful
CI / build (push) Successful in 2m3s
CI / docker (push) Successful in 1m14s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:02:32 +02:00
hsiegeln
a5445e332e fix: fetch actual agent/environment counts from server for tenant dashboard
All checks were successful
CI / build (push) Successful in 1m8s
CI / docker (push) Successful in 43s
The dashboard was showing hardcoded zeroes for agent and environment usage.
Now fetches real counts via M2M API from the tenant's server.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:35:24 +02:00
hsiegeln
cab6e409b9 fix: show public endpoint instead of internal Docker URL in tenant settings
All checks were successful
CI / build (push) Successful in 1m21s
CI / docker (push) Successful in 38s
Closes #51

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:29:54 +02:00
hsiegeln
0fe084bcb2 fix: restrict key.pem file permissions to 0600 (owner-only)
All checks were successful
CI / build (push) Successful in 1m6s
CI / docker (push) Successful in 34s
All private key writes now use writeAtomicRestricted which sets POSIX
owner-read/write permissions after writing. Gracefully skips on
non-POSIX filesystems (Windows dev).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:49:07 +02:00
hsiegeln
3ae8fa18cd feat: support password-protected private keys
All checks were successful
CI / build (push) Successful in 1m8s
CI / docker (push) Successful in 42s
Encrypted PKCS#8 private keys are decrypted during staging using the
provided password. The decrypted key is stored for Traefik (which needs
cleartext PEM). Unencrypted keys continue to work without a password.

- CertificateManager.stage() accepts optional keyPassword
- DockerCertificateManager handles EncryptedPrivateKeyInfo decryption
- UI: password field in upload form (vendor CertificatesPage)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:44:09 +02:00
hsiegeln
82f62ca0ff docs: add tenant CA cert management to CLAUDE.md and HOWTO.md
All checks were successful
CI / build (push) Successful in 1m8s
CI / docker (push) Successful in 12s
- TenantCaCertEntity, TenantCaCertRepository, TenantCaCertService
- TenantPortalController CA endpoints
- V013 migration
- Tenant portal API reference updated

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:36:51 +02:00
hsiegeln
dd30ee77d4 feat: tenant CA certificate management with staging
Some checks failed
CI / build (push) Successful in 1m7s
CI / docker (push) Has been cancelled
Tenants can upload multiple CA certificates for enterprise SSO providers
that use private certificate authorities.

- New tenant_ca_certs table (V013) with PEM storage in DB
- Stage/activate/delete lifecycle per CA cert
- Aggregated ca.pem rebuild on activate/delete (atomic .wip swap)
- REST API: GET/POST/DELETE on /api/tenant/ca
- UI: CA Certificates section on SSO page with upload, activate, remove

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:35:04 +02:00
hsiegeln
a3a6f99958 fix: prevent vendor redirect to /tenant on hard refresh
All checks were successful
CI / build (push) Successful in 1m6s
CI / docker (push) Successful in 42s
RequireScope and LandingRedirect now wait for scopesReady flag before
evaluating, preventing the race where org-scoped tokens load before
global tokens and the vendor gets incorrectly redirected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:16:46 +02:00
hsiegeln
22752ffcb1 fix: polish CertificatesPage layout
All checks were successful
CI / build (push) Successful in 1m7s
CI / docker (push) Successful in 44s
- Truncate fingerprint with hover tooltip
- Remove duplicate warning icon in stale banner
- Style file inputs to match design system
- Bump grid min-width for better card spacing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:04:16 +02:00
hsiegeln
a48c4bfd08 docs: update CLAUDE.md and HOWTO.md for all session changes
All checks were successful
CI / build (push) Successful in 1m5s
CI / docker (push) Successful in 9s
- Certificate management (provider interface, lifecycle, bootstrap, UI)
- Async tenant provisioning with polling UX
- Server restart capability (vendor + tenant)
- Audit log actor name resolution from Logto
- SSO connector management, vendor audit page
- Updated API reference with all current endpoints
- Fixed architecture table (per-tenant containers are dynamic)
- Updated migration list through V012

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 18:41:41 +02:00
hsiegeln
45bcc954ac feat: certificate management with stage/activate/restore lifecycle
All checks were successful
CI / build (push) Successful in 1m8s
CI / docker (push) Successful in 45s
Provider-based architecture (Docker now, K8s later):
- CertificateManager interface + DockerCertificateManager (file-based)
- Atomic swap via .wip files for safe cert replacement
- Stage -> Activate -> Archive lifecycle with one-deep rollback
- Bootstrap supports user-supplied certs via CERT_FILE/KEY_FILE/CA_FILE
- CA bundle aggregates platform + tenant CAs, distributed to containers
- Vendor UI: Certificates page with upload, activate, restore, discard
- Stale tenant tracking (ca_applied_at) with restart banner
- Conditional TLS skip removal when CA bundle exists

Includes design spec, migration V012, service + controller tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 18:29:02 +02:00
hsiegeln
51a1aef10e fix: hide server dashboard link for vendor, remove fingerprint icon
All checks were successful
CI / build (push) Successful in 52s
CI / docker (push) Successful in 43s
Vendor persona doesn't need "Open Server Dashboard" in sidebar footer.
Removed inline Fingerprint icon from Identity (Logto) menu item.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:49:40 +02:00
hsiegeln
2607ef5dbe fix: resolve actor name from Logto for audit log entries
All checks were successful
CI / build (push) Successful in 50s
CI / docker (push) Successful in 32s
AuditService now looks up username/name/email from Logto Management API
when actorEmail is null, with an in-memory cache to avoid repeated calls.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:47:43 +02:00
hsiegeln
0a1e848ef7 fix: return 204 No Content from restart endpoints
All checks were successful
CI / build (push) Successful in 52s
CI / docker (push) Successful in 34s
Empty 200 responses caused JSON parse errors in the API client.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:41:17 +02:00
hsiegeln
6dc5e558a3 chore: bump @cameleer/design-system to 0.1.41
All checks were successful
CI / build (push) Successful in 51s
CI / docker (push) Successful in 52s
Picks up Spinner animation fix (missing @keyframes in CSS module).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:38:40 +02:00
hsiegeln
a3a1643b37 fix: update VendorTenantServiceTest for async provisioning
All checks were successful
CI / build (push) Successful in 50s
CI / docker (push) Successful in 45s
Tests now mock tenantRepository.findById() since provisionAsync re-loads
the tenant entity, and assert on the entity directly rather than the
return value of createAndProvision().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:28:51 +02:00
hsiegeln
4447d79c92 fix: add missing TenantProvisioner mock to TenantPortalServiceTest
Some checks failed
CI / build (push) Failing after 40s
CI / docker (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:25:37 +02:00
hsiegeln
7e7a07470b feat: add restart server action for vendor and tenant
Some checks failed
CI / build (push) Failing after 36s
CI / docker (push) Has been skipped
Vendor: POST /api/vendor/tenants/{id}/restart (platform:admin scope)
Tenant: POST /api/tenant/server/restart (tenant:manage scope)

Both call TenantProvisioner.stop() then start() on the server + UI
containers. Restart button on vendor TenantDetailPage (Actions card)
and tenant TenantDashboardPage (Server card). Allowed in any status
including PROVISIONING.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:21:14 +02:00
hsiegeln
252c18bcff feat: async tenant provisioning with polling UX
Some checks failed
CI / build (push) Failing after 39s
CI / docker (push) Has been skipped
Backend: extract Docker provisioning into @Async method so the API
returns immediately with status=PROVISIONING. The tenant record, Logto
org, admin user, and license are created synchronously; container
provisioning, health check, license push, and OIDC config happen in a
background thread.

Frontend: navigate to tenant detail page immediately after creation.
Detail page polls every 3s while status=PROVISIONING and shows a
spinner indicator. Toast notification when provisioning completes.
Fixes #52.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:14:26 +02:00
hsiegeln
269c679e9c fix: remove server-specific providers now that TopBar is decomposed
All checks were successful
CI / build (push) Successful in 1m9s
CI / docker (push) Successful in 49s
Update to @cameleer/design-system@0.1.40 where TopBar no longer depends
on GlobalFilterProvider or CommandPaletteProvider. Remove these
unnecessary provider wrappers from main.tsx. Fixes #53.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:06:16 +02:00
hsiegeln
e559267f1e feat: replace tenant OIDC page with Enterprise SSO connector management
All checks were successful
CI / build (push) Successful in 1m3s
CI / docker (push) Successful in 46s
- Add LogtoManagementClient methods for SSO connector CRUD + org JIT
- Add TenantSsoService with tenant isolation (validates connector-org link)
- Add TenantSsoController at /api/tenant/sso with test endpoint
- Create SsoPage with provider selection, dynamic config form, test button
- Remove old OIDC config endpoints from tenant portal (server OIDC is
  now platform-managed, set during provisioning)
- Sidebar: OIDC -> SSO with Shield icon

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:48:51 +02:00
hsiegeln
4341656a5e refactor: remove additionalScopes from OIDC config push
All checks were successful
CI / build (push) Successful in 1m34s
CI / docker (push) Successful in 56s
Server now hardcodes Logto org scopes in the auth flow, so the
provisioner no longer needs to push them via OIDC config.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:37:53 +02:00
hsiegeln
2cda065c06 fix: register /platform/ as post-logout redirect URI, improve sidebar contrast
All checks were successful
CI / build (push) Successful in 52s
CI / docker (push) Successful in 44s
- Add /platform/ to SPA postLogoutRedirectUris in bootstrap (fixes #54)
- Use amber color + bold for active vendor sidebar items

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:41:18 +02:00
hsiegeln
bcad83cc40 fix: use JdbcTemplate for audit queries (match server pattern)
All checks were successful
CI / build (push) Successful in 53s
CI / docker (push) Successful in 34s
Replace JPQL @Query with dynamic SQL via JdbcTemplate to avoid
Hibernate null parameter type issues (bytea vs text). Conditionally
appends WHERE clauses only for non-null filters, matching the proven
pattern from cameleer3-server's PostgresAuditRepository.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:31:02 +02:00
hsiegeln
0d47c2ec7c fix: avoid null bytea in audit search JPQL
All checks were successful
CI / build (push) Successful in 51s
CI / docker (push) Successful in 32s
Hibernate binds null String params as bytea, causing PostgreSQL
lower(bytea) error. Convert null search to empty string in service
layer, use empty-string check in JPQL instead of IS NULL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:26:18 +02:00
hsiegeln
247ec030e5 fix: use COALESCE in audit JPQL to prevent lower(bytea) error
All checks were successful
CI / build (push) Successful in 51s
CI / docker (push) Successful in 31s
Hibernate passes null search param as bytea type, causing PostgreSQL
to fail on LOWER(bytea). COALESCE converts null to empty string.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:21:20 +02:00
hsiegeln
a1acc0bc62 fix: permit SPA routes /vendor/** and /tenant/** for direct navigation
All checks were successful
CI / build (push) Successful in 49s
CI / docker (push) Successful in 32s
Without this, hard refresh on SPA routes returns 401 because Spring
Security intercepts before SpaController can forward to index.html.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:15:08 +02:00
hsiegeln
8b94937d38 feat: add audit log viewing for vendor and tenant personas
All checks were successful
CI / build (push) Successful in 52s
CI / docker (push) Successful in 40s
Vendor sees all audit events with tenant filter at /vendor/audit.
Tenant admin sees only their own events at /tenant/audit.
Both support pagination, action/result filters, and text search.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:07:18 +02:00
hsiegeln
1750fe64a2 docs: update CLAUDE.md with provisioning fixes and OIDC role flow
All checks were successful
CI / build (push) Successful in 52s
CI / docker (push) Successful in 9s
Documents traefik.docker.network label requirement, JAR volume mount,
CAMELEER_API_URL env var, additionalScopes for org roles, and the
OIDC role fallback priority (claim mapping > token roles > defaults).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 12:43:45 +02:00
hsiegeln
4572a4bb57 fix: mount JAR volume on provisioned server containers
All checks were successful
CI / build (push) Successful in 1m3s
CI / docker (push) Successful in 34s
The server needs a shared Docker volume at /data/jars to store
uploaded JARs that deployed app containers can access. Without this
mount, deployed containers fail with "Unable to access jarfile".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 12:36:09 +02:00
hsiegeln
9824d06824 fix: include Logto org scopes in OIDC config pushed to servers
All checks were successful
CI / build (push) Successful in 1m1s
CI / docker (push) Successful in 38s
Without urn:logto:scope:organizations and
urn:logto:scope:organization_roles in the additionalScopes, Logto
doesn't include organization role data in the Custom JWT context.
This caused the roles claim to be empty, so all OIDC users got
defaultRoles (VIEWER) instead of their org role (e.g. owner →
server:admin).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 12:10:56 +02:00
hsiegeln
e24c6da025 feat: grant vendor user Logto admin console access during bootstrap
All checks were successful
CI / build (push) Successful in 1m1s
CI / docker (push) Successful in 11s
When VENDOR_SEED_ENABLED=true, the vendor user is now also created
in the Logto admin tenant with user + default:admin roles, giving
them access to the Logto admin console at port 3002.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:54:57 +02:00
hsiegeln
6bdcbf840b fix: correct Logto admin console link to use port 3002
All checks were successful
CI / build (push) Successful in 58s
CI / docker (push) Successful in 47s
The Identity (Logto) link in the vendor sidebar pointed to /console
which doesn't exist. The Logto admin console is served on port 3002
via a dedicated Traefik entrypoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 11:52:45 +02:00
hsiegeln
4699db5465 docs: document traefik.docker.network and CAMELEER_API_URL in CLAUDE.md
All checks were successful
CI / build (push) Successful in 57s
CI / docker (push) Successful in 9s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:55:31 +02:00
hsiegeln
d911fd2201 fix: add traefik.docker.network label to provisioned containers
All checks were successful
CI / build (push) Successful in 53s
CI / docker (push) Successful in 34s
Traefik's Docker provider resolves container IPs using the configured
default network ('cameleer'). For dynamically-created containers not
managed by compose, this network name doesn't match. Adding the
traefik.docker.network label explicitly tells Traefik to use the
cameleer-traefik network for routing, fixing 504 Gateway Timeouts
on /t/{slug}/api/* paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:40:59 +02:00
hsiegeln
b4f9277220 fix: use CAMELEER_API_URL env var for server-ui container
All checks were successful
CI / build (push) Successful in 53s
CI / docker (push) Successful in 33s
The nginx template in cameleer3-server-ui uses ${CAMELEER_API_URL} for
the upstream proxy target, not API_URL. The wrong env var name caused
the baked-in default (http://cameleer3-server:8081) to be used, which
doesn't resolve in per-tenant networks where the server is named
cameleer-server-{slug}.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:16:42 +02:00
hsiegeln
eaf109549d fix: use /api/v1/admin/oidc for server OIDC config push (not /api/admin)
All checks were successful
CI / build (push) Successful in 51s
CI / docker (push) Successful in 36s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:58:34 +02:00
hsiegeln
3a6b94c1eb fix: remove server health wait from bootstrap (no compose server)
All checks were successful
CI / build (push) Successful in 1m3s
CI / docker (push) Successful in 10s
Bootstrap was stuck waiting for cameleer3-server which no longer exists
in docker-compose. Removed server wait loop and SERVER_ENDPOINT config.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:54:37 +02:00
hsiegeln
b727bc771d docs: update CLAUDE.md to reflect platform redesign
All checks were successful
CI / build (push) Successful in 1m27s
CI / docker (push) Successful in 14s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:44:23 +02:00
hsiegeln
7ee2985626 feat: push OIDC config to provisioned server for SSO login
All checks were successful
CI / build (push) Successful in 52s
CI / docker (push) Successful in 33s
After provisioning a server, pushes Logto Traditional Web App
credentials (client ID + secret) via the server's OIDC admin API.
This enables SSO: users authenticated via Logto can access the
server dashboard without a separate login.

Reads tradAppSecret from bootstrap JSON via LogtoConfig.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:37:01 +02:00
hsiegeln
3efae43879 feat: clean control plane — remove all example tenant resources
All checks were successful
CI / build (push) Successful in 58s
CI / docker (push) Successful in 11s
- Removed cameleer3-server and cameleer3-server-ui from docker-compose
  (tenants provision their own server instances via the vendor console)
- Removed viewer/camel user from bootstrap (tenant users created during
  provisioning)
- Removed Phase 7 server OIDC configuration (provisioned servers get
  OIDC config from env vars, claim mappings via Logto Custom JWT)
- Removed server-related env vars from bootstrap (SERVER_ENDPOINT, etc.)
- Removed jardata volume from dev overlay

Clean slate: docker compose up gives you Traefik + PostgreSQL +
ClickHouse + Logto + SaaS platform + vendor seed. Everything else
(servers, tenants, users) created through the vendor console.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:24:28 +02:00
hsiegeln
aa663a9c9e feat: vendor sidebar section, remove example tenant, add Logto link
All checks were successful
CI / build (push) Successful in 1m4s
CI / docker (push) Successful in 48s
- Sidebar: Tenants moved into expandable "Vendor" section with
  sub-items for Tenants and Identity (Logto console link)
- Bootstrap: removed example organization creation (Phase 6 org)
  — tenants are now created exclusively via the vendor console
- Removed BootstrapDataSeeder (no auto-seeded tenant/license)
- Bootstrap log updated to reflect clean-slate approach

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:19:46 +02:00
hsiegeln
f5ef8e6488 feat: per-tenant network isolation
All checks were successful
CI / build (push) Successful in 52s
CI / docker (push) Successful in 33s
Each tenant gets an isolated Docker bridge network (cameleer-tenant-{slug}).
Server + UI containers use the tenant network as primary, with additional
connections to the shared services network (postgres/clickhouse/logto) and
Traefik network (routing). Tenant networks are internal (no internet) and
isolated from each other. Apps deployed by the tenant server also join
the tenant network. Network is removed on tenant delete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:04:11 +02:00
hsiegeln
0a43a7dcd1 feat: register OIDC redirect URIs for provisioned tenant servers
All checks were successful
CI / build (push) Successful in 53s
CI / docker (push) Successful in 34s
During tenant provisioning, adds /t/{slug}/oidc/callback to the Logto
Traditional Web App's registered redirect URIs. This enables the
server's OIDC login flow to work when accessed via Traefik routing.

Also reads tradAppId from bootstrap JSON via LogtoConfig.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 07:50:38 +02:00
hsiegeln
3b345881c6 fix: strip non-alphanumeric chars from admin username input
All checks were successful
CI / build (push) Successful in 53s
CI / docker (push) Successful in 41s
Logto usernames must match alphanumeric regex. The form now strips
invalid characters on input and shows a hint about the constraint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 07:44:59 +02:00
hsiegeln
2dc75c4361 feat: create initial admin user + add vendor to new tenant orgs
All checks were successful
CI / build (push) Successful in 50s
CI / docker (push) Successful in 41s
When creating a tenant, the vendor can specify adminUsername +
adminPassword. The backend creates the user in Logto and assigns them
the owner org role. The vendor user is also auto-added to every new
org for support access.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 07:35:17 +02:00
hsiegeln
b7a0530466 fix: exclude DELETED tenants from vendor tenant list
All checks were successful
CI / build (push) Successful in 50s
CI / docker (push) Successful in 33s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 07:28:56 +02:00
hsiegeln
ebdb4f9450 fix: allow slug reuse after tenant soft-delete
All checks were successful
CI / build (push) Successful in 59s
CI / docker (push) Successful in 39s
existsBySlug found DELETED records, blocking slug reuse. Changed to
existsBySlugAndStatusNot(slug, DELETED) so deleted tenant slugs can
be reclaimed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 07:25:49 +02:00
hsiegeln
5ed33807d8 fix: use /api/v1/health for server health checks (not /actuator/health)
Some checks failed
CI / build (push) Successful in 49s
CI / docker (push) Successful in 30s
SonarQube Analysis / sonarqube (push) Failing after 1m24s
The server's /actuator/health requires auth. The public health endpoint
is /api/v1/health (same as compose-managed server's Docker HEALTHCHECK).
Also increased health check retries/timeout and added startPeriod.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 23:43:46 +02:00
hsiegeln
00476c974f fix: vendor scoping, sidebar visibility, and landing redirect
All checks were successful
CI / build (push) Successful in 50s
CI / docker (push) Successful in 41s
- OrgResolver merges global + org-scoped token scopes so vendor's
  platform:admin (from global saas-vendor role) is always visible
- LandingRedirect waits for scopes to load before redirecting
  (prevents premature redirect to server dashboard)
- Layout hides tenant portal sidebar items when vendor is on
  /vendor/* routes; shows them when navigating to tenant context

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 23:39:12 +02:00
hsiegeln
c674785c82 fix: merge global + org-scoped token scopes in OrgResolver
All checks were successful
CI / build (push) Successful in 50s
CI / docker (push) Successful in 40s
Vendor's platform:admin scope comes from a global Logto role, which is
only present in the non-org-scoped token. OrgResolver now fetches both
the global token and the org-scoped token, merging their scopes. This
ensures vendor users see platform:admin and land on the vendor console.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 23:33:30 +02:00
hsiegeln
4087ce8f29 fix: provisioned server containers — strip-prefix, Docker socket, env
All checks were successful
CI / build (push) Successful in 1m14s
CI / docker (push) Successful in 34s
- Add Traefik strip-prefix middleware so /t/{slug}/api -> /api on server
- Add priority to routers (server API=10, UI=5) to prevent conflicts
- Mount Docker socket + group_add in server containers for app deployment
- Add JAR storage, Docker network, volume env vars for runtime orchestrator
- Use HashMap for labels (>10 entries exceeds Map.of limit)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 23:26:48 +02:00
hsiegeln
39c3b39711 feat: role-based sidebar visibility and landing redirect
All checks were successful
CI / build (push) Successful in 51s
CI / docker (push) Successful in 42s
- Vendor (platform:admin): sees only TENANTS in sidebar
- Tenant admin (tenant:manage): sees Dashboard, License, OIDC, Team, Settings
- Regular user (operator/viewer): redirected to server dashboard directly
- LandingRedirect checks scopes in priority order: vendor > admin > server

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 23:24:22 +02:00
hsiegeln
cdd495d985 ci: exclude new integration tests from CI (no Testcontainers in CI)
All checks were successful
CI / build (push) Successful in 50s
CI / docker (push) Successful in 33s
VendorTenantControllerTest and TenantPortalControllerTest use
@SpringBootTest + Testcontainers PostgreSQL, same as the existing
controller tests that are already excluded.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 23:11:45 +02:00
hsiegeln
17fbe73e60 test: add 25 tests for vendor + portal services and controllers
Some checks failed
CI / build (push) Failing after 1m16s
CI / docker (push) Has been skipped
VendorTenantServiceTest (8): create/provision, suspend, delete, renew
VendorTenantControllerTest (7): CRUD, auth, conflict handling
TenantPortalServiceTest (5): dashboard, license, settings
TenantPortalControllerTest (5): dashboard, license, settings, auth

Fix TenantIsolationInterceptor bugs found by tests:
- org_id resolution now runs before portal path check
- path matching uses URI minus context path (not getServletPath)
- portal path returns 403 sendError instead of empty 200

Total: 50 tests, 0 failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 23:08:47 +02:00
hsiegeln
faac0048c3 fix: add missing server env vars to DockerTenantProvisioner
All checks were successful
CI / build (push) Successful in 50s
CI / docker (push) Successful in 43s
Adds CAMELEER_AUTH_TOKEN, CAMELEER_JWT_SECRET, CAMELEER_OIDC_AUDIENCE,
CLICKHOUSE_URL to provisioned server containers. Also passes PUBLIC_HOST
and PUBLIC_PROTOCOL to SaaS container in dev overlay so provisioner
resolves the correct hostname instead of defaulting to localhost.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 22:53:21 +02:00
hsiegeln
e6f2f17fa1 fix: use correct tier enum values (LOW/MID/HIGH/BUSINESS) in create form
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 22:39:23 +02:00
hsiegeln
28d044efbc fix: vendor user now lands on /vendor/tenants after login
LandingRedirect component checks scopes — platform:admin goes to
vendor console, others go to tenant dashboard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 22:37:28 +02:00
hsiegeln
6a81053d37 feat: integrate vendor seed into bootstrap with VENDOR_SEED_ENABLED switch
All checks were successful
CI / build (push) Successful in 1m13s
CI / docker (push) Successful in 49s
Phase 12 in logto-bootstrap.sh creates saas-vendor global role + vendor
user when VENDOR_SEED_ENABLED=true. Enabled by default in dev overlay.
Also restores GlobalFilterProvider + CommandPaletteProvider (required by
DS TopBar internally).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 22:30:06 +02:00
hsiegeln
fd41a056eb feat: Docker socket mount for tenant provisioning
Add Docker socket volume, group_add: ["0"], and provisioning env vars
(CAMELEER_SERVER_IMAGE, CAMELEER_SERVER_UI_IMAGE, CAMELEER_NETWORK,
CAMELEER_TRAEFIK_NETWORK) to the cameleer-saas service in docker-compose.dev.yml.
2026-04-09 22:30:06 +02:00
hsiegeln
9ecaf22f09 feat: tenant portal — all 5 pages (dashboard, license, OIDC, team, settings)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 22:30:06 +02:00
hsiegeln
d2f6b02a5f feat: vendor console — tenant list, create wizard, detail page
Implements Task 9: shared components (ServerStatusBadge, UsageIndicator,
platform.module.css, tierColor utility) and full vendor console pages
(VendorTenantsPage, CreateTenantPage, TenantDetailPage). Build passes cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 22:30:06 +02:00
hsiegeln
bf3aa57274 feat: restructure frontend routes — vendor/tenant persona split
Splits the flat 3-page UI into /vendor/* (platform:admin) and /tenant/*
(all authenticated users) route trees, with stub pages, new API hooks,
updated Layout with persona-aware sidebar, and SpaController forwarding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 22:29:59 +02:00
hsiegeln
e56e3fca8a feat: tenant portal API (dashboard, license, OIDC, team, settings)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 22:29:01 +02:00
hsiegeln
127834ce4d feat: vendor tenant API with provisioning, suspend, delete
Adds VendorTenantService orchestrating full tenant lifecycle (create,
provision, license push, activate, suspend, delete, renew license),
VendorTenantController at /api/vendor/tenants with platform:admin guard,
LicenseResponse.from() factory, SecurityConfig vendor/tenant path rules,
and TenantIsolationInterceptor bypasses for vendor and tenant portal paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 22:29:01 +02:00
hsiegeln
6bdb02ff5a feat: add per-tenant health, OIDC, team methods to API clients
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 22:29:01 +02:00
hsiegeln
96a5b1d9f1 feat: implement DockerTenantProvisioner with container lifecycle
Replace stub with full Docker implementation using docker-java. Manages
per-tenant server and UI containers with Traefik labels, health checks,
image pull, network attachment, and full lifecycle (provision/start/stop/remove/status).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 22:29:01 +02:00
hsiegeln
771e9d1081 feat: add TenantProvisioner interface with auto-detection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 22:29:01 +02:00
hsiegeln
ebba021448 feat: add provisioning fields to tenants + license revoke
Adds server_endpoint and provision_error columns to tenants table (V011 migration),
updates TenantEntity and TenantResponse with new fields and a from() factory,
adds revokeLicense() to LicenseService, and updates TenantController to use the factory.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 22:29:01 +02:00
hsiegeln
81d570fd63 docs: add platform redesign implementation plan (12 tasks)
Backend: TenantProvisioner interface, DockerTenantProvisioner,
vendor API (create/provision/suspend/delete), tenant portal API
(dashboard/license/OIDC/team/settings). Frontend: route restructure
(/vendor/*, /tenant/*), persona-aware Layout, 8 new pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 22:29:01 +02:00
hsiegeln
7b92de4017 docs: add platform redesign spec with user stories
Redesign SaaS platform from read-only viewer into vendor management
plane with tenant provisioning, license management, and customer
self-service. Two personas (vendor/customer), pluggable provisioning
interface (Docker first, K8s later), per-tenant server instances.

User stories tracked as Gitea issues #40-#51. Closes #37.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 22:29:01 +02:00
0ba896ada4 Merge pull request 'SaaS platform UX polish: layout, navigation, error handling' (#39) from feature/saas-ux-polish into main
All checks were successful
CI / build (push) Successful in 53s
CI / docker (push) Successful in 11s
Reviewed-on: #39
2026-04-09 19:56:24 +02:00
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
hsiegeln
af04f7b4a1 ci: add nightly SonarQube analysis workflow
All checks were successful
CI / build (push) Successful in 45s
CI / build (pull_request) Successful in 46s
CI / docker (pull_request) Has been skipped
CI / docker (push) Successful in 2m29s
Runs at 02:00 UTC daily (same schedule as cameleer3 and cameleer3-server).
Uses cameleer-build:1 image, excludes TestContainers integration tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 18:08:35 +02:00
hsiegeln
abc06f57da feat: update Docker Compose, CI, and add runtime-base Dockerfile
Some checks failed
CI / build (push) Successful in 57s
CI / build (pull_request) Successful in 54s
CI / docker (pull_request) Has been skipped
CI / docker (push) Has been cancelled
Add jardata volume, CAMELEER_AUTH_TOKEN/CAMELEER3_SERVER_ENDPOINT/CLICKHOUSE_URL env vars to cameleer-saas, CAMELEER_AUTH_TOKEN to cameleer3-server, runtime-base Dockerfile for agent-instrumented customer apps, and expand CI surefire excludes for new integration test classes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:04:42 +02:00
hsiegeln
0bd54f2a95 feat: add container log service with ClickHouse storage and log API
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:02:42 +02:00
hsiegeln
fc34626a88 feat: add deployment controller with deploy/stop/restart endpoints
Add DeploymentResponse DTO, DeploymentController at /api/apps/{appId} with POST /deploy (202), GET /deployments, GET /deployments/{id}, POST /stop, POST /restart (202), and integration tests covering empty list, 404, and 401 cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:00:23 +02:00
hsiegeln
59df59f406 feat: add deployment service with async pipeline
Implements DeploymentService with TDD: builds Docker images, starts containers with Cameleer env vars, polls for health, and handles stop/restart lifecycle. All 3 unit tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:57:09 +02:00
hsiegeln
23a474fbf3 feat: add deployment entity, repository, and status enums 2026-04-04 17:54:08 +02:00
hsiegeln
d2ea256cd8 feat: add app controller with multipart JAR upload
Adds AppController at /api/environments/{environmentId}/apps with POST (multipart
metadata+JAR), GET list, GET by ID, PUT jar reupload, and DELETE endpoints.
Also adds CreateAppRequest and AppResponse DTOs, integration tests (AppControllerTest),
and fixes ClickHouseConfig to be excluded in test profile via @Profile("!test").

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:53:10 +02:00
hsiegeln
51f5822364 feat: add app service with JAR upload and tier enforcement
Implements AppService with JAR file storage, SHA-256 checksum computation,
tier-based app limit enforcement via LicenseDefaults, and audit logging.
Four TDD tests all pass covering creation, JAR validation, duplicate slug
rejection, and JAR re-upload.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:47:05 +02:00
hsiegeln
2151801d40 feat: add DockerRuntimeOrchestrator with docker-java
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:44:34 +02:00
hsiegeln
90c1e36cb7 feat: add RuntimeOrchestrator interface and request/response types 2026-04-04 17:42:56 +02:00
hsiegeln
731690191b feat: add app entity and repository 2026-04-04 17:42:08 +02:00
hsiegeln
36069bae07 feat: auto-create default environment on tenant provisioning 2026-04-04 17:41:23 +02:00
hsiegeln
785bdab3d1 feat: add environment controller with CRUD endpoints
Implements POST/GET/PATCH/DELETE endpoints at /api/tenants/{tenantId}/environments
with DTOs, mapping helpers, and a Spring Boot integration test (TestContainers).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:40:23 +02:00
hsiegeln
34e98ab176 feat: add environment service with tier enforcement and audit logging
Implements EnvironmentService with full CRUD, duplicate slug rejection,
tier-based environment count limits, and audit logging for create/update/delete.
Adds ENVIRONMENT_CREATE, ENVIRONMENT_UPDATE, ENVIRONMENT_DELETE to AuditAction.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:36:09 +02:00
hsiegeln
8511d10343 feat: add environment entity, repository, and status enum 2026-04-04 17:33:43 +02:00
hsiegeln
4cb15c9bea feat: add database migrations for environments, apps, deployments 2026-04-04 17:32:51 +02:00
hsiegeln
bd8dfcf147 fix: use concrete ClickHouseDataSource return type to avoid bean ambiguity 2026-04-04 17:32:09 +02:00
hsiegeln
803b8c9876 feat: add Phase 3 dependencies and configuration
Add docker-java and ClickHouse JDBC dependencies, RuntimeConfig and
ClickHouseConfig Spring components, AsyncConfig with deployment thread
pool, and runtime/clickhouse config sections in application.yml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:29:06 +02:00
hsiegeln
c0fce36d4a chore: add .worktrees to .gitignore 2026-04-04 17:26:22 +02:00
hsiegeln
fa7853b02d docs: add Phase 3 Runtime Orchestration implementation plan
16-task plan covering environments, apps, deployments, Docker
runtime orchestrator, ClickHouse log ingestion, and CI updates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:24:20 +02:00
hsiegeln
0326dc6cce docs: add Phase 3 Runtime Orchestration spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:13:08 +02:00
5d14f78b9d Merge pull request 'Phase 2: Tenants + Identity + Licensing' (#32) from feature/phase-2-tenants-identity-licensing into main
All checks were successful
CI / build (push) Successful in 25s
CI / docker (push) Successful in 31s
Reviewed-on: #32
2026-04-04 15:58:07 +02:00
220 changed files with 43746 additions and 1105 deletions

View File

@@ -1,25 +1,44 @@
# Cameleer SaaS Environment Variables
# Copy to .env and fill in values
# Cameleer SaaS Environment Configuration
# Copy to .env and fill in values for production
# Application version
# Image version
VERSION=latest
# Public access
PUBLIC_HOST=localhost
PUBLIC_PROTOCOL=https
# Ports
HTTP_PORT=80
HTTPS_PORT=443
LOGTO_CONSOLE_PORT=3002
# PostgreSQL
POSTGRES_USER=cameleer
POSTGRES_PASSWORD=change_me_in_production
POSTGRES_DB=cameleer_saas
# Logto Identity Provider
LOGTO_ENDPOINT=http://logto:3001
LOGTO_ISSUER_URI=http://logto: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=
# ClickHouse
CLICKHOUSE_PASSWORD=change_me_in_production
# Ed25519 Keys (mount PEM files)
CAMELEER_JWT_PRIVATE_KEY_PATH=/etc/cameleer/keys/ed25519.key
CAMELEER_JWT_PUBLIC_KEY_PATH=/etc/cameleer/keys/ed25519.pub
# Admin user (created by bootstrap)
SAAS_ADMIN_USER=admin
SAAS_ADMIN_PASS=change_me_in_production
# Domain (for Traefik TLS)
DOMAIN=localhost
# TLS (leave empty for self-signed)
# NODE_TLS_REJECT=0 # Set to 1 when using real certificates
# CERT_FILE=
# KEY_FILE=
# CA_FILE=
# Vendor account (optional)
VENDOR_SEED_ENABLED=false
# VENDOR_USER=vendor
# VENDOR_PASS=change_me
# Docker images (override for custom registries)
# TRAEFIK_IMAGE=gitea.siegeln.net/cameleer/cameleer-traefik
# POSTGRES_IMAGE=gitea.siegeln.net/cameleer/cameleer-postgres
# CLICKHOUSE_IMAGE=gitea.siegeln.net/cameleer/cameleer-clickhouse
# LOGTO_IMAGE=gitea.siegeln.net/cameleer/cameleer-logto
# CAMELEER_IMAGE=gitea.siegeln.net/cameleer/cameleer-saas

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"
-Dsurefire.excludes="**/AuthControllerTest.java,**/TenantControllerTest.java,**/LicenseControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java,**/EnvironmentControllerTest.java,**/AppControllerTest.java,**/DeploymentControllerTest.java,**/AgentStatusControllerTest.java,**/VendorTenantControllerTest.java,**/TenantPortalControllerTest.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,89 @@ 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 .
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push PostgreSQL image
run: |
TAGS="-t gitea.siegeln.net/cameleer/cameleer-postgres:${{ github.sha }}"
for TAG in $IMAGE_TAGS; do
TAGS="$TAGS -t gitea.siegeln.net/cameleer/cameleer-postgres:$TAG"
done
docker buildx build --platform linux/amd64 \
$TAGS \
--provenance=false \
--push docker/cameleer-postgres/
- name: Build and push ClickHouse image
run: |
TAGS="-t gitea.siegeln.net/cameleer/cameleer-clickhouse:${{ github.sha }}"
for TAG in $IMAGE_TAGS; do
TAGS="$TAGS -t gitea.siegeln.net/cameleer/cameleer-clickhouse:$TAG"
done
docker buildx build --platform linux/amd64 \
$TAGS \
--provenance=false \
--push docker/cameleer-clickhouse/
- name: Build and push Traefik image
run: |
TAGS="-t gitea.siegeln.net/cameleer/cameleer-traefik:${{ github.sha }}"
for TAG in $IMAGE_TAGS; do
TAGS="$TAGS -t gitea.siegeln.net/cameleer/cameleer-traefik:$TAG"
done
docker buildx build --platform linux/amd64 \
$TAGS \
--provenance=false \
--push docker/cameleer-traefik/

View File

@@ -0,0 +1,35 @@
name: SonarQube Analysis
on:
schedule:
- cron: '0 2 * * *' # Nightly at 02:00 UTC
workflow_dispatch: # Allow manual trigger
jobs:
sonarqube:
runs-on: ubuntu-latest
container:
image: gitea.siegeln.net/cameleer/cameleer-build:1
credentials:
username: cameleer
password: ${{ secrets.REGISTRY_TOKEN }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for blame data
- name: Cache Maven dependencies
uses: actions/cache@v4
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: ${{ runner.os }}-maven-
- 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,**/AgentStatusControllerTest.java"
-Dsonar.host.url=${{ secrets.SONAR_HOST_URL }}
-Dsonar.token=${{ secrets.SONAR_TOKEN }}
-Dsonar.projectKey=cameleer-saas
-Dsonar.projectName="Cameleer SaaS"

8
.gitignore vendored
View File

@@ -18,3 +18,11 @@ Thumbs.db
# Environment
.env
*.env.local
# Worktrees
.worktrees/
# Generated by postinstall from @cameleer/design-system
ui/public/favicon.svg
docker/runtime-base/agent.jar
.gitnexus

425
CLAUDE.md
View File

@@ -4,36 +4,443 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project
Cameleer SaaS — multi-tenant SaaS platform wrapping the Cameleer observability stack (Java agent + server) for Apache Camel applications. Customers get managed observability for their Camel integrations without running infrastructure.
Cameleer SaaS — **vendor management plane** for the Cameleer observability stack. Two personas: **vendor** (platform:admin) manages the platform and provisions tenants; **tenant admin** (tenant:manage) manages their observability instance. The vendor creates tenants, which provisions per-tenant cameleer3-server + UI instances via Docker API. No example tenant — clean slate bootstrap, vendor creates everything.
## Ecosystem
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 data model
- `TenantEntity.java` — JPA entity (id, name, slug, tier, status, logto_org_id, stripe IDs, settings JSONB)
**vendor/** — Vendor console (platform:admin only)
- `VendorTenantService.java` — orchestrates tenant creation (sync: DB + Logto + license, async: Docker provisioning + config push), suspend/activate, delete, restart server, upgrade server (force-pull + re-provision), license renewal
- `VendorTenantController.java` — REST at `/api/vendor/tenants` (platform:admin required). List endpoint returns `VendorTenantSummary` with fleet health data (agentCount, environmentCount, agentLimit) fetched in parallel via `CompletableFuture`.
- `InfrastructureService.java` — raw JDBC queries against shared PostgreSQL and ClickHouse for per-tenant infrastructure monitoring (schema sizes, table stats, row counts, disk usage)
- `InfrastructureController.java` — REST at `/api/vendor/infrastructure` (platform:admin required). PostgreSQL and ClickHouse overview with per-tenant breakdown.
**portal/** — Tenant admin portal (org-scoped)
- `TenantPortalService.java` — customer-facing: dashboard (health + agent/env counts from server via M2M), license, SSO connectors, team, settings (public endpoint URL), server restart/upgrade, password management (own + team + server admin)
- `TenantPortalController.java` — REST at `/api/tenant/*` (org-scoped, includes CA cert management at `/api/tenant/ca`, password endpoints at `/api/tenant/password` and `/api/tenant/server/admin-password`)
**provisioning/** — Pluggable tenant provisioning
- `TenantProvisioner.java` — pluggable interface (like server's RuntimeOrchestrator)
- `DockerTenantProvisioner.java` — Docker implementation, creates per-tenant server + UI containers. `upgrade(slug)` force-pulls latest images and removes server+UI containers (preserves app containers, volumes, networks) for re-provisioning. `remove(slug)` does full cleanup: label-based container removal, env networks, tenant network, JAR volume.
- `TenantDataCleanupService.java` — GDPR data erasure on tenant delete: drops PostgreSQL `tenant_{slug}` schema, deletes ClickHouse data across all tables with `tenant_id` column
- `TenantProvisionerAutoConfig.java` — auto-detects Docker socket
- `DockerCertificateManager.java` — file-based cert management with atomic `.wip` swap (Docker volume)
- `DisabledCertificateManager.java` — no-op when certs dir unavailable
- `CertificateManagerAutoConfig.java` — auto-detects `/certs` directory
**certificate/** — TLS certificate lifecycle management
- `CertificateManager.java` — provider interface (Docker now, K8s later)
- `CertificateService.java` — orchestrates stage/activate/restore/discard, DB metadata, tenant CA staleness
- `CertificateController.java` — REST at `/api/vendor/certificates` (platform:admin required)
- `CertificateEntity.java` — JPA entity (status: ACTIVE/STAGED/ARCHIVED, subject, fingerprint, etc.)
- `CertificateStartupListener.java` — seeds DB from filesystem on boot (for bootstrap-generated certs)
- `TenantCaCertEntity.java` — JPA entity for per-tenant CA certs (PEM stored in DB, multiple per tenant)
- `TenantCaCertRepository.java` — queries by tenant, status, all active across tenants
- `TenantCaCertService.java` — stage/activate/delete tenant CAs, rebuilds aggregated `ca.pem` on changes
**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, get user, SSO connectors, JIT provisioning, password updates via `PATCH /api/users/{id}/password`)
- `ServerApiClient.java` — M2M client for cameleer3-server API (Logto M2M token, `X-Cameleer-Protocol-Version: 1` header). Health checks, license/OIDC push, agent count, environment count, server admin password reset per tenant server.
**audit/** — Audit logging
- `AuditEntity.java` — JPA entity (actor_id, actor_email, tenant_id, action, resource, status)
- `AuditService.java` — log audit events (TENANT_CREATE, TENANT_UPDATE, etc.); auto-resolves actor name from Logto when actorEmail is null (cached in-memory)
### React Frontend (`ui/src/`)
- `main.tsx` — React 19 root
- `router.tsx``/vendor/*` + `/tenant/*` with `RequireScope` guards and `LandingRedirect` that waits for scopes
- `Layout.tsx` — persona-aware sidebar: vendor sees expandable "Vendor" section (Tenants, Audit Log, Certificates, Infrastructure, Identity/Logto), tenant admin sees Dashboard/License/SSO/Team/Audit/Settings
- `OrgResolver.tsx` — merges global + org-scoped token scopes (vendor's platform:admin is global)
- `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)
- **Vendor pages**: `VendorTenantsPage.tsx`, `CreateTenantPage.tsx`, `TenantDetailPage.tsx`, `VendorAuditPage.tsx`, `CertificatesPage.tsx`
- **Tenant pages**: `TenantDashboardPage.tsx` (restart + upgrade server), `TenantLicensePage.tsx`, `SsoPage.tsx`, `TeamPage.tsx` (reset member passwords), `TenantAuditPage.tsx`, `SettingsPage.tsx` (change own password, reset server admin password)
### 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:
- Add multi-tenancy (tenant isolation of agent data, diagrams, configs)
- Provide self-service signup, billing, and team management
- Generate per-tenant bootstrap tokens for agent registration
- Proxy or federate access to tenant-specific cameleer3-server instances
- Enforce usage quotas and metered billing
The SaaS platform is a **vendor management plane**. It does not proxy requests to servers — instead it provisions dedicated per-tenant cameleer3-server instances via Docker API. Each tenant gets isolated server + UI containers with their own database schemas, networks, and Traefik routing.
### Routing (single-domain, path-based via Traefik)
All services on one hostname. Infrastructure containers (Traefik, Logto) use `PUBLIC_HOST` + `PUBLIC_PROTOCOL` env vars directly. The SaaS app reads these via `CAMELEER_SAAS_PROVISIONING_PUBLICHOST` / `CAMELEER_SAAS_PROVISIONING_PUBLICPROTOCOL` (Spring Boot properties `cameleer.saas.provisioning.publichost` / `cameleer.saas.provisioning.publicprotocol`).
| Path | Target | Notes |
|------|--------|-------|
| `/platform/*` | cameleer-saas:8080 | SPA + API (`server.servlet.context-path: /platform`) |
| `/platform/vendor/*` | (SPA routes) | Vendor console (platform:admin) |
| `/platform/tenant/*` | (SPA routes) | Tenant admin portal (org-scoped) |
| `/t/{slug}/*` | per-tenant server-ui | Provisioned tenant UI containers (Traefik labels) |
| `/` | 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: `traefik-certs` init container generates self-signed cert (dev) or copies user-supplied cert via `CERT_FILE`/`KEY_FILE`/`CA_FILE` env vars. Default cert configured in `docker/traefik-dynamic.yml` (NOT static `traefik.yml` — Traefik v3 ignores `tls.stores.default` in static config). Runtime cert replacement via vendor UI (stage/activate/restore). ACME for production (future). Server containers import `/certs/ca.pem` into JVM truststore at startup via `docker-entrypoint.sh` for OIDC trust.
- Root `/` -> `/platform/` redirect via Traefik file provider (`docker/traefik-dynamic.yml`)
- LoginPage auto-redirects to Logto OIDC (no intermediate button)
- Per-tenant server containers get Traefik labels for `/t/{slug}/*` routing at provisioning time
### Docker Networks
Compose-defined networks:
| Network | Name on Host | Purpose |
|---------|-------------|---------|
| `cameleer` | `cameleer-saas_cameleer` | Compose default — shared services (DB, Logto, SaaS) |
| `cameleer-traefik` | `cameleer-traefik` (fixed `name:`) | Traefik + provisioned tenant containers |
Per-tenant networks (created dynamically by `DockerTenantProvisioner`):
| Network | Name Pattern | Purpose |
|---------|-------------|---------|
| Tenant network | `cameleer-tenant-{slug}` | Internal bridge, no internet — isolates tenant server + apps |
| Environment network | `cameleer-env-{tenantId}-{envSlug}` | Tenant-scoped (includes tenantId to prevent slug collision across tenants) |
Server containers join three networks: tenant network (primary), shared services network (`cameleer`), and traefik network. Apps deployed by the server use the tenant network as primary.
**IMPORTANT:** Dynamically-created containers MUST have `traefik.docker.network=cameleer-traefik` label. Traefik's Docker provider defaults to `network: cameleer` (compose-internal name) for IP resolution, which doesn't match dynamically-created containers connected via Docker API using the host network name (`cameleer-saas_cameleer`). Without this label, Traefik returns 504 Gateway Timeout for `/t/{slug}/api/*` paths.
### 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)
- Org roles: `owner` -> `server:admin` + `tenant:manage`, `operator` -> `server:operator`, `viewer` -> `server:viewer`
- `saas-vendor` global role injected via `docker/vendor-seed.sh` (`VENDOR_SEED_ENABLED=true` in dev) — 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
### Auth routing by persona
| Persona | Logto role | Key scope | Landing route |
|---------|-----------|-----------|---------------|
| Vendor | `saas-vendor` (global) | `platform:admin` | `/vendor/tenants` |
| Tenant admin | org `owner` | `tenant:manage` | `/tenant` (dashboard) |
| Regular user (operator/viewer) | org member | `server:operator` or `server:viewer` | Redirected to server dashboard directly |
- `LandingRedirect` component waits for scopes to load, then routes to the correct persona landing page
- `RequireScope` guard on route groups enforces scope requirements
- SSO bridge: Logto session carries over to provisioned server's OIDC flow (Traditional Web App per tenant)
### Per-tenant server env vars (set by DockerTenantProvisioner)
These env vars are injected into provisioned per-tenant server containers:
| Env var | Value | Purpose |
|---------|-------|---------|
| `CAMELEER_SERVER_SECURITY_OIDCISSUERURI` | `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}/oidc` | Token issuer claim validation |
| `CAMELEER_SERVER_SECURITY_OIDCJWKSETURI` | `http://logto:3001/oidc/jwks` | Docker-internal JWK fetch |
| `CAMELEER_SERVER_SECURITY_OIDCTLSSKIPVERIFY` | `true` (conditional) | Skip cert verify for OIDC discovery; only set when no `/certs/ca.pem` exists. When ca.pem exists, the server's `docker-entrypoint.sh` imports it into the JVM truststore instead. |
| `CAMELEER_SERVER_SECURITY_OIDCAUDIENCE` | `https://api.cameleer.local` | JWT audience validation for OIDC tokens |
| `CAMELEER_SERVER_SECURITY_CORSALLOWEDORIGINS` | `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}` | Allow browser requests through Traefik |
| `CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKEN` | (generated) | Bootstrap auth token for M2M communication |
| `CAMELEER_SERVER_RUNTIME_ENABLED` | `true` | Enable Docker orchestration |
| `CAMELEER_SERVER_RUNTIME_SERVERURL` | `http://cameleer3-server-{slug}:8081` | Per-tenant server URL (DNS alias on tenant network) |
| `CAMELEER_SERVER_RUNTIME_ROUTINGDOMAIN` | `${PUBLIC_HOST}` | Domain for Traefik routing labels |
| `CAMELEER_SERVER_RUNTIME_ROUTINGMODE` | `path` | `path` or `subdomain` routing |
| `CAMELEER_SERVER_RUNTIME_JARSTORAGEPATH` | `/data/jars` | Directory for uploaded JARs |
| `CAMELEER_SERVER_RUNTIME_DOCKERNETWORK` | `cameleer-tenant-{slug}` | Primary network for deployed app containers |
| `CAMELEER_SERVER_RUNTIME_JARDOCKERVOLUME` | `cameleer-jars-{slug}` | Docker volume name for JAR sharing between server and deployed containers |
| `CAMELEER_SERVER_TENANT_ID` | (tenant UUID) | Tenant identifier for data isolation |
| `CAMELEER_SERVER_SECURITY_INFRASTRUCTUREENDPOINTS` | `false` | Hides Database/ClickHouse admin from tenant admins |
| `BASE_PATH` (server-ui) | `/t/{slug}` | React Router basename + `<base>` tag |
| `CAMELEER_API_URL` (server-ui) | `http://cameleer-server-{slug}:8081` | Nginx upstream proxy target (NOT `API_URL` — image uses `${CAMELEER_API_URL}`) |
### Per-tenant volume mounts (set by DockerTenantProvisioner)
| Mount | Container path | Purpose |
|-------|---------------|---------|
| `/var/run/docker.sock` | `/var/run/docker.sock` | Docker socket for app deployment orchestration |
| `cameleer-jars-{slug}` (volume, via `CAMELEER_SERVER_RUNTIME_JARDOCKERVOLUME`) | `/data/jars` | Shared JAR storage — server writes, deployed app containers read |
| `cameleer-saas_certs` (volume, ro) | `/certs` | Platform TLS certs + CA bundle for OIDC trust |
### SaaS app configuration (env vars for cameleer-saas itself)
SaaS properties use the `cameleer.saas.*` prefix (env vars: `CAMELEER_SAAS_*`). Two groups:
**Identity** (`cameleer.saas.identity.*` / `CAMELEER_SAAS_IDENTITY_*`):
- Logto endpoint, M2M credentials, bootstrap file path — used by `LogtoConfig.java`
**Provisioning** (`cameleer.saas.provisioning.*` / `CAMELEER_SAAS_PROVISIONING_*`):
| Env var | Spring property | Purpose |
|---------|----------------|---------|
| `CAMELEER_SAAS_PROVISIONING_SERVERIMAGE` | `cameleer.saas.provisioning.serverimage` | Docker image for per-tenant server containers |
| `CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE` | `cameleer.saas.provisioning.serveruiimage` | Docker image for per-tenant UI containers |
| `CAMELEER_SAAS_PROVISIONING_NETWORKNAME` | `cameleer.saas.provisioning.networkname` | Shared services Docker network (compose default) |
| `CAMELEER_SAAS_PROVISIONING_TRAEFIKNETWORK` | `cameleer.saas.provisioning.traefiknetwork` | Traefik Docker network for routing |
| `CAMELEER_SAAS_PROVISIONING_PUBLICHOST` | `cameleer.saas.provisioning.publichost` | Public hostname (same value as infrastructure `PUBLIC_HOST`) |
| `CAMELEER_SAAS_PROVISIONING_PUBLICPROTOCOL` | `cameleer.saas.provisioning.publicprotocol` | Public protocol (same value as infrastructure `PUBLIC_PROTOCOL`) |
**Note:** `PUBLIC_HOST` and `PUBLIC_PROTOCOL` remain as infrastructure env vars for Traefik and Logto containers. The SaaS app reads its own copies via the `CAMELEER_SAAS_PROVISIONING_*` prefix. `LOGTO_ENDPOINT` and `LOGTO_DB_PASSWORD` are infrastructure env vars for the Logto service and are unchanged.
### 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"]`.
**CRITICAL:** `additionalScopes` MUST include `urn:logto:scope:organizations` and `urn:logto:scope:organization_roles` — without these, Logto doesn't populate `context.user.organizationRoles` in the Custom JWT script, so the `roles` claim is empty and all users get `defaultRoles` (VIEWER). The server's `OidcAuthController.applyClaimMappings()` uses OIDC token roles (from Custom JWT) as fallback when no DB claim mapping rules exist: claim mapping rules > OIDC token roles > defaultRoles.
### 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-tenant-{slug}` (primary, isolation) + `cameleer-traefik` (routing) + `cameleer-env-{tenantId}-{envSlug}` (environment isolation)
### Bootstrap (`docker/logto-bootstrap.sh`)
Idempotent script run via `logto-bootstrap` init container. **Clean slate** — no example tenant, no viewer user, no server configuration. Phases:
1. Wait for Logto health (no server to wait for — servers are provisioned per-tenant)
2. Get Management API token (reads `m-default` secret from DB)
3. Create Logto apps (SPA, Traditional Web App 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 admin user (platform owner with Logto console access)
7b. Configure Logto Custom JWT for access tokens (maps org roles -> `roles` claim: owner->server:admin, operator->server:operator, viewer->server:viewer; saas-vendor global role -> server:admin)
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`
12. (Optional) Vendor seed: create `saas-vendor` global role, vendor user, grant Logto console access (`VENDOR_SEED_ENABLED=true` in dev).
The compose stack is: Traefik + traefik-certs (init) + PostgreSQL + ClickHouse + Logto + logto-bootstrap (init) + cameleer-saas. No `cameleer3-server` or `cameleer3-server-ui` in compose — those are provisioned per-tenant by `DockerTenantProvisioner`.
### Tenant Provisioning Flow
When vendor creates a tenant via `VendorTenantService`:
**Synchronous (in `createAndProvision`):**
1. Create `TenantEntity` (status=PROVISIONING) + Logto organization
2. Create admin user in Logto with owner org role
3. Add vendor user to new org for support access
4. Register OIDC redirect URIs for `/t/{slug}/oidc/callback` on Logto Traditional Web App
5. Generate license (tier-appropriate, 365 days)
6. Return immediately — UI shows provisioning spinner, polls via `refetchInterval`
**Asynchronous (in `provisionAsync`, `@Async`):**
7. Create tenant-isolated Docker network (`cameleer-tenant-{slug}`)
8. Create server container with env vars, Traefik labels (`traefik.docker.network`), health check, Docker socket bind, JAR volume, certs volume (ro)
9. Create UI container with `CAMELEER_API_URL`, `BASE_PATH`, Traefik strip-prefix labels
10. Wait for health check (`/api/v1/health`, not `/actuator/health` which requires auth)
11. Push license token to server via M2M API
12. Push OIDC config (Traditional Web App credentials + `additionalScopes: [urn:logto:scope:organizations, urn:logto:scope:organization_roles]`) to server for SSO
13. Update tenant status -> ACTIVE (or set `provisionError` on failure)
**Server restart** (available to vendor + tenant admin):
- `POST /api/vendor/tenants/{id}/restart` (vendor) and `POST /api/tenant/server/restart` (tenant)
- Calls `TenantProvisioner.stop(slug)` then `start(slug)` — restarts server + UI containers only (same image)
**Server upgrade** (available to vendor + tenant admin):
- `POST /api/vendor/tenants/{id}/upgrade` (vendor) and `POST /api/tenant/server/upgrade` (tenant)
- Calls `TenantProvisioner.upgrade(slug)` — removes server + UI containers, force-pulls latest images (preserves app containers, volumes, networks), then `provisionAsync()` re-creates containers with the new image + pushes license + OIDC config
**Tenant delete** cleanup:
- `DockerTenantProvisioner.remove(slug)` — label-based container removal (`cameleer.tenant={slug}`), env network cleanup, tenant network removal, JAR volume removal
- `TenantDataCleanupService.cleanup(slug)` — drops PostgreSQL `tenant_{slug}` schema, deletes ClickHouse data (GDPR)
**Password management** (tenant portal):
- `POST /api/tenant/password` — tenant admin changes own Logto password (via `@AuthenticationPrincipal` JWT subject)
- `POST /api/tenant/team/{userId}/password` — tenant admin resets a team member's Logto password (validates org membership first)
- `POST /api/tenant/server/admin-password` — tenant admin resets the server's built-in local admin password (via M2M API to `POST /api/v1/admin/users/user:admin/password`)
## 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
- V011 — add provisioning fields (server_endpoint, provision_error)
- V012 — certificates table + tenants.ca_applied_at
- V013 — tenant_ca_certs (per-tenant CA certificates with PEM storage)
## 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 vendor management plane (frontend + JAR baked in)
- `cameleer-logto` — custom Logto with sign-in UI baked in
- `cameleer3-server` / `cameleer3-server-ui` — provisioned per-tenant (not in compose, created by `DockerTenantProvisioner`)
- `cameleer-runtime-base` — base image for deployed apps (agent JAR + JRE). CI downloads latest agent SNAPSHOT from Gitea Maven registry. Uses `CAMELEER_SERVER_RUNTIME_SERVERURL` 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`, `VENDOR_SEED_ENABLED: true`. 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 for tenant provisioning.
- Design system: import from `@cameleer/design-system` (Gitea npm registry)
## Disabled Skills
- Do NOT use any `gsd:*` skills in this project. This includes all `/gsd:` prefixed commands.
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **cameleer-saas** (2472 symbols, 5338 relationships, 207 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
## When Debugging
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
3. `READ gitnexus://repo/cameleer-saas/process/{processName}` — trace the full execution flow step by step
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
## When Refactoring
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
## Never Do
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
## Tools Quick Reference
| Tool | When to use | Command |
|------|-------------|---------|
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
## Impact Risk Levels
| Depth | Meaning | Action |
|-------|---------|--------|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
## Resources
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/cameleer-saas/context` | Codebase overview, check index freshness |
| `gitnexus://repo/cameleer-saas/clusters` | All functional areas |
| `gitnexus://repo/cameleer-saas/processes` | All execution flows |
| `gitnexus://repo/cameleer-saas/process/{name}` | Step-by-step execution trace |
## Self-Check Before Finishing
Before completing any code modification task, verify:
1. `gitnexus_impact` was run for all modified symbols
2. No HIGH/CRITICAL risk warnings were ignored
3. `gitnexus_detect_changes()` confirms changes match expected scope
4. All d=1 (WILL BREAK) dependents were updated
## Keeping the Index Fresh
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
```bash
npx gitnexus analyze
```
If the index previously included embeddings, preserve them by adding `--embeddings`:
```bash
npx gitnexus analyze --embeddings
```
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
## CLI
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
<!-- gitnexus:end -->

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

447
HOWTO.md Normal file
View File

@@ -0,0 +1,447 @@
# 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:
| Service | Image | Port | Purpose |
|---------|-------|------|---------|
| **traefik-certs** | alpine:latest | — | Init container: generates self-signed cert or copies user-supplied cert |
| **traefik** | traefik:v3 | 80, 443, 3002 | Reverse proxy, TLS termination, 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 + vendor UI |
| **clickhouse** | clickhouse-server:latest | 8123* | Trace/metrics/log storage |
*Ports exposed to host only with `docker-compose.dev.yml` overlay.
Per-tenant `cameleer3-server` and `cameleer3-server-ui` containers are provisioned dynamically by `DockerTenantProvisioner` — they are NOT part of the compose stack.
## Installation
### 1. Environment Configuration
```bash
cp .env.example .env
```
Edit `.env` and set at minimum:
```bash
# Change in production
POSTGRES_PASSWORD=<strong-password>
# Logto M2M credentials (auto-provisioned by bootstrap, or get from Logto admin console)
CAMELEER_SAAS_IDENTITY_M2MCLIENTID=
CAMELEER_SAAS_IDENTITY_M2MCLIENTSECRET=
```
### 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. TLS Certificate (Optional)
By default, the `traefik-certs` init container generates a self-signed certificate for `PUBLIC_HOST`. To supply your own certificate at bootstrap time, set these env vars in `.env`:
```bash
CERT_FILE=/path/to/cert.pem # PEM-encoded certificate
KEY_FILE=/path/to/key.pem # PEM-encoded private key
CA_FILE=/path/to/ca.pem # Optional: CA bundle (for private CA trust)
```
The init container validates that the key matches the certificate before accepting. If validation fails, the container exits with an error.
**Runtime certificate replacement** is available via the vendor UI at `/vendor/certificates`:
- Upload a new cert+key+CA bundle (staged, not yet active)
- Validate and activate (atomic swap, Traefik hot-reloads)
- Roll back to the previous certificate if needed
- Track which tenants need a restart to pick up CA bundle changes
### 4. 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
```
### 5. 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`:
```
CAMELEER_SAAS_IDENTITY_M2MCLIENTID=<app-id>
CAMELEER_SAAS_IDENTITY_M2MCLIENTSECRET=<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) |
### Vendor: Certificates (platform:admin)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/vendor/certificates` | Overview (active, staged, archived, stale count) |
| POST | `/api/vendor/certificates/stage` | Upload cert+key+CA (multipart) |
| POST | `/api/vendor/certificates/activate` | Promote staged -> active |
| POST | `/api/vendor/certificates/restore` | Swap archived <-> active |
| DELETE | `/api/vendor/certificates/staged` | Discard staged cert |
| GET | `/api/vendor/certificates/stale-tenants` | Count tenants needing CA restart |
### Vendor: Tenants (platform:admin)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/vendor/tenants` | List all tenants (includes fleet health: agentCount, environmentCount, agentLimit) |
| POST | `/api/vendor/tenants` | Create tenant (async provisioning) |
| GET | `/api/vendor/tenants/{id}` | Tenant detail + server state |
| POST | `/api/vendor/tenants/{id}/restart` | Restart server containers |
| POST | `/api/vendor/tenants/{id}/suspend` | Suspend tenant |
| POST | `/api/vendor/tenants/{id}/activate` | Activate tenant |
| DELETE | `/api/vendor/tenants/{id}` | Delete tenant |
| POST | `/api/vendor/tenants/{id}/license` | Renew license |
### Tenant Portal (org-scoped)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/tenant/dashboard` | Tenant dashboard data |
| GET | `/api/tenant/license` | License details |
| POST | `/api/tenant/server/restart` | Restart server |
| GET | `/api/tenant/team` | List team members |
| POST | `/api/tenant/team/invite` | Invite team member |
| DELETE | `/api/tenant/team/{userId}` | Remove team member |
| GET | `/api/tenant/settings` | Tenant settings |
| GET | `/api/tenant/sso` | List SSO connectors |
| POST | `/api/tenant/sso` | Create SSO connector |
| GET | `/api/tenant/ca` | List tenant CA certificates |
| POST | `/api/tenant/ca` | Upload CA cert (staged) |
| POST | `/api/tenant/ca/{id}/activate` | Activate staged CA cert |
| DELETE | `/api/tenant/ca/{id}` | Remove CA cert |
| GET | `/api/tenant/audit` | Tenant audit log |
### 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,13 +8,28 @@ services:
logto:
ports:
- "3001:3001"
- "3002:3002"
logto-bootstrap:
environment:
VENDOR_SEED_ENABLED: "true"
cameleer-saas:
ports:
- "8080:8080"
volumes:
- ./ui/dist:/app/static
- /var/run/docker.sock:/var/run/docker.sock
group_add:
- "0"
environment:
SPRING_PROFILES_ACTIVE: dev
SPRING_WEB_RESOURCES_STATIC_LOCATIONS: file:/app/static/,classpath:/static/
CAMELEER_SAAS_PROVISIONING_PUBLICHOST: ${PUBLIC_HOST:-localhost}
CAMELEER_SAAS_PROVISIONING_PUBLICPROTOCOL: ${PUBLIC_PROTOCOL:-https}
CAMELEER_SAAS_PROVISIONING_SERVERIMAGE: gitea.siegeln.net/cameleer/cameleer3-server:${VERSION:-latest}
CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE: gitea.siegeln.net/cameleer/cameleer3-server-ui:${VERSION:-latest}
CAMELEER_SAAS_PROVISIONING_NETWORKNAME: cameleer-saas_cameleer
CAMELEER_SAAS_PROVISIONING_TRAEFIKNETWORK: cameleer-traefik
clickhouse:
ports:

View File

@@ -1,19 +1,25 @@
services:
traefik:
image: traefik:v3
image: ${TRAEFIK_IMAGE:-gitea.siegeln.net/cameleer/cameleer-traefik}:${VERSION:-latest}
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "${HTTP_PORT:-80}:80"
- "${HTTPS_PORT:-443}:443"
- "${LOGTO_CONSOLE_PORT:-3002}:3002"
environment:
PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
CERT_FILE: ${CERT_FILE:-}
KEY_FILE: ${KEY_FILE:-}
CA_FILE: ${CA_FILE:-}
volumes:
- certs:/certs
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik.yml:/etc/traefik/traefik.yml:ro
- acme:/etc/traefik/acme
networks:
- cameleer
- cameleer-traefik
postgres:
image: postgres:16-alpine
image: ${POSTGRES_IMAGE:-gitea.siegeln.net/cameleer/cameleer-postgres}:${VERSION:-latest}
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-cameleer_saas}
@@ -21,31 +27,85 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
volumes:
- 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
networks:
- cameleer
clickhouse:
image: ${CLICKHOUSE_IMAGE:-gitea.siegeln.net/cameleer/cameleer-clickhouse}:${VERSION:-latest}
restart: unless-stopped
environment:
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-cameleer_ch}
volumes:
- chdata:/var/lib/clickhouse
healthcheck:
test: ["CMD-SHELL", "clickhouse-client --password ${CLICKHOUSE_PASSWORD:-cameleer_ch} --query 'SELECT 1'"]
interval: 10s
timeout: 5s
retries: 3
labels:
- prometheus.scrape=true
- prometheus.path=/metrics
- prometheus.port=9363
networks:
- 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:
condition: service_healthy
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}:${LOGTO_CONSOLE_PORT:-3002}
TRUST_PROXY_HEADER: 1
NODE_TLS_REJECT_UNAUTHORIZED: "${NODE_TLS_REJECT:-0}"
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}
VENDOR_SEED_ENABLED: "${VENDOR_SEED_ENABLED:-false}"
VENDOR_USER: ${VENDOR_USER:-vendor}
VENDOR_PASS: ${VENDOR_PASS:-vendor}
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))\" && test -f /data/logto-bootstrap.json"]
interval: 10s
timeout: 5s
retries: 60
start_period: 30s
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}:${LOGTO_CONSOLE_PORT:-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
volumes:
- bootstrapdata:/data
networks:
- cameleer
@@ -53,70 +113,39 @@ services:
image: ${CAMELEER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-saas}:${VERSION:-latest}
restart: unless-stopped
depends_on:
postgres:
logto:
condition: service_healthy
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./keys:/etc/cameleer/keys:ro
- bootstrapdata:/data/bootstrap:ro
- certs:/certs
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_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_SAAS_IDENTITY_LOGTOENDPOINT: ${LOGTO_ENDPOINT:-http://logto:3001}
CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
CAMELEER_SAAS_PROVISIONING_PUBLICPROTOCOL: ${PUBLIC_PROTOCOL:-https}
CAMELEER_SAAS_PROVISIONING_PUBLICHOST: ${PUBLIC_HOST:-localhost}
CAMELEER_SAAS_IDENTITY_M2MCLIENTID: ${LOGTO_M2M_CLIENT_ID:-}
CAMELEER_SAAS_IDENTITY_M2MCLIENTSECRET: ${LOGTO_M2M_CLIENT_SECRET:-}
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
networks:
- cameleer
cameleer3-server:
image: ${CAMELEER3_SERVER_IMAGE:-gitea.siegeln.net/cameleer/cameleer3-server}:${VERSION:-latest}
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
clickhouse:
condition: service_started
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-cameleer_saas}
CLICKHOUSE_URL: jdbc:clickhouse://clickhouse:8123/cameleer
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
networks:
- cameleer
clickhouse:
image: clickhouse/clickhouse-server:latest
restart: unless-stopped
volumes:
- chdata:/var/lib/clickhouse
healthcheck:
test: ["CMD-SHELL", "clickhouse-client --query 'SELECT 1'"]
interval: 10s
timeout: 5s
retries: 3
- 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
networks:
cameleer:
driver: bridge
cameleer-traefik:
name: cameleer-traefik
driver: bridge
volumes:
pgdata:
chdata:
acme:
certs:
bootstrapdata:

View File

@@ -0,0 +1,4 @@
FROM clickhouse/clickhouse-server:latest
COPY init.sql /docker-entrypoint-initdb.d/init.sql
COPY users.xml /etc/clickhouse-server/users.d/default-user.xml
COPY prometheus.xml /etc/clickhouse-server/config.d/prometheus.xml

View File

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

View File

@@ -0,0 +1,9 @@
<clickhouse>
<prometheus>
<endpoint>/metrics</endpoint>
<port>9363</port>
<metrics>true</metrics>
<events>true</events>
<asynchronous_metrics>true</asynchronous_metrics>
</prometheus>
</clickhouse>

View File

@@ -0,0 +1,16 @@
<clickhouse>
<users>
<default remove="remove">
</default>
<default>
<profile>default</profile>
<networks>
<ip>::/0</ip>
</networks>
<password from_env="CLICKHOUSE_PASSWORD" />
<quota>default</quota>
<access_management>0</access_management>
</default>
</users>
</clickhouse>

View File

@@ -0,0 +1,41 @@
#!/bin/sh
set -e
echo "[entrypoint] Seeding Logto database..."
npm run cli db seed -- --swe 2>/dev/null || true
echo "[entrypoint] Starting Logto..."
npm start &
LOGTO_PID=$!
echo "[entrypoint] Waiting for Logto to be ready..."
for i in $(seq 1 120); do
if 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))" 2>/dev/null; then
echo "[entrypoint] Logto is ready."
break
fi
if [ "$i" -eq 120 ]; then
echo "[entrypoint] ERROR: Logto not ready after 120s"
exit 1
fi
sleep 1
done
# Run bootstrap if not already done
BOOTSTRAP_FILE="/data/logto-bootstrap.json"
if [ -f "$BOOTSTRAP_FILE" ]; then
CACHED_SECRET=$(jq -r '.m2mClientSecret // empty' "$BOOTSTRAP_FILE" 2>/dev/null)
CACHED_SPA=$(jq -r '.spaClientId // empty' "$BOOTSTRAP_FILE" 2>/dev/null)
if [ -n "$CACHED_SECRET" ] && [ -n "$CACHED_SPA" ]; then
echo "[entrypoint] Bootstrap already complete."
else
echo "[entrypoint] Incomplete bootstrap found, re-running..."
/scripts/logto-bootstrap.sh
fi
else
echo "[entrypoint] Running bootstrap..."
/scripts/logto-bootstrap.sh
fi
echo "[entrypoint] Logto is running (PID $LOGTO_PID)."
wait $LOGTO_PID

View File

@@ -0,0 +1,3 @@
FROM postgres:16-alpine
COPY init-databases.sh /docker-entrypoint-initdb.d/init-databases.sh
RUN chmod +x /docker-entrypoint-initdb.d/init-databases.sh

View File

@@ -0,0 +1,9 @@
#!/bin/bash
set -e
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

View File

@@ -0,0 +1,7 @@
FROM traefik:v3
RUN apk add --no-cache openssl
COPY traefik.yml /etc/traefik/traefik.yml
COPY traefik-dynamic.yml /etc/traefik/dynamic.yml
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -0,0 +1,60 @@
#!/bin/sh
set -e
CERTS_DIR="/certs"
# Skip if certs already exist (idempotent)
if [ ! -f "$CERTS_DIR/cert.pem" ]; then
mkdir -p "$CERTS_DIR"
if [ -n "$CERT_FILE" ] && [ -n "$KEY_FILE" ]; then
# User-supplied certificate
echo "[certs] Installing user-supplied certificate..."
cp "$CERT_FILE" "$CERTS_DIR/cert.pem"
cp "$KEY_FILE" "$CERTS_DIR/key.pem"
if [ -n "$CA_FILE" ]; then
cp "$CA_FILE" "$CERTS_DIR/ca.pem"
fi
# Validate key matches cert
CERT_MOD=$(openssl x509 -noout -modulus -in "$CERTS_DIR/cert.pem" 2>/dev/null | md5sum)
KEY_MOD=$(openssl rsa -noout -modulus -in "$CERTS_DIR/key.pem" 2>/dev/null | md5sum)
if [ "$CERT_MOD" != "$KEY_MOD" ]; then
echo "[certs] ERROR: Certificate and key do not match!"
rm -f "$CERTS_DIR/cert.pem" "$CERTS_DIR/key.pem" "$CERTS_DIR/ca.pem"
exit 1
fi
SELF_SIGNED=false
echo "[certs] Installed user-supplied certificate."
else
# Generate self-signed certificate
HOST="${PUBLIC_HOST:-localhost}"
echo "[certs] Generating self-signed certificate for $HOST..."
openssl req -x509 -newkey rsa:4096 \
-keyout "$CERTS_DIR/key.pem" -out "$CERTS_DIR/cert.pem" \
-days 365 -nodes \
-subj "/CN=$HOST" \
-addext "subjectAltName=DNS:$HOST,DNS:*.$HOST"
SELF_SIGNED=true
echo "[certs] Generated self-signed certificate for $HOST."
fi
# Write metadata for SaaS app to seed DB
SUBJECT=$(openssl x509 -noout -subject -in "$CERTS_DIR/cert.pem" 2>/dev/null | sed 's/subject=//')
FINGERPRINT=$(openssl x509 -noout -fingerprint -sha256 -in "$CERTS_DIR/cert.pem" 2>/dev/null | sed 's/.*=//')
NOT_BEFORE=$(openssl x509 -noout -startdate -in "$CERTS_DIR/cert.pem" 2>/dev/null | sed 's/notBefore=//')
NOT_AFTER=$(openssl x509 -noout -enddate -in "$CERTS_DIR/cert.pem" 2>/dev/null | sed 's/notAfter=//')
HAS_CA=false
[ -f "$CERTS_DIR/ca.pem" ] && HAS_CA=true
cat > "$CERTS_DIR/meta.json" <<METAEOF
{"subject":"$SUBJECT","fingerprint":"$FINGERPRINT","selfSigned":$SELF_SIGNED,"hasCa":$HAS_CA,"notBefore":"$NOT_BEFORE","notAfter":"$NOT_AFTER"}
METAEOF
mkdir -p "$CERTS_DIR/staged" "$CERTS_DIR/prev"
chmod 775 "$CERTS_DIR" "$CERTS_DIR/staged" "$CERTS_DIR/prev"
chmod 660 "$CERTS_DIR"/*.pem 2>/dev/null || true
else
echo "[certs] Certificates already exist, skipping generation."
fi
# Start Traefik
exec traefik "$@"

View File

@@ -0,0 +1,24 @@
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
tls:
stores:
default:
defaultCertificate:
certFile: /certs/cert.pem
keyFile: /certs/key.pem

View File

@@ -4,11 +4,20 @@ api:
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
websecure:
address: ":443"
admin-console:
address: ":3002"
providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false
network: cameleer
file:
filename: /etc/traefik/dynamic.yml

View File

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

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

@@ -0,0 +1,688 @@
#!/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}"
# Vendor seed (optional — creates saas-vendor role + vendor user)
VENDOR_SEED_ENABLED="${VENDOR_SEED_ENABLED:-false}"
VENDOR_USER="${VENDOR_USER:-vendor}"
VENDOR_PASS="${VENDOR_PASS:-vendor}"
VENDOR_NAME="${VENDOR_NAME:-SaaS Vendor}"
# No server config — servers are provisioned dynamically by the vendor console
# 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\",\"${PROTO}://${HOST}/platform/\"]"
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 if not already available (deps are baked into cameleer-logto image)
if ! command -v jq >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1; then
if command -v apk >/dev/null 2>&1; then
apk add --no-cache jq curl >/dev/null 2>&1
elif command -v apt-get >/dev/null 2>&1; then
apt-get update -qq && apt-get install -y -qq jq curl >/dev/null 2>&1
fi
fi
# 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
# No server wait — servers are provisioned dynamically by the vendor console
# ============================================================
# 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
# Switch sign-in mode from Register to SignIn (admin user already created)
admin_api_patch "/api/sign-in-exp" '{"signInMode": "SignIn"}' >/dev/null 2>&1
log "Set 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
# No viewer user — tenant users are created by the vendor during tenant provisioning.
# No example organization — tenants are created via the vendor console.
# No server OIDC config — each provisioned server gets OIDC from env vars.
ORG_ID=""
# ============================================================
# 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",
"platformAdminUser": "$SAAS_ADMIN_USER",
"oidcIssuerUri": "${LOGTO_ENDPOINT}/oidc",
"oidcAudience": "$API_RESOURCE_INDICATOR"
}
EOF
chmod 644 "$BOOTSTRAP_FILE"
# ============================================================
# Phase 12: Vendor Seed (optional)
# ============================================================
if [ "$VENDOR_SEED_ENABLED" = "true" ]; then
log ""
log "=== Phase 12: Vendor Seed ==="
# Create saas-vendor global role with all API scopes
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
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 globally."
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
SEED_ORG_ID=$(echo "$ORGS" | jq -r ".[$i].id")
SEED_ORG_NAME=$(echo "$ORGS" | jq -r ".[$i].name")
api_post "/api/organizations/$SEED_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/$SEED_ORG_ID/users/$VENDOR_USER_ID/roles" >/dev/null 2>&1
fi
log " Added to org '$SEED_ORG_NAME' with owner role."
done
# Grant vendor user Logto console access (admin tenant, port 3002)
if [ -n "$ADMIN_TOKEN" ] && [ "$ADMIN_TOKEN" != "null" ]; then
log "Granting vendor Logto console access..."
VENDOR_CONSOLE_USER_ID=$(admin_api_get "/api/users?search=$VENDOR_USER" | jq -r ".[] | select(.username == \"$VENDOR_USER\") | .id" 2>/dev/null)
if [ -z "$VENDOR_CONSOLE_USER_ID" ] || [ "$VENDOR_CONSOLE_USER_ID" = "null" ]; then
VENDOR_CONSOLE_RESPONSE=$(admin_api_post "/api/users" "{
\"username\": \"$VENDOR_USER\",
\"password\": \"$VENDOR_PASS\",
\"name\": \"$VENDOR_NAME\"
}")
VENDOR_CONSOLE_USER_ID=$(echo "$VENDOR_CONSOLE_RESPONSE" | jq -r '.id')
log "Created vendor console user: $VENDOR_CONSOLE_USER_ID"
else
log "Vendor console user exists: $VENDOR_CONSOLE_USER_ID"
fi
if [ -n "$VENDOR_CONSOLE_USER_ID" ] && [ "$VENDOR_CONSOLE_USER_ID" != "null" ]; then
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')
V_ROLE_IDS="[]"
[ -n "$ADMIN_USER_ROLE_ID" ] && [ "$ADMIN_USER_ROLE_ID" != "null" ] && V_ROLE_IDS=$(echo "$V_ROLE_IDS" | jq ". + [\"$ADMIN_USER_ROLE_ID\"]")
[ -n "$ADMIN_ROLE_ID" ] && [ "$ADMIN_ROLE_ID" != "null" ] && V_ROLE_IDS=$(echo "$V_ROLE_IDS" | jq ". + [\"$ADMIN_ROLE_ID\"]")
[ "$V_ROLE_IDS" != "[]" ] && admin_api_post "/api/users/$VENDOR_CONSOLE_USER_ID/roles" "{\"roleIds\": $V_ROLE_IDS}" >/dev/null 2>&1
log "Vendor granted Logto console access."
fi
else
log "Skipping vendor console access (no admin token)."
fi
log "Vendor seed complete."
fi
log ""
log "=== Bootstrap complete! ==="
# dev only — remove credential logging in production
log " SPA Client ID: $SPA_ID"
if [ "$VENDOR_SEED_ENABLED" = "true" ]; then
log " Vendor: $VENDOR_USER / $VENDOR_PASS (role: saas-vendor)"
fi
log ""
log " No tenants created — use the vendor console to create tenants."
log ""

View File

@@ -0,0 +1,19 @@
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
# Agent JAR is copied during CI build from Gitea Maven registry
# ARG AGENT_JAR=cameleer3-agent-1.0-SNAPSHOT-shaded.jar
COPY agent.jar /app/agent.jar
ENTRYPOINT exec java \
-Dcameleer.export.type=${CAMELEER_EXPORT_TYPE:-HTTP} \
-Dcameleer.export.endpoint=${CAMELEER_SERVER_URL} \
-Dcameleer.agent.name=${HOSTNAME} \
-Dcameleer.agent.application=${CAMELEER_APPLICATION_ID:-default} \
-Dcameleer.agent.environment=${CAMELEER_ENVIRONMENT_ID:-default} \
-Dcameleer.routeControl.enabled=${CAMELEER_ROUTE_CONTROL_ENABLED:-false} \
-Dcameleer.replay.enabled=${CAMELEER_REPLAY_ENABLED:-false} \
-Dcameleer.health.enabled=true \
-Dcameleer.health.port=9464 \
-javaagent:/app/agent.jar \
-jar /app/app.jar

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 "$@"

999
docs/architecture.md Normal file
View File

@@ -0,0 +1,999 @@
# 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_SERVER_SECURITY_BOOTSTRAPTOKEN` 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_SERVER_SECURITY_BOOTSTRAPTOKEN` | API key for agent registration |
| `CAMELEER_EXPORT_TYPE` | `HTTP` |
| `CAMELEER_SERVER_RUNTIME_SERVERURL` | 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_SERVER_SECURITY_BOOTSTRAPTOKEN` 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 |
**Identity / OIDC:**
| Variable | Default | Description |
|---------------------------|------------|--------------------------------------------|
| `CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT` | (empty) | Logto internal URL (Docker-internal) |
| `CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT` | (empty) | Logto public URL (browser-accessible) |
| `CAMELEER_SAAS_IDENTITY_M2MCLIENTID` | (empty) | M2M app client ID (from bootstrap) |
| `CAMELEER_SAAS_IDENTITY_M2MCLIENTSECRET` | (empty) | M2M app client secret (from bootstrap) |
| `CAMELEER_SAAS_IDENTITY_SPACLIENTID` | (empty) | SPA app client ID (fallback; bootstrap preferred) |
**Provisioning** (`cameleer.saas.provisioning.*` / `CAMELEER_SAAS_PROVISIONING_*`):
| Variable | Default | Description |
|-----------------------------------|------------------------------------|----------------------------------|
| `CAMELEER_SAAS_PROVISIONING_SERVERIMAGE` | `gitea.siegeln.net/cameleer/cameleer3-server:latest` | Docker image for per-tenant server |
| `CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE` | `gitea.siegeln.net/cameleer/cameleer3-server-ui:latest` | Docker image for per-tenant UI |
| `CAMELEER_SAAS_PROVISIONING_NETWORKNAME` | `cameleer-saas_cameleer` | Shared services Docker network |
| `CAMELEER_SAAS_PROVISIONING_TRAEFIKNETWORK` | `cameleer-traefik` | Traefik Docker network |
| `CAMELEER_SAAS_PROVISIONING_PUBLICHOST` | `localhost` | Public hostname (same as infrastructure `PUBLIC_HOST`) |
| `CAMELEER_SAAS_PROVISIONING_PUBLICPROTOCOL` | `https` | Public protocol (same as infrastructure `PUBLIC_PROTOCOL`) |
| `CAMELEER_SAAS_PROVISIONING_DATASOURCEURL` | `jdbc:postgresql://postgres:5432/cameleer3` | PostgreSQL URL passed to tenant servers |
| `CAMELEER_SAAS_PROVISIONING_CLICKHOUSEURL` | `jdbc:clickhouse://clickhouse:8123/cameleer` | ClickHouse URL passed to tenant servers |
### 10.2 cameleer3-server (per-tenant)
Env vars injected into provisioned per-tenant server containers by `DockerTenantProvisioner`. All server properties use the `cameleer.server.*` prefix (env vars: `CAMELEER_SERVER_*`).
| Variable | Default / Value | 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 |
| `CAMELEER_SERVER_CLICKHOUSE_URL` | `jdbc:clickhouse://clickhouse:8123/cameleer` | ClickHouse JDBC URL |
| `CAMELEER_SERVER_TENANT_ID` | *(tenant slug)* | Tenant identifier for data isolation |
| `CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKEN` | *(generated)* | Agent bootstrap token |
| `CAMELEER_SERVER_SECURITY_JWTSECRET` | *(generated)* | JWT signing secret |
| `CAMELEER_SERVER_SECURITY_OIDC_ISSUERURI` | `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}/oidc` | OIDC issuer for M2M tokens |
| `CAMELEER_SERVER_SECURITY_OIDC_JWKSETURI` | `http://logto:3001/oidc/jwks` | Docker-internal JWK fetch |
| `CAMELEER_SERVER_SECURITY_OIDC_AUDIENCE` | `https://api.cameleer.local` | JWT audience validation |
| `CAMELEER_SERVER_SECURITY_CORSALLOWEDORIGINS` | `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}` | CORS for browser requests |
| `CAMELEER_SERVER_RUNTIME_ENABLED` | `true` | Enable Docker orchestration |
| `CAMELEER_SERVER_RUNTIME_SERVERURL` | `http://cameleer-server-{slug}:8081` | Per-tenant server URL |
| `CAMELEER_SERVER_RUNTIME_ROUTINGDOMAIN` | `${PUBLIC_HOST}` | Domain for Traefik routing |
| `CAMELEER_SERVER_RUNTIME_ROUTINGMODE` | `path` | `path` or `subdomain` routing |
| `CAMELEER_SERVER_RUNTIME_JARSTORAGEPATH` | `/data/jars` | JAR file storage directory |
| `CAMELEER_SERVER_RUNTIME_DOCKERNETWORK` | `cameleer-tenant-{slug}` | Primary network for app containers |
| `CAMELEER_SERVER_RUNTIME_JARDOCKERVOLUME` | `cameleer-jars-{slug}` | Docker volume for JAR sharing |
| `CAMELEER_JWT_SECRET` | `cameleer-dev-jwt-secret-...` | HMAC secret for internal JWTs |
| `CAMELEER_SERVER_TENANT_ID` | `default` | Tenant slug for data isolation |
| `CAMELEER_SERVER_SECURITY_OIDCISSUERURI` | (empty) | Logto issuer for M2M token validation |
| `CAMELEER_SERVER_SECURITY_OIDCAUDIENCE` | (empty) | Expected JWT audience |
### 10.3 logto
| Variable | Default | Description |
|---------------------|--------------------------|---------------------------------|
| `LOGTO_PUBLIC_ENDPOINT` | `http://localhost:3001`| Public-facing Logto URL |
| `LOGTO_ADMIN_ENDPOINT` | `http://localhost:3002`| Admin console URL (not exposed) |
### 10.4 postgres
| Variable | Default | Description |
|---------------------|-------------------|---------------------------------|
| `POSTGRES_DB` | `cameleer_saas` | Default database name |
| `POSTGRES_USER` | `cameleer` | PostgreSQL superuser |
| `POSTGRES_PASSWORD` | `cameleer_dev` | PostgreSQL password |
### 10.5 logto-bootstrap
| Variable | Default | Description |
|----------------------|----------------------------|--------------------------------|
| `SAAS_ADMIN_USER` | `admin` | Platform admin username |
| `SAAS_ADMIN_PASS` | `admin` | Platform admin password |
| `TENANT_ADMIN_USER` | `camel` | Default tenant admin username |
| `TENANT_ADMIN_PASS` | `camel` | Default tenant admin password |
| `CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKEN`| `default-bootstrap-token` | Agent bootstrap token |
### 10.6 Bootstrap Output
The bootstrap script writes `/data/logto-bootstrap.json` containing:
```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 |

File diff suppressed because it is too large Load Diff

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"
```

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,424 @@
# Phase 3: Runtime Orchestration + Environments
**Date:** 2026-04-04
**Status:** Draft
**Depends on:** Phase 2 (Tenants + Identity + Licensing)
**Gitea issue:** #26
## Context
Phase 2 delivered multi-tenancy, identity (Logto OIDC), and license management. The platform can create tenants and issue licenses, but there is nothing to run yet. Phase 3 is the core product differentiator: customers upload a Camel JAR, the platform builds an immutable container image with the cameleer3 agent auto-injected, and deploys it to a logical environment. This is "managed Camel runtime" — similar to Coolify or MuleSoft CloudHub, but purpose-built for Apache Camel with deep observability.
Docker-first. The `KubernetesRuntimeOrchestrator` is deferred to Phase 5.
**Single-node constraint:** Because Phase 3 builds images locally via Docker socket (no registry push), the cameleer-saas control plane and the Docker daemon must reside on the same host. This is inherent to the single-tenant Docker Compose stack and is acceptable for that target. In K8s mode (Phase 5), images are built via Kaniko and pushed to a registry, removing this constraint.
## Key Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| JAR delivery | Direct HTTP upload (multipart) | Simplest path. Git-based and image-ref options can be added later. |
| Agent JAR source | Bundled in `cameleer-runtime-base` image | Version-locked to platform release. Updated by rebuilding the platform image with the new agent version. No runtime network dependency. |
| Build speed | Pre-built base image + single-layer customer add | Customer image build is `FROM base` + `COPY app.jar`. ~1-3 seconds. |
| Deployment model | Async with polling | Image builds are inherently slow. Deploy returns immediately with deployment ID. Client polls for status. |
| Entity hierarchy | Environment → App → Deployment | User thinks "I'm in dev, deploy my app." Environment is the workspace context. |
| Environment provisioning | Hybrid auto + manual | Every tenant gets a `default` environment on creation. Additional environments created manually, tier limit enforced. |
| Cross-environment isolation | Logical (not network) | Docker single-tenant mode — customer owns the stack. Data separated by `environmentId` in cameleer3-server. Network isolation is a K8s Phase 5 concern. |
| Container networking | Shared `cameleer` bridge network | Customer containers join the existing network. Agent reaches cameleer3-server at `http://cameleer3-server:8081`. |
| Container naming | `{tenant-slug}-{env-slug}-{app-slug}` | Human-readable, unique, identifies tenant+environment+app at a glance. |
| Bootstrap tokens | Shared `CAMELEER_AUTH_TOKEN` from cameleer3-server config | Platform reads the existing token and injects it into customer containers. Environment separation via agent `environmentId` claim, not token. Per-environment tokens deferred to K8s Phase 5. |
| Health checking | Agent health endpoint (port 9464) | Guaranteed to exist, no user config needed. User-defined health endpoints deferred. |
| Inbound HTTP routing | Not in Phase 3 | Most Camel apps are consumers (queues, polls), not servers. Traefik routing for customer apps deferred to Phase 4/4.5. |
| Container logs | Captured via docker-java, written to ClickHouse | Unified log query surface from day 1. Same pattern future app logs will use. |
| Resource constraints | cgroups via docker-java `mem_limit` + `cpu_shares` | Protect the control plane from noisy neighbors. Tier-based defaults. Even in single-tenant Docker mode, a runaway Camel app shouldn't starve Traefik/Postgres/Logto. |
| Orchestrator metadata | JSONB field on deployment entity | Docker stores `containerId`. K8s (Phase 5) stores `namespace`, `deploymentName`, `gitCommit`. Same table, different orchestrator. |
## Data Model
### Environment Entity
```sql
CREATE TABLE environments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
slug VARCHAR(100) NOT NULL,
display_name VARCHAR(255) NOT NULL,
bootstrap_token TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, slug)
);
CREATE INDEX idx_environments_tenant_id ON environments(tenant_id);
```
- `slug` — URL-safe, immutable, unique per tenant. Auto-created environment gets slug `default`.
- `display_name` — User-editable. Auto-created environment gets `Default`.
- `bootstrap_token` — The `CAMELEER_AUTH_TOKEN` value used for customer containers in this environment. In Docker mode, all environments share the same value (read from platform config). In K8s mode (Phase 5), can be unique per environment.
- `status``ACTIVE` or `SUSPENDED`.
### App Entity
```sql
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,
jar_storage_path VARCHAR(500),
jar_checksum VARCHAR(64),
jar_original_filename VARCHAR(255),
jar_size_bytes BIGINT,
current_deployment_id UUID,
previous_deployment_id UUID,
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);
```
- `slug` — URL-safe, immutable, unique per environment.
- `jar_storage_path` — Relative path to uploaded JAR (e.g., `tenants/{tenant-slug}/envs/{env-slug}/apps/{app-slug}/app.jar`). Relative to the configured storage root (`cameleer.runtime.jar-storage-path`). Makes it easy to migrate the storage volume to a different mount point or cloud provider.
- `jar_checksum` — SHA-256 hex digest of the uploaded JAR.
- `current_deployment_id` — Points to the active deployment. Nullable (app created but never deployed).
- `previous_deployment_id` — Points to the last known good deployment. When a new deploy succeeds, `current` becomes the new one and `previous` becomes the old `current`. When a deploy fails, `current` stays as the failed one but `previous` still points to the last good version, enabling a rollback button.
### Deployment Entity
```sql
CREATE TABLE deployments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
image_ref VARCHAR(500) NOT NULL,
desired_status VARCHAR(20) NOT NULL DEFAULT 'RUNNING',
observed_status VARCHAR(20) NOT NULL DEFAULT 'BUILDING',
orchestrator_metadata JSONB DEFAULT '{}',
error_message TEXT,
deployed_at TIMESTAMPTZ,
stopped_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(app_id, version)
);
CREATE INDEX idx_deployments_app_id ON deployments(app_id);
```
- `version` — Sequential per app (1, 2, 3...). Incremented on each deploy.
- `image_ref` — Docker image reference, e.g., `cameleer-runtime-{tenant}-{app}:v3`.
- `desired_status` — What the user wants: `RUNNING`, `STOPPED`.
- `observed_status` — What the platform sees: `BUILDING`, `STARTING`, `RUNNING`, `FAILED`, `STOPPED`.
- `orchestrator_metadata` — Docker mode: `{"containerId": "abc123"}`. K8s mode (Phase 5): `{"namespace": "...", "deploymentName": "...", "gitCommit": "..."}`.
- `error_message` — Populated when `observed_status` is `FAILED`. Build error, startup crash, etc.
## Component Architecture
### RuntimeOrchestrator Interface
```java
public interface RuntimeOrchestrator {
String buildImage(BuildImageRequest request);
void startContainer(StartContainerRequest request);
void stopContainer(String containerId);
void removeContainer(String containerId);
ContainerStatus getContainerStatus(String containerId);
void streamLogs(String containerId, LogConsumer consumer);
}
```
- Single interface, implemented by `DockerRuntimeOrchestrator` (Phase 3) and `KubernetesRuntimeOrchestrator` (Phase 5).
- Injected via Spring `@Profile` or `@ConditionalOnProperty`.
- Request objects carry all context (image name, env vars, network, labels, etc.).
### DockerRuntimeOrchestrator
Uses `com.github.docker-java:docker-java` library. Connects via Docker socket (`/var/run/docker.sock`).
**buildImage:**
1. Creates a temporary build context directory
2. Writes a Dockerfile:
```dockerfile
FROM cameleer-runtime-base:{platform-version}
COPY app.jar /app/app.jar
```
3. Copies the customer JAR as `app.jar`
4. Calls `docker build` via docker-java
5. Tags as `cameleer-runtime-{tenant-slug}-{app-slug}:v{version}`
6. Returns the image reference
**startContainer:**
1. Creates container with:
- Image: the built image reference
- Name: `{tenant-slug}-{env-slug}-{app-slug}`
- Network: `cameleer` (the platform bridge network)
- Environment variables:
- `CAMELEER_AUTH_TOKEN={bootstrap-token}`
- `CAMELEER_EXPORT_TYPE=HTTP`
- `CAMELEER_EXPORT_ENDPOINT=http://cameleer3-server:8081`
- `CAMELEER_APPLICATION_ID={app-slug}`
- `CAMELEER_ENVIRONMENT_ID={env-slug}`
- `CAMELEER_DISPLAY_NAME={tenant-slug}-{env-slug}-{app-slug}`
- Resource constraints (cgroups):
- `memory` / `memorySwap` — hard memory limit per container
- `cpuShares` — relative CPU weight (default 512)
- Defaults configurable via `cameleer.runtime.container-memory-limit` (default `512m`) and `cameleer.runtime.container-cpu-shares` (default `512`)
- Protects the control plane (Traefik, Postgres, Logto, cameleer-saas) from noisy neighbor Camel apps
- Health check: HTTP GET to agent health port 9464
2. Starts container
3. Returns container ID
**streamLogs:**
- Attaches to container stdout/stderr via docker-java `LogContainerCmd`
- Passes log lines to a `LogConsumer` callback (for ClickHouse ingestion)
### cameleer-runtime-base Image
A pre-built Docker image containing everything except the customer JAR:
```dockerfile
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY cameleer3-agent-{version}-shaded.jar /app/agent.jar
ENTRYPOINT exec java \
-Dcameleer.export.type=${CAMELEER_EXPORT_TYPE:-HTTP} \
-Dcameleer.export.endpoint=${CAMELEER_EXPORT_ENDPOINT} \
-Dcameleer.agent.name=${HOSTNAME} \
-Dcameleer.agent.application=${CAMELEER_APPLICATION_ID:-default} \
-Dcameleer.agent.environment=${CAMELEER_ENVIRONMENT_ID:-default} \
-Dcameleer.routeControl.enabled=${CAMELEER_ROUTE_CONTROL_ENABLED:-false} \
-Dcameleer.replay.enabled=${CAMELEER_REPLAY_ENABLED:-false} \
-Dcameleer.health.enabled=true \
-Dcameleer.health.port=9464 \
-javaagent:/app/agent.jar \
-jar /app/app.jar
```
- Built as part of the CI pipeline for cameleer-saas.
- Published to Gitea registry: `gitea.siegeln.net/cameleer/cameleer-runtime-base:{version}`.
- Version tracks the platform version + agent version (e.g., `0.2.0` includes agent `1.0-SNAPSHOT`).
- Updating the agent JAR = rebuild this image with the new agent version → rebuild cameleer-saas image → all new deployments use the new agent.
### JAR Upload
- `POST /api/environments/{eid}/apps` with multipart file
- Validation:
- File extension: `.jar`
- Max size: 200 MB (configurable via `cameleer.runtime.max-jar-size`)
- SHA-256 checksum computed and stored
- Storage: relative path `tenants/{tenant-slug}/envs/{env-slug}/apps/{app-slug}/app.jar` under the configured storage root (`cameleer.runtime.jar-storage-path`, default `/data/jars`)
- Docker volume `jardata` mounted into cameleer-saas container
- Database stores the relative path only — decoupled from mount point
- JAR is overwritten on re-upload (new deploy uses new JAR)
### Async Deployment Pipeline
1. **API receives deploy request** → creates `Deployment` entity with `observed_status=BUILDING` → returns deployment ID (HTTP 202 Accepted)
2. **Background thread** (Spring `@Async` with a bounded thread pool):
a. Calls `orchestrator.buildImage(...)` → updates `observed_status=STARTING`
b. Calls `orchestrator.startContainer(...)` → updates `observed_status=STARTING`
c. Polls agent health endpoint (port 9464) with timeout → updates to `RUNNING` or `FAILED`
d. On any failure → updates `observed_status=FAILED`, `error_message=...`
3. **Client polls** `GET /api/apps/{aid}/deployments/{did}` for status updates
4. **On success:** set `previous_deployment_id = old current_deployment_id`, then `current_deployment_id = new deployment`. Stop and remove the old container.
5. **On failure:** `current_deployment_id` is set to the failed deployment (so status is visible), `previous_deployment_id` still points to the last known good version. Enables rollback.
### Container Logs → ClickHouse
- When a container starts, platform attaches a log consumer via `orchestrator.streamLogs()`
- Log consumer batches lines and writes to ClickHouse table:
```sql
CREATE TABLE IF NOT EXISTS container_logs (
tenant_id UUID,
environment_id UUID,
app_id UUID,
deployment_id UUID,
timestamp DateTime64(3),
stream String, -- 'stdout' or 'stderr'
message String
) ENGINE = MergeTree()
ORDER BY (tenant_id, environment_id, app_id, timestamp);
```
- Logs retrieved via `GET /api/apps/{aid}/logs?since=...&limit=...` which queries ClickHouse
- ClickHouse TTL can enforce retention based on license `retention_days` limit (future enhancement)
### Bootstrap Token Handling
In Docker single-tenant mode, all environments share the single cameleer3-server instance and its single `CAMELEER_AUTH_TOKEN`. The platform reads this token from its own configuration (`cameleer.runtime.bootstrap-token` / `CAMELEER_AUTH_TOKEN` env var) and injects it into every customer container. No changes to cameleer3-server are needed.
Environment-level data separation happens at the agent registration level — the agent sends its `environmentId` claim when it registers, and cameleer3-server uses that to scope all data. The bootstrap token is the same across environments in a Docker stack.
The `bootstrap_token` column on the environment entity stores the token value used for that environment's containers. In Docker mode this is the same shared value for all environments. In K8s mode (Phase 5), each environment could have its own cameleer3-server instance with a unique token, enabling true per-environment token isolation.
## API Surface
### Environment Endpoints
```
POST /api/tenants/{tenantId}/environments
Body: { "slug": "dev", "displayName": "Development" }
Returns: 201 Created + EnvironmentResponse
Enforces: tier-based max_environments limit from license
GET /api/tenants/{tenantId}/environments
Returns: 200 + List<EnvironmentResponse>
GET /api/tenants/{tenantId}/environments/{environmentId}
Returns: 200 + EnvironmentResponse
PATCH /api/tenants/{tenantId}/environments/{environmentId}
Body: { "displayName": "New Name" }
Returns: 200 + EnvironmentResponse
DELETE /api/tenants/{tenantId}/environments/{environmentId}
Returns: 204 No Content
Precondition: no running apps in environment
Restriction: cannot delete the auto-created "default" environment
```
### App Endpoints
```
POST /api/environments/{environmentId}/apps
Multipart: file (JAR) + metadata { "slug": "order-service", "displayName": "Order Service" }
Returns: 201 Created + AppResponse
Validates: file extension, size, checksum
GET /api/environments/{environmentId}/apps
Returns: 200 + List<AppResponse>
GET /api/environments/{environmentId}/apps/{appId}
Returns: 200 + AppResponse (includes current deployment status)
PUT /api/environments/{environmentId}/apps/{appId}/jar
Multipart: file (JAR)
Returns: 200 + AppResponse
Purpose: re-upload JAR without creating new app
DELETE /api/environments/{environmentId}/apps/{appId}
Returns: 204 No Content
Side effect: stops running container, removes image
```
### Deployment Endpoints
```
POST /api/apps/{appId}/deploy
Body: {} (empty — uses current JAR)
Returns: 202 Accepted + DeploymentResponse (with deployment ID, status=BUILDING)
GET /api/apps/{appId}/deployments
Returns: 200 + List<DeploymentResponse> (ordered by version desc)
GET /api/apps/{appId}/deployments/{deploymentId}
Returns: 200 + DeploymentResponse (poll this for status updates)
POST /api/apps/{appId}/stop
Returns: 200 + DeploymentResponse (desired_status=STOPPED)
POST /api/apps/{appId}/restart
Returns: 202 Accepted + DeploymentResponse (stops + redeploys same image)
```
### Log Endpoints
```
GET /api/apps/{appId}/logs
Query: since (ISO timestamp), until (ISO timestamp), limit (default 500), stream (stdout/stderr/both)
Returns: 200 + List<LogEntry>
Source: ClickHouse container_logs table
```
## Tier Enforcement
| Tier | max_environments | max_agents (apps) |
|------|-----------------|-------------------|
| LOW | 1 | 3 |
| MID | 2 | 10 |
| HIGH | unlimited (-1) | 50 |
| BUSINESS | unlimited (-1) | unlimited (-1) |
- `max_environments` enforced on `POST /api/tenants/{tid}/environments`. The auto-created `default` environment counts toward the limit.
- `max_agents` enforced on `POST /api/environments/{eid}/apps`. Count is total apps across all environments in the tenant.
## Docker Compose Changes
The cameleer-saas service needs:
- Docker socket mount: `/var/run/docker.sock:/var/run/docker.sock` (already present in docker-compose.yml)
- JAR storage volume: `jardata:/data/jars`
- `cameleer-runtime-base` image must be available (pre-pulled or built locally)
The cameleer3-server `CAMELEER_AUTH_TOKEN` is read by cameleer-saas from shared environment config and injected into customer containers.
New volume in docker-compose.yml:
```yaml
volumes:
jardata:
```
## Dependencies
### New Maven Dependencies
```xml
<!-- 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>
```
### New Configuration Properties
```yaml
cameleer:
runtime:
max-jar-size: 209715200 # 200 MB
jar-storage-path: /data/jars
base-image: cameleer-runtime-base:latest
docker-network: cameleer
agent-health-port: 9464
health-check-timeout: 60 # seconds to wait for healthy status
deployment-thread-pool-size: 4
container-memory-limit: 512m # per customer container
container-cpu-shares: 512 # relative weight (default Docker is 1024)
clickhouse:
url: jdbc:clickhouse://clickhouse:8123/cameleer
```
## Verification Plan
1. Upload a sample Camel JAR via `POST /api/environments/{eid}/apps`
2. Deploy via `POST /api/apps/{aid}/deploy` — returns 202 with deployment ID
3. Poll `GET /api/apps/{aid}/deployments/{did}` — status transitions: `BUILDING` → `STARTING` → `RUNNING`
4. Container visible in `docker ps` as `{tenant}-{env}-{app}`
5. Container is on the `cameleer` network
6. cameleer3 agent registers with cameleer3-server (visible in server logs)
7. Agent health endpoint responds on port 9464
8. Container logs appear in ClickHouse `container_logs` table
9. `GET /api/apps/{aid}/logs` returns log entries
10. `POST /api/apps/{aid}/stop` stops the container, status becomes `STOPPED`
11. `POST /api/apps/{aid}/restart` restarts with same image
12. Re-upload JAR + redeploy creates deployment v2, stops v1
13. Tier limits enforced: LOW tenant cannot create more than 1 environment or 3 apps
14. Default environment auto-created on tenant provisioning

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,499 @@
# Cameleer SaaS Platform Redesign — Design Spec
**Date:** 2026-04-09
**Status:** Approved (brainstorming session)
**Scope:** Redesign the SaaS platform from a read-only tenant viewer into a functional vendor management plane with tenant provisioning, license management, and customer self-service.
## Context
The SaaS platform currently has 3 pages (Dashboard, License, Admin Tenants) — all read-only. It cannot create tenants, provision servers, manage licenses, or let customers configure their own settings. The backend has foundations (TenantService, LicenseService, LogtoManagementClient, ServerApiClient, audit logging) but none are exposed through management workflows.
This spec redesigns the platform around two personas — **vendor** (us) and **customer** (tenant admin) — with a clear separation of concerns.
### Architectural Decisions (from brainstorming)
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Server isolation | Shared data stores, isolated server per tenant | Server is already standalone; PostgreSQL/ClickHouse shared with tenant_id partitioning |
| Auth model | Hybrid — SaaS uses Logto, server uses customer OIDC | Clean separation: SaaS is vendor plane, server is product plane |
| Tenant admin access | Both SaaS + server, with SSO bridge | Admin configures in SaaS, jumps to server for operations |
| Server data in SaaS | License compliance + health summary | Quick pulse without duplicating the server dashboard |
| Provisioning mechanism | Docker API via docker-java | Already a dependency, same pattern as server's RuntimeOrchestrator |
| Docker/K8s support | Pluggable interface, Docker first | Mirror server's RuntimeOrchestrator + auto-detection pattern |
---
## 1. Personas & User Stories
### Vendor (platform:admin scope)
| ID | Story | Acceptance Criteria |
|----|-------|-------------------|
| V1 | As a vendor, I want to create a tenant so I can onboard a new customer | Form collects name, slug, tier. Creates DB record + Logto org. Status = PROVISIONING. |
| V2 | As a vendor, I want to provision a server for a tenant so they have a running Cameleer instance | After tenant creation, SaaS creates a cameleer3-server container via Docker API with correct env vars, network, and Traefik labels. Health check passes → status = ACTIVE. |
| V3 | As a vendor, I want to generate and assign a license to a tenant | License created with tier-appropriate features/limits/expiry. Token pushed to tenant's server via M2M API. |
| V4 | As a vendor, I want to suspend a tenant who hasn't paid | Suspend stops the server container and marks tenant SUSPENDED. Reactivation restarts it. |
| V5 | As a vendor, I want to view fleet health at a glance | Tenant list shows each tenant's server status (running/stopped/error), agent count vs limit, license expiry. |
| V6 | As a vendor, I want to delete/offboard a tenant | Stops and removes server container, revokes license, marks tenant DELETED. |
### Customer (tenant admin, org-scoped JWT)
| ID | Story | Acceptance Criteria |
|----|-------|-------------------|
| C1 | As a tenant admin, I want to see my dashboard with server health and license usage | Dashboard shows: server status (up/down), connected agents vs limit, environments vs limit, feature entitlements. |
| C2 | As a tenant admin, I want to configure external OIDC for my team | Form to set issuer URI, client ID, client secret, audience, claim mappings. SaaS pushes config to the tenant's server via M2M API. |
| C3 | As a tenant admin, I want to manage team members | View/invite/remove users in Logto org. Assign roles (owner/operator/viewer) that flow through to server access. |
| C4 | As a tenant admin, I want to access the server dashboard seamlessly | "Open Server Dashboard" navigates to the tenant's server URL. Initial auth via Logto (same OIDC provider until customer configures their own). |
| C5 | As a tenant admin, I want to view my license details | Tier, features, limits, validity, days remaining — enriched with actual usage data from server. |
| C6 | As a tenant admin, I want to see my organization settings | Tenant name, slug, tier, created date. Read-only (tier changes go through vendor). |
---
## 2. Information Architecture
### Route Structure
```
/platform/
├── /vendor/ (platform:admin only)
│ ├── /vendor/tenants Tenant list with fleet health overview
│ ├── /vendor/tenants/new Create tenant flow (create → provision → license)
│ └── /vendor/tenants/:id Tenant detail — server status, license, actions
├── /tenant/ (org-scoped, any authenticated user)
│ ├── /tenant/ Dashboard — server health + license usage
│ ├── /tenant/license License details + usage vs limits
│ ├── /tenant/oidc External OIDC configuration
│ ├── /tenant/team Team members + role management
│ └── /tenant/settings Organization settings
├── /login Logto OIDC redirect
└── /callback Logto callback handler
```
### Navigation
**Sidebar adapts to persona:**
- **Vendor** (`platform:admin`): "Tenants" section at top. If a tenant is selected (e.g., viewing detail), the tenant portal sections appear below for support/debugging.
- **Customer** (no `platform:admin`): Dashboard, License, OIDC, Team, Settings.
- **Footer**: "Open Server Dashboard" (contextual to current tenant).
**Landing page:**
- `platform:admin``/vendor/tenants`
- Otherwise → `/tenant/`
### What Happens to Existing Pages
| Current | Becomes | Changes |
|---------|---------|---------|
| `DashboardPage` | `/tenant/` | Add health data from server, license usage indicators |
| `LicensePage` | `/tenant/license` | Add usage enrichment (agents used/limit, envs used/limit) |
| `AdminTenantsPage` | `/vendor/tenants` | Full CRUD, health indicators, provision/suspend/delete actions |
---
## 3. Provisioning Architecture
### Pluggable Interface
Following the server's `RuntimeOrchestrator` pattern with auto-detection:
```java
public interface TenantProvisioner {
boolean isAvailable();
ProvisionResult provision(TenantProvisionRequest request);
void start(String tenantId);
void stop(String tenantId);
void remove(String tenantId);
ServerStatus getStatus(String tenantId);
String getServerEndpoint(String tenantId);
}
```
**Auto-detection** (same pattern as server's `RuntimeOrchestratorAutoConfig`):
```java
@Configuration
public class TenantProvisionerAutoConfig {
@Bean
TenantProvisioner tenantProvisioner() {
if (Files.exists(Path.of("/var/run/docker.sock"))) {
return new DockerTenantProvisioner(dockerClientConfig());
}
// Future: K8s detection (service account token)
return new DisabledTenantProvisioner();
}
}
```
### Docker Implementation
`DockerTenantProvisioner` uses docker-java to manage per-tenant server containers:
**Container specification per tenant:**
| Config | Value | Source |
|--------|-------|--------|
| Image | `gitea.siegeln.net/cameleer/cameleer3-server:${VERSION}` | Global config |
| Name | `cameleer-server-${tenant.slug}` | Derived from tenant |
| Network | `cameleer` + `cameleer-traefik` | Fixed networks from compose |
| DNS alias | `cameleer-server-${tenant.slug}` | For SaaS→server M2M calls |
| Health check | `wget -q -O- http://localhost:8081/actuator/health` | Server's actuator |
| Restart policy | `unless-stopped` | Standard for services |
**Environment variables injected per tenant:**
| Env var | Value | Purpose |
|---------|-------|---------|
| `SPRING_DATASOURCE_URL` | `jdbc:postgresql://postgres:5432/cameleer3` | Shared PostgreSQL |
| `CAMELEER_TENANT_ID` | `${tenant.slug}` | Tenant isolation key |
| `CAMELEER_OIDC_ISSUER_URI` | `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}/oidc` | Logto as initial OIDC |
| `CAMELEER_OIDC_JWK_SET_URI` | `http://logto:3001/oidc/jwks` | Docker-internal JWK |
| `CAMELEER_CORS_ALLOWED_ORIGINS` | `${PUBLIC_PROTOCOL}://${PUBLIC_HOST}` | Browser CORS |
| `CAMELEER_LICENSE_TOKEN` | `${license.token}` | License for this tenant |
| `CAMELEER_RUNTIME_ENABLED` | `true` | Enable Docker orchestration |
| `CAMELEER_SERVER_URL` | `http://cameleer-server-${slug}:8081` | Self-reference for agents |
| `CAMELEER_ROUTING_DOMAIN` | `${PUBLIC_HOST}` | Traefik routing domain |
| `CAMELEER_ROUTING_MODE` | `path` | Path-based routing |
**Traefik labels for per-tenant routing:**
```
traefik.enable=true
traefik.http.routers.server-${slug}.rule=PathPrefix(`/t/${slug}`)
traefik.http.routers.server-${slug}.tls=true
traefik.http.services.server-${slug}.loadbalancer.server.port=8081
```
**Server UI container per tenant:**
Each tenant also gets a `cameleer3-server-ui` container:
| Config | Value |
|--------|-------|
| Name | `cameleer-server-ui-${tenant.slug}` |
| Image | `gitea.siegeln.net/cameleer/cameleer3-server-ui:${VERSION}` |
| Env | `BASE_PATH=/t/${slug}` |
| Traefik | `PathPrefix(/t/${slug})` with `priority=2` (higher than API) |
The server UI serves static assets and proxies API calls to the backend. The `BASE_PATH` env var configures React Router's basename and nginx proxy target.
### Provision Flow
```
Vendor clicks "Create Tenant"
→ POST /api/vendor/tenants
1. Validate slug uniqueness
2. Create TenantEntity (status=PROVISIONING)
3. Create Logto organization
4. Generate license (tier-appropriate, 365 days)
5. Create server container (DockerTenantProvisioner.provision())
6. Create server UI container
7. Wait for health check (poll /actuator/health, timeout 60s)
8. Push license to server via M2M API (ServerApiClient)
9. Update status → ACTIVE
10. Audit log: TENANT_CREATE + TENANT_PROVISION + LICENSE_GENERATE
```
If provisioning fails at any step, the tenant remains in PROVISIONING status with an error message. The vendor can retry or delete.
### Suspend / Activate Flow
```
Suspend:
1. Stop server + UI containers (DockerTenantProvisioner.stop())
2. Set tenant status → SUSPENDED
3. Audit log: TENANT_SUSPEND
Activate:
1. Start server + UI containers (DockerTenantProvisioner.start())
2. Wait for health check
3. Set tenant status → ACTIVE
4. Audit log: TENANT_ACTIVATE
```
### Delete Flow
```
Delete:
1. Stop and remove server + UI containers (DockerTenantProvisioner.remove())
2. Revoke active license
3. Delete Logto organization (LogtoManagementClient.deleteOrganization())
4. Set tenant status → DELETED (soft delete, keep record for audit)
5. Audit log: TENANT_DELETE
```
---
## 4. Server Communication
### SaaS → Server (M2M API)
The existing `ServerApiClient` pattern (Logto M2M token, `X-Cameleer-Protocol-Version: 1` header) is extended for per-tenant endpoints:
```java
public class ServerApiClient {
// Existing: uses configured server-endpoint
// New: accepts dynamic endpoint per tenant
public ServerHealth getHealth(String serverEndpoint) { ... }
public void pushLicenseToken(String serverEndpoint, String token) { ... }
public void pushOidcConfig(String serverEndpoint, OidcConfigRequest config) { ... }
public ServerUsage getUsage(String serverEndpoint) { ... }
}
```
The `serverEndpoint` is resolved per tenant: `http://cameleer-server-${slug}:8081` (Docker-internal DNS).
### Health & Usage Data
**ServerHealth** (from server's `/actuator/health` + `/api/admin/status`):
- Server status: UP/DOWN
- Connected agents: count
- Active applications: count
- Error rate (last hour)
**ServerUsage** (from server API — new endpoint or existing data):
- Agent count vs license limit
- Environment count vs license limit
- Which features are actively used (topology, lineage, etc.)
The SaaS caches health data per tenant (refresh every 30s for the fleet view, on-demand for detail pages).
### SSO Bridge
**Initial state** (before customer OIDC): The tenant's server trusts Logto. The tenant admin has a Logto account. "Open Server Dashboard" navigates to `/t/{slug}/` — the server's OIDC flow detects the existing Logto session and authenticates the user.
**After customer OIDC**: The SaaS pushes the customer's OIDC config to the server via `ServerApiClient.pushOidcConfig()`. The server switches to trusting the customer's provider. The tenant admin authenticates via their company's OIDC when accessing the server.
---
## 5. Backend API Design
### Vendor Endpoints (platform:admin required)
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/vendor/tenants` | List all tenants with health summary |
| `POST` | `/api/vendor/tenants` | Create tenant (triggers full provisioning flow) |
| `GET` | `/api/vendor/tenants/{id}` | Tenant detail with server status |
| `PATCH` | `/api/vendor/tenants/{id}` | Update tenant metadata (name, tier) |
| `POST` | `/api/vendor/tenants/{id}/suspend` | Suspend tenant |
| `POST` | `/api/vendor/tenants/{id}/activate` | Reactivate tenant |
| `DELETE` | `/api/vendor/tenants/{id}` | Offboard tenant |
| `POST` | `/api/vendor/tenants/{id}/license` | Generate/renew license |
| `GET` | `/api/vendor/tenants/{id}/health` | Server health check (on-demand) |
### Tenant Endpoints (org-scoped, tenant from JWT)
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/tenant/dashboard` | Aggregated health + license usage |
| `GET` | `/api/tenant/license` | License details with usage data |
| `GET` | `/api/tenant/oidc` | Current OIDC configuration |
| `PUT` | `/api/tenant/oidc` | Update OIDC config (push to server) |
| `GET` | `/api/tenant/team` | Team members (from Logto org) |
| `POST` | `/api/tenant/team/invite` | Invite member |
| `PATCH` | `/api/tenant/team/{userId}/role` | Change member role |
| `DELETE` | `/api/tenant/team/{userId}` | Remove member |
| `GET` | `/api/tenant/settings` | Org settings |
### Existing Endpoints to Modify
| Current | Change |
|---------|--------|
| `GET /api/tenants` | Move to `/api/vendor/tenants`, add health data |
| `POST /api/tenants` | Move to `/api/vendor/tenants`, add provisioning |
| `GET /api/tenants/{id}` | Keep for backward compat, also available at `/api/vendor/tenants/{id}` |
| `GET /api/tenants/{id}/license` | Keep, also available at `/api/tenant/license` |
| `POST /api/tenants/{id}/license` | Move to `/api/vendor/tenants/{id}/license` |
| `GET /api/me` | Keep (used by OrgResolver) |
| `GET /api/config` | Keep (used by frontend bootstrap) |
---
## 6. Frontend Design
### Vendor Console
**Tenant List** (`/vendor/tenants`):
- DataTable with columns: Name, Slug, Tier (Badge), Status (Badge), Server (health indicator), Agents (used/limit), License (expiry or "None"), Created
- Row click → tenant detail
- "+ Create Tenant" button in header
- Status badges: ACTIVE (green), PROVISIONING (blue), SUSPENDED (amber), DELETED (gray)
- Server health: green dot (UP), red dot (DOWN), gray dot (no server)
**Create Tenant** (`/vendor/tenants/new`):
- Form with: Name, Slug (auto-generated from name, editable), Tier (dropdown: LOW/MID/HIGH/BUSINESS)
- On submit: shows provisioning progress (creating record → creating org → generating license → starting server → health check → done)
- Progress displayed as a step indicator or timeline
- On success: redirect to tenant detail
**Tenant Detail** (`/vendor/tenants/:id`):
- Header: Tenant name + tier badge + status badge
- KPI strip: Server Status, Agents (used/limit), Environments (used/limit), License (days remaining)
- Sections:
- **Server**: Status, endpoint URL, start/stop/restart actions
- **License**: Current license details, "Renew" button
- **Info**: Slug, created date, Logto org ID
- Actions: Suspend/Activate toggle, Delete (with confirmation)
### Tenant Portal
**Dashboard** (`/tenant/`):
- KPI strip: Server Status, Agents (used/limit), Environments (used/limit), License (days remaining)
- Quick links: "Open Server Dashboard", "View License", "Configure OIDC"
- If server is DOWN: prominent alert banner
**License** (`/tenant/license`):
- Reuses existing LicensePage layout
- Adds usage indicators: "2 of 3 agents", "1 of 1 environments"
- Progress bars for limits approaching capacity
- License token section (show/hide + copy)
**OIDC Configuration** (`/tenant/oidc`):
- Form: Issuer URI, Client ID, Client Secret (masked), Audience, Roles Claim
- Current status: "Using Logto (default)" or "External OIDC configured"
- Save pushes config to server via SaaS API
- "Test Connection" button (calls server's OIDC discovery endpoint)
- "Reset to Logto" button (reverts to default)
**Team Management** (`/tenant/team`):
- DataTable: Name, Email, Role (dropdown: Owner/Operator/Viewer), Actions (Remove)
- "+ Invite Member" button → form with email + role
- Role changes update Logto org membership
- Cannot remove the last owner
**Settings** (`/tenant/settings`):
- Read-only info: Name, Slug, Tier, Status, Created
- Server endpoint URL
- "Contact support to change tier" message (tier changes go through vendor)
### Shared Components
- **ServerStatusBadge**: Green dot + "Running", Red dot + "Stopped", Gray dot + "Provisioning"
- **UsageIndicator**: "2 / 3 agents" with progress bar, color-coded (green < 80%, amber < 100%, red = 100%)
- **ProvisioningProgress**: Step indicator for tenant creation flow
### Layout Changes
- Remove TopBar server controls (status filters, time range, auto-refresh) — these are not relevant to the SaaS platform. Use a simplified TopBar with breadcrumb, theme toggle, and user menu only.
- Sidebar: persona-aware navigation (vendor vs customer sections)
- Sidebar footer: "Open Server Dashboard" link with tenant-specific URL (`/t/{slug}/`)
---
## 7. Files to Create/Modify
### New Backend Files
| File | Purpose |
|------|---------|
| `provisioning/TenantProvisioner.java` | Pluggable provisioning interface |
| `provisioning/TenantProvisionRequest.java` | Provision request record |
| `provisioning/ProvisionResult.java` | Provision result record |
| `provisioning/ServerStatus.java` | Server health status record |
| `provisioning/DockerTenantProvisioner.java` | Docker implementation |
| `provisioning/DisabledTenantProvisioner.java` | No-op fallback |
| `provisioning/TenantProvisionerAutoConfig.java` | Auto-detection config |
| `vendor/VendorTenantController.java` | Vendor API endpoints |
| `vendor/VendorTenantService.java` | Vendor business logic (orchestrates provisioning + license + Logto) |
| `tenant/TenantPortalController.java` | Customer API endpoints |
| `tenant/TenantPortalService.java` | Customer business logic (reads from server, manages team) |
### Modified Backend Files
| File | Changes |
|------|---------|
| `identity/ServerApiClient.java` | Add per-tenant endpoint support, health/usage/OIDC methods |
| `identity/LogtoManagementClient.java` | Add user invite, role management, list org members |
| `tenant/TenantEntity.java` | Add `serverEndpoint` field, `provisionError` field |
| `tenant/TenantService.java` | Keep existing methods, used by VendorTenantService |
| `license/LicenseService.java` | Keep existing, add revoke method |
| `config/SecurityConfig.java` | Add vendor/tenant endpoint security rules |
| `config/TenantIsolationInterceptor.java` | Handle `/api/tenant/*` (resolve from JWT, no path variable) |
### New Frontend Files
| File | Purpose |
|------|---------|
| `pages/vendor/VendorTenantsPage.tsx` | Tenant list with fleet health |
| `pages/vendor/CreateTenantPage.tsx` | Create tenant wizard |
| `pages/vendor/TenantDetailPage.tsx` | Tenant detail with actions |
| `pages/tenant/TenantDashboardPage.tsx` | Customer dashboard (evolves from DashboardPage) |
| `pages/tenant/TenantLicensePage.tsx` | License with usage (evolves from LicensePage) |
| `pages/tenant/OidcConfigPage.tsx` | External OIDC configuration |
| `pages/tenant/TeamPage.tsx` | Team management |
| `pages/tenant/SettingsPage.tsx` | Organization settings |
| `components/ServerStatusBadge.tsx` | Shared server status indicator |
| `components/UsageIndicator.tsx` | License usage progress bar |
| `api/vendor-hooks.ts` | React Query hooks for vendor API |
| `api/tenant-hooks.ts` | React Query hooks for tenant API |
### Modified Frontend Files
| File | Changes |
|------|---------|
| `router.tsx` | Restructure routes: `/vendor/*`, `/tenant/*` |
| `components/Layout.tsx` | Persona-aware sidebar, simplified TopBar, tenant-specific server link |
| `auth/OrgResolver.tsx` | Handle vendor landing (redirect to `/vendor/tenants`) |
| `types/api.ts` | Add vendor/tenant API types |
| `api/client.ts` | No changes needed (generic fetch wrapper) |
### Files to Remove
| File | Reason |
|------|--------|
| `pages/DashboardPage.tsx` | Replaced by `tenant/TenantDashboardPage.tsx` |
| `pages/LicensePage.tsx` | Replaced by `tenant/TenantLicensePage.tsx` |
| `pages/AdminTenantsPage.tsx` | Replaced by `vendor/VendorTenantsPage.tsx` |
### Docker Changes
| File | Changes |
|------|---------|
| `docker-compose.yml` | Mount Docker socket into cameleer-saas container |
| `docker-compose.dev.yml` | Add Docker socket mount, group_add for Docker access |
### Database Migration
New migration `V011`:
- Add `server_endpoint` column to `tenants` (nullable VARCHAR, stores Docker-internal URL)
- Add `provision_error` column to `tenants` (nullable TEXT, stores last error message)
- Add `DELETED` to status enum (for soft-delete offboarding)
---
## 8. Existing Compose Stack Changes
The default `cameleer3-server` and `cameleer3-server-ui` containers in docker-compose.yml become the "bootstrap" server for the `default` tenant. When provisioning is enabled, new tenants get their own dynamically-created containers.
The existing compose stack continues to work as-is for development. The provisioner creates additional containers alongside the compose-managed ones.
For the `default` tenant (created by bootstrap), the SaaS recognizes the existing compose-managed server and doesn't try to provision a new one. This is detected by checking if a container named `cameleer-server-default` (or the compose-managed `cameleer3-server`) already exists.
---
## 9. Out of Scope
- **Kubernetes provisioning** — interface defined, implementation deferred
- **Billing/Stripe** — fields exist in DB, no integration in this spec
- **Mobile responsiveness** — deferred
- **Self-service signup** — tenants created by vendor only
- **Custom domains** — deferred
- **Email notifications** — deferred
- **Usage-based metering** — deferred (license limits are checked but not metered)
---
## 10. Related Issues
| Issue | Relevance |
|-------|-----------|
| #1 | Epic: SaaS Management Platform |
| #3 | Tenant Provisioning & Lifecycle |
| #25 | K8s Operational Layer (deferred) |
| #29 | Billing & Metering (deferred) |
| #37 | Admin: Tenant creation UI — superseded by this spec |
| #38 | Cross-app session management — addressed by SSO bridge |

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)

View File

@@ -0,0 +1,242 @@
# Certificate Management Design
## Problem
The platform currently generates a self-signed TLS certificate at bootstrap time via an Alpine init container. There is no way to supply a real certificate at bootstrap, replace it at runtime, or manage CA trust bundles for tenant enterprise SSO providers. Internal services bypass TLS verification with hardcoded flags (`CAMELEER_OIDC_TLS_SKIP_VERIFY=true`, `NODE_TLS_REJECT_UNAUTHORIZED=0`).
## Goals
1. Supply a cert+key at bootstrap time (env vars pointing to files)
2. Replace the platform TLS certificate at runtime via vendor UI
3. Manage a CA trust bundle (`ca.pem`) aggregating platform CA + tenant enterprise CAs
4. Stage certificates before activation (shadow certs)
5. Roll back to the previous certificate if activation causes issues
6. Flag tenants that need restart after CA bundle changes
7. Provider-based architecture: Docker now, K8s later
## Non-Goals
- ACME/Let's Encrypt integration (separate future work)
- Per-tenant TLS certificates (all tenants share the platform cert via Traefik)
- Client certificate authentication (mTLS)
## Architecture
### Provider Interface
```java
package net.siegeln.cameleer.saas.certificate;
public interface CertificateManager {
boolean isAvailable();
CertificateInfo getActive();
CertificateInfo getStaged();
CertificateInfo getArchived();
CertValidationResult stage(byte[] certPem, byte[] keyPem, byte[] caBundlePem);
void activate();
void restore();
void discardStaged();
void generateSelfSigned(String hostname);
byte[] getCaBundle();
}
```
Lives in `net.siegeln.cameleer.saas.certificate`. Implementation in `net.siegeln.cameleer.saas.provisioning` alongside `DockerTenantProvisioner`.
`DockerCertificateManager` writes to the Docker `certs` volume. Future `K8sCertificateManager` would manage K8s TLS Secrets + cert-manager CRDs.
### Records
```java
public record CertificateInfo(
String subject, String issuer, Instant notBefore, Instant notAfter,
boolean hasCaBundle, boolean selfSigned, String fingerprint
) {}
public record CertValidationResult(
boolean valid, List<String> errors, CertificateInfo info
) {}
```
### File Layout (Docker Volume)
```
/certs/
cert.pem <- ACTIVE platform cert (Traefik reads)
key.pem <- ACTIVE private key
ca.pem <- aggregated CA bundle (platform CA + tenant CAs)
meta.json <- bootstrap metadata for DB seeding
staged/
cert.pem <- STAGED cert
key.pem <- STAGED key
ca.pem <- STAGED CA bundle
prev/
cert.pem <- ARCHIVED (one previous)
key.pem
ca.pem
```
Atomic swap pattern: write to `*.wip`, validate, rename to final path.
### Database
```sql
-- V011__certificates.sql
CREATE TABLE certificates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
status VARCHAR(10) NOT NULL CHECK (status IN ('ACTIVE', 'STAGED', 'ARCHIVED')),
subject VARCHAR(500),
issuer VARCHAR(500),
not_before TIMESTAMPTZ,
not_after TIMESTAMPTZ,
fingerprint VARCHAR(128),
has_ca BOOLEAN NOT NULL DEFAULT FALSE,
self_signed BOOLEAN NOT NULL DEFAULT FALSE,
uploaded_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
activated_at TIMESTAMPTZ,
archived_at TIMESTAMPTZ
);
```
At most 3 rows: one per status. On activate: delete ARCHIVED -> ACTIVE becomes ARCHIVED -> STAGED becomes ACTIVE.
Tenant staleness tracked via `ca_applied_at` column on `tenants` table:
```sql
-- in same migration
ALTER TABLE tenants ADD COLUMN ca_applied_at TIMESTAMPTZ;
```
Tenants with `ca_applied_at < (active cert's activated_at)` are stale.
### State Transitions
```
Upload -> STAGED -> activate -> ACTIVE -> (next activate) -> ARCHIVED
^ |
+------ restore ---------------+
```
- **Activate staged**: delete ARCHIVED row+files, ACTIVE -> ARCHIVED (move files to prev/), STAGED -> ACTIVE (move files to root)
- **Restore archived**: swap ACTIVE <-> ARCHIVED (swap files and DB statuses)
- **Discard staged**: delete STAGED row + staged/ files
### Bootstrap Flow
The `traefik-certs` init container gains env var support:
```
1. cert.pem + key.pem exist in volume?
-> Yes: skip (idempotent)
-> No: continue
2. CERT_FILE + KEY_FILE env vars set?
-> Yes: copy to volume, validate (PEM parseable, key matches cert)
If CA_FILE set, copy as ca.pem
-> No: generate self-signed (current behavior)
3. Write /certs/meta.json with subject, fingerprint, self_signed flag
```
SaaS app reads `meta.json` on startup to seed the certificates DB table if no ACTIVE row exists.
### REST API
All under `platform:admin` scope:
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/vendor/certificates` | List active, staged, archived |
| POST | `/api/vendor/certificates/stage` | Upload cert+key+ca (multipart) |
| POST | `/api/vendor/certificates/activate` | Promote staged -> active |
| POST | `/api/vendor/certificates/restore` | Swap archived <-> active |
| DELETE | `/api/vendor/certificates/staged` | Discard staged |
| GET | `/api/vendor/certificates/stale-tenants` | Tenants needing restart for CA |
### Service Layer
`CertificateService` orchestrates:
- Validation (PEM parsing, key-cert match, chain building, expiry check)
- Delegates file operations to `CertificateManager` (provider)
- Manages DB metadata
- Computes tenant CA staleness
### CA Bundle Management
`ca.pem` is a concatenation of:
- Platform cert's CA (if from a private CA, supplied at bootstrap or upload)
- Tenant-supplied CAs (for enterprise SSO with private IdPs)
On any CA change (platform cert upload with CA, tenant CA add/remove):
1. Rebuild: concatenate all CAs into `ca.wip`
2. Validate: parse all PEM entries, verify structure
3. Atomic swap: `mv ca.wip ca.pem`
4. Update `activated_at` on ACTIVE cert row
5. Flag tenants as stale
### Tenant CA Distribution
At provisioning time (`DockerTenantProvisioner`):
- Mount `certs` volume read-only at `/certs` in tenant containers
- Java servers: JVM truststore import at entrypoint or `JAVA_OPTS` with custom truststore
- Node containers: `NODE_EXTRA_CA_CERTS=/certs/ca.pem`
- Set `ca_applied_at = now()` on tenant record
- Remove TLS skip flags when `ca.pem` exists
On tenant restart (manual, after CA change):
- Container picks up current `ca.pem` from volume mount
- Update `ca_applied_at` on tenant
### Vendor UI
New "Certificates" page in vendor sidebar:
- **Active cert card**: subject, issuer, expiry, fingerprint, self-signed badge, activated date
- **Staged cert card** (conditional): same metadata + Activate / Discard buttons, validation errors if any
- **Archived cert card** (conditional): same metadata + Restore button (disabled if expired)
- **Upload area**: file inputs for cert.pem (required), key.pem (required), ca.pem (optional)
- **Stale tenants banner**: "CA bundle updated - N tenants need restart" with restart action
### React Hooks
```typescript
useVendorCertificates() // GET /vendor/certificates
useStageCertificate() // POST multipart
useActivateCertificate() // POST activate
useRestoreCertificate() // POST restore
useDiscardStaged() // DELETE staged
useStaleTenants() // GET stale-tenants
```
## File Inventory
### New Files
| File | Description |
|------|-------------|
| `src/.../certificate/CertificateManager.java` | Provider interface |
| `src/.../certificate/CertificateInfo.java` | Cert metadata record |
| `src/.../certificate/CertValidationResult.java` | Validation result record |
| `src/.../certificate/CertificateEntity.java` | JPA entity |
| `src/.../certificate/CertificateRepository.java` | Spring Data repo |
| `src/.../certificate/CertificateService.java` | Business logic |
| `src/.../certificate/CertificateController.java` | REST endpoints |
| `src/.../provisioning/DockerCertificateManager.java` | Docker volume implementation |
| `src/main/resources/db/migration/V011__certificates.sql` | Migration |
| `ui/src/api/certificate-hooks.ts` | React Query hooks |
| `ui/src/pages/vendor/CertificatesPage.tsx` | Vendor UI page |
### Modified Files
| File | Change |
|------|--------|
| `docker-compose.yml` | Add CERT_FILE/KEY_FILE/CA_FILE env vars to init container |
| `traefik.yml` | No change (already reads from /certs/) |
| `src/.../provisioning/DockerTenantProvisioner.java` | Mount certs volume, set CA env vars, remove TLS skip flags |
| `ui/src/components/Layout.tsx` | Add Certificates sidebar item |
| `ui/src/router.tsx` | Add certificates route |
| `ui/src/api/vendor-hooks.ts` | Or new file for cert hooks |

View File

@@ -0,0 +1,444 @@
# Cameleer SaaS Install Script Design
## Overview
A professional installer for the Cameleer SaaS platform, distributed as two native scripts (`install.sh` for Linux, `install.ps1` for Windows). The installer downloads nothing — it embeds compose templates and generates all configuration from user input. All service initialization logic is baked into Docker images, configured via environment variables.
Distribution model: `curl -sfL https://install.cameleer.io | bash` (Linux), `irm https://install.cameleer.io/windows | iex` (Windows).
## Platform Simplification (Prerequisites)
The current architecture uses 7 services with 10+ bind-mounted config files. This design consolidates everything into 5 services with zero bind mounts (except Docker socket and optional user-supplied TLS certs).
### Image Consolidation
| Image | Base | Bakes in |
|---|---|---|
| `cameleer-traefik` | `traefik:v3` | Static/dynamic Traefik config (uses Traefik env var substitution for dynamic values like ports), cert generation entrypoint (`openssl`), self-signed cert logic |
| `cameleer-postgres` | `postgres:16-alpine` | `init-databases.sh` (creates `cameleer_saas`, `logto` databases) |
| `cameleer-clickhouse` | `clickhouse/clickhouse-server` | Init SQL (`CREATE DATABASE cameleer`), `clickhouse-users.xml`, `clickhouse-config.xml` (Prometheus metrics) |
| `cameleer-logto` | `ghcr.io/logto-io/logto` | Custom sign-in UI, bootstrap logic (app/user/role/scope creation), vendor seed (env-var gated). Replaces the separate `logto-bootstrap` init container. |
| `cameleer-saas` | `eclipse-temurin:21-jre-alpine` | Spring Boot app + React SPA (already exists, no changes) |
All images published to `gitea.siegeln.net/cameleer/`.
### Service Reduction
| Before | After |
|---|---|
| traefik-certs (init container) | Merged into `cameleer-traefik` entrypoint |
| traefik | `cameleer-traefik` |
| postgres + bind-mounted init script | `cameleer-postgres` |
| clickhouse + 3 bind-mounted config files | `cameleer-clickhouse` |
| logto | `cameleer-logto` (with bootstrap) |
| logto-bootstrap (init container) | Merged into `cameleer-logto` entrypoint |
| cameleer-saas + bind-mounted UI | `cameleer-saas` |
**Result: 7 services → 5 services. 10+ bind-mounted files → 0.**
### Bootstrap Merge
The `logto-bootstrap` init container logic moves into `cameleer-logto`'s entrypoint as an idempotent startup step:
1. Logto starts and seeds its own database (`npm run cli db seed -- --swe`)
2. Entrypoint runs bootstrap logic (create apps, users, roles, scopes, branding)
3. Bootstrap checks for cached results in a Docker volume — skips if already done
4. Writes `logto-bootstrap.json` to shared volume
5. If `VENDOR_SEED_ENABLED=true`, creates vendor user and global role
6. Logto server starts normally
The `cameleer-saas` service uses `depends_on: logto (healthy)` and reads bootstrap results from the shared volume on startup — same as today.
## Installer Architecture
### Distribution
- Linux: `curl -sfL https://install.cameleer.io | bash`
- Windows: `irm https://install.cameleer.io/windows | iex`
The scripts are self-contained. They embed docker-compose templates and generate all files locally. No secondary downloads.
### Scripts
- `install.sh` — Bash, targets Linux with Docker Engine
- `install.ps1` — PowerShell, targets Windows with Docker Desktop (WSL2 backend)
Both implement identical logic and produce identical output. They share a config file format (`cameleer.conf`) so configurations are portable between platforms.
### Prerequisites
The installer checks (does not install) these prerequisites:
- Docker Engine 24+ (Linux) or Docker Desktop 4.25+ (Windows)
- Docker Compose v2 (`docker compose` subcommand)
- `openssl` (Linux, for password generation) — PowerShell uses `[System.Security.Cryptography.RandomNumberGenerator]`
- Ports 80, 443, 3002 are free (or custom ports if specified)
- Docker socket accessible
If any prerequisite is missing, the script prints a clear error message with a link to installation instructions and exits.
## Installation Modes
### Simple Mode (default)
Asks 6 essential questions:
1. Install directory (default: `./cameleer`)
2. Public hostname (auto-detected, default: `localhost`)
3. Admin username (default: `admin`)
4. Admin password (default: auto-generated)
5. Use custom TLS certificates? (default: no → self-signed)
- If yes: paths to cert.pem, key.pem, optional ca.pem
6. Connect to a monitoring network? (default: none)
Everything else uses secure defaults. All passwords auto-generated.
### Expert Mode (`--expert` or chosen at interactive prompt)
Adds these options, grouped by category:
**Credentials:**
- PostgreSQL password (default: generated)
- ClickHouse password (default: generated)
- Vendor account enable + username + password
**Networking:**
- HTTP port (default: 80)
- HTTPS port (default: 443)
- Logto admin console port (default: 3002)
**Docker:**
- Image version/tag (default: `latest`)
- Compose project name (default: `cameleer-saas`)
- Docker socket path (auto-detected)
**TLS:**
- CA bundle path
- `NODE_TLS_REJECT_UNAUTHORIZED` setting
**Logto:**
- Admin console external exposure (default: yes)
### Silent Mode (`--silent`)
No interactive prompts. Uses defaults plus overrides.
**Config precedence:** CLI flags > environment variables > config file (`--config`) > defaults.
## Configuration Reference
| Config key | CLI flag | Env var | Default | Simple | Expert |
|---|---|---|---|---|---|
| `install_dir` | `--install-dir` | `CAMELEER_INSTALL_DIR` | `./cameleer` | yes | yes |
| `public_host` | `--public-host` | `PUBLIC_HOST` | auto-detect | yes | yes |
| `public_protocol` | `--public-protocol` | `PUBLIC_PROTOCOL` | `https` | no | yes |
| `admin_user` | `--admin-user` | `SAAS_ADMIN_USER` | `admin` | yes | yes |
| `admin_password` | `--admin-password` | `SAAS_ADMIN_PASS` | generated | yes | yes |
| `tls_mode` | `--tls-mode` | `TLS_MODE` | `self-signed` | yes | yes |
| `cert_file` | `--cert-file` | `CERT_FILE` | none | yes* | yes |
| `key_file` | `--key-file` | `KEY_FILE` | none | yes* | yes |
| `ca_file` | `--ca-file` | `CA_FILE` | none | no | yes |
| `monitoring_network` | `--monitoring-network` | `MONITORING_NETWORK` | none | yes | yes |
| `postgres_password` | `--postgres-password` | `POSTGRES_PASSWORD` | generated | no | yes |
| `clickhouse_password` | `--clickhouse-password` | `CLICKHOUSE_PASSWORD` | generated | no | yes |
| `http_port` | `--http-port` | `HTTP_PORT` | `80` | no | yes |
| `https_port` | `--https-port` | `HTTPS_PORT` | `443` | no | yes |
| `logto_console_port` | `--logto-console-port` | `LOGTO_CONSOLE_PORT` | `3002` | no | yes |
| `logto_console_exposed` | `--logto-console-exposed` | `LOGTO_CONSOLE_EXPOSED` | `true` | no | yes |
| `vendor_enabled` | `--vendor-enabled` | `VENDOR_ENABLED` | `false` | no | yes |
| `vendor_user` | `--vendor-user` | `VENDOR_USER` | `vendor` | no | yes |
| `vendor_password` | `--vendor-password` | `VENDOR_PASS` | generated | no | yes |
| `version` | `--version` | `CAMELEER_VERSION` | `latest` | no | yes |
| `compose_project` | `--compose-project` | `COMPOSE_PROJECT` | `cameleer-saas` | no | yes |
| `docker_socket` | `--docker-socket` | `DOCKER_SOCKET` | auto-detect | no | yes |
| `node_tls_reject` | `--node-tls-reject` | `NODE_TLS_REJECT` | `0` (self-signed) / `1` (custom) | no | yes |
*\* Only asked in simple mode if the user chooses custom TLS.*
### Config File Format (`cameleer.conf`)
```ini
# Cameleer installation config
# Generated by installer v1.0.0 on 2026-04-13
install_dir=./cameleer
public_host=cameleer.example.com
public_protocol=https
admin_user=my-admin
version=1.0.0
tls_mode=custom
https_port=443
monitoring_network=prometheus
```
Plain `key=value`, `#` comments. Portable between Linux and Windows.
## Auto-Detection
The installer auto-detects sensible defaults:
| Value | Linux | Windows |
|---|---|---|
| Public hostname | `hostname -f`, reverse DNS of primary IP, fallback `localhost` | `[System.Net.Dns]::GetHostEntry`, fallback `localhost` |
| Docker socket | `/var/run/docker.sock` | `//./pipe/docker_engine` |
| Port availability | `ss -tlnp` or `netstat` check on 80, 443, 3002 | `Test-NetConnection` on 80, 443, 3002 |
| Existing install | Check for `cameleer.conf` in install directory | Same |
## Output Files
The installer generates the following in the install directory:
```
./cameleer/
docker-compose.yml # Generated from embedded template
.env # All service configuration
.env.bak # Snapshot of .env at install time
cameleer.conf # Installer config (for re-runs, cloning)
credentials.txt # All generated passwords in plain text
INSTALL.md # Tailored documentation
certs/ # Only if user supplies custom TLS certs
cert.pem
key.pem
ca.pem
```
### docker-compose.yml (generated)
The compose file is generated from a template embedded in the script, with values substituted from the user's configuration. Key characteristics:
- All services use `${VARIABLE}` references to `.env`
- No bind mounts except Docker socket and optional `certs/` directory
- Shared volumes: `pgdata`, `chdata`, `bootstrapdata`, `certs`
- Networks: `cameleer` (internal), `cameleer-traefik` (for dynamic tenant routing)
- Optional external `monitoring_network` with Prometheus labels on services
- Health checks on all services
- `depends_on` with health conditions for startup ordering
### credentials.txt
```
===========================================
CAMELEER PLATFORM CREDENTIALS
Generated: 2026-04-13 14:32:00 UTC
SECURE THIS FILE AND DELETE AFTER NOTING
THESE CREDENTIALS CANNOT BE RECOVERED
===========================================
Admin Console: https://cameleer.example.com/platform/
Admin User: my-admin
Admin Password: aB3x...generated...9Zq
PostgreSQL: cameleer / Kx8m...generated...Wp2
ClickHouse: default / Rm4n...generated...Ht7
Vendor User: acme-admin (not enabled)
Logto Console: https://cameleer.example.com:3002
```
Printed to terminal once at the end of installation. Never displayed again on re-runs.
### INSTALL.md (generated)
Tailored to the actual installation values. Sections:
1. **Installation Summary** — version, date, mode, install directory
2. **Service URLs** — platform UI, Logto admin console, API endpoint
3. **First Steps** — log in as admin, create first tenant
4. **Architecture Overview** — containers running, purpose of each
5. **Networking** — ports, monitoring network, Docker networks
6. **TLS** — self-signed or custom, cert location, how to replace via vendor UI
7. **Data & Backups** — Docker volume names, backup commands (pg_dump, clickhouse-backup)
8. **Upgrading** — re-run installer with `--version`, what gets preserved
9. **Troubleshooting** — common issues with `docker compose logs` commands
10. **Uninstalling** — clean removal steps
## Password Generation
When no password is provided, the script generates cryptographically secure random passwords:
- Linux: `openssl rand -base64 24` (32 characters)
- Windows: `[System.Security.Cryptography.RandomNumberGenerator]` → Base64
### Passwords Generated
| Credential | Config key | Consumers |
|---|---|---|
| PostgreSQL password | `postgres_password` | postgres, logto, cameleer-saas |
| ClickHouse password | `clickhouse_password` | clickhouse, cameleer-saas (tenant provisioning) |
| Admin password | `admin_password` | Logto admin user |
| Vendor password | `vendor_password` | Logto vendor user (only if enabled) |
### Credential Lifecycle
1. Generated (or user-provided) during install
2. Written to `.env` (consumed by Docker Compose)
3. Written to `credentials.txt` in plain text
4. Printed to terminal once at end of installation
5. Never shown again — re-runs preserve existing credentials without displaying them
## Monitoring Network Integration
When a monitoring network is configured (simple or expert mode):
1. The script verifies the network exists via `docker network inspect`
- If missing in interactive mode: asks whether to create it or skip
- If missing in silent mode: creates it automatically
2. The network is added as an external network in the generated `docker-compose.yml`
3. Services are attached to it and labeled for Prometheus Docker SD:
```yaml
cameleer-saas:
labels:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/platform/actuator/prometheus"
cameleer-traefik:
labels:
prometheus.io/scrape: "true"
prometheus.io/port: "8082"
prometheus.io/path: "/metrics"
cameleer-clickhouse:
labels:
prometheus.io/scrape: "true"
prometheus.io/port: "9363"
prometheus.io/path: "/metrics"
```
No Prometheus configuration needed on the customer's side — Docker service discovery picks up the labels automatically.
## Idempotent Re-run & Upgrade
### Detection
The script checks for `cameleer.conf` in the install directory. If found, it's a re-run.
### Interactive Re-run Menu
```
Existing Cameleer installation detected (v1.0.0)
Install directory: ./cameleer
Public host: cameleer.example.com
[1] Upgrade to v1.1.0 (pull new images, update compose)
[2] Reconfigure (re-run interactive setup, preserve data)
[3] Reinstall (fresh install, WARNING: destroys data volumes)
[4] Cancel
```
### Re-run Behavior
| Action | Preserve | Regenerate | Pull images |
|---|---|---|---|
| Upgrade | `.env`, `cameleer.conf`, `credentials.txt`, `certs/`, volumes | `docker-compose.yml`, `INSTALL.md` | yes (new version) |
| Reconfigure | Data volumes, `credentials.txt` (unless passwords changed) | `.env`, `docker-compose.yml`, `cameleer.conf`, `INSTALL.md` | optional |
| Reinstall | Nothing | Everything | yes |
### Silent Re-run
Defaults to upgrade. Override with `--reconfigure` or `--reinstall`.
### Safety
- Data volumes (`pgdata`, `chdata`, `bootstrapdata`) are never removed unless `--reinstall` is explicitly chosen
- `--reinstall` requires double opt-in: `--reinstall --confirm-destroy`
- The script never runs `docker volume rm` without this confirmation
## Health Verification
After `docker compose up -d`, the script polls services in dependency order:
| Step | Service | Check | Timeout |
|---|---|---|---|
| 1 | PostgreSQL | `pg_isready` via `docker compose exec` | 120s |
| 2 | ClickHouse | `clickhouse-client` query via `docker compose exec` | 120s |
| 3 | Logto | GET `/oidc/.well-known/openid-configuration` | 120s |
| 4 | Bootstrap | Check `logto-bootstrap.json` exists in volume | 120s |
| 5 | Cameleer SaaS | GET `/platform/api/config` | 120s |
| 6 | Traefik | GET `https://{PUBLIC_HOST}/` (expect redirect) | 120s |
**Polling interval:** 5 seconds. **Total timeout:** 5 minutes.
### Output
```
Verifying installation...
[ok] PostgreSQL ready (3s)
[ok] ClickHouse ready (5s)
[ok] Logto ready (18s)
[ok] Bootstrap complete (0s)
[ok] Cameleer SaaS ready (8s)
[ok] Traefik routing ready (1s)
Installation complete!
```
### Failure
- Failing service marked with `[FAIL]` and a hint (e.g., "check `docker compose logs logto`")
- Remaining checks skipped
- Stack left running for inspection
- Script exits with code 1
## Script Structure (both platforms)
```
main()
parse_args()
detect_existing_install()
if existing → show_rerun_menu()
check_prerequisites()
auto_detect_defaults()
select_mode() # simple / expert / silent
if interactive → run_prompts()
merge_config() # CLI > env > config file > defaults
validate_config()
generate_passwords() # for any not provided
if custom_certs → copy_certs()
generate_env_file()
generate_compose_file()
write_config_file() # cameleer.conf
docker_compose_pull()
docker_compose_up()
verify_health()
generate_credentials_file()
generate_install_doc()
print_credentials()
print_summary()
```
Each function has a direct equivalent in both bash and PowerShell. The logic, prompts, and output are identical across platforms.
## TLS Certificate Flow (Simplified)
With the `traefik-certs` init container merged into `cameleer-traefik`, the certificate flow works as follows:
**Shared `certs` Docker volume** remains the mechanism for sharing TLS state between `cameleer-traefik` and `cameleer-saas` (which mounts it read-only for per-tenant server provisioning).
**Self-signed mode (default):**
1. `cameleer-traefik` entrypoint checks if `/certs/cert.pem` exists in the volume
2. If not, generates a self-signed cert for `${PUBLIC_HOST}` with wildcard SAN using `openssl`
3. Writes `cert.pem`, `key.pem`, `meta.json` to the `certs` volume
4. Starts Traefik normally
**Custom cert mode:**
1. The installer copies user-supplied cert files to `./cameleer/certs/` on the host
2. The generated `docker-compose.yml` bind-mounts `./certs/:/user-certs:ro` on the `cameleer-traefik` service
3. `cameleer-traefik` entrypoint detects `CERT_FILE=/user-certs/cert.pem` and `KEY_FILE=/user-certs/key.pem`
4. Validates and copies them to the shared `certs` Docker volume
5. Writes `meta.json` with certificate metadata
6. Starts Traefik normally
**Runtime cert replacement** (via vendor UI) continues to work unchanged — `cameleer-saas` writes to the `certs` volume's `staged/` directory and performs atomic swaps.
## Docker Socket Path
The generated `docker-compose.yml` uses the platform-appropriate Docker socket path:
- Linux: `/var/run/docker.sock:/var/run/docker.sock`
- Windows (Docker Desktop): `//./pipe/docker_engine://./pipe/docker_engine`
The installer detects the platform and generates the correct bind mount. The `docker_socket` config key allows overriding this in expert mode.

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

@@ -0,0 +1,620 @@
# 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` |
| `CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT` | Internal Logto URL (container-to-container) | `http://logto:3001` |
| `CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT` | Public-facing Logto URL | `http://localhost:3001` |
| `CAMELEER_SAAS_IDENTITY_M2MCLIENTID` | Machine-to-machine client ID (auto-set by bootstrap) | _(empty)_ |
| `CAMELEER_SAAS_IDENTITY_M2MCLIENTSECRET` | Machine-to-machine client secret (auto-set by bootstrap) | _(empty)_ |
| `CAMELEER_SAAS_IDENTITY_SPACLIENTID` | SPA client ID for the frontend | _(empty)_ |
| `PUBLIC_HOST` | Public hostname for Traefik, Logto, and SaaS routing | `localhost` |
| `PUBLIC_PROTOCOL` | Public protocol (`http` or `https`) | `https` |
| `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 `CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT` in `.env` is correct (the OIDC issuer and JWK set URIs are derived from it automatically).
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_SERVER_SECURITY_BOOTSTRAPTOKEN` 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_SERVER_SECURITY_BOOTSTRAPTOKEN` 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_SERVER_RUNTIME_CONTAINER_MEMORYLIMIT` to a higher value (e.g., `1g`) on the per-tenant server container and restart it.
### 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.

1626
installer/install.ps1 Normal file

File diff suppressed because it is too large Load Diff

1372
installer/install.sh Normal file

File diff suppressed because it is too large Load Diff

20
pom.xml
View File

@@ -80,6 +80,26 @@
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- ClickHouse JDBC (tenant data cleanup on delete) -->
<dependency>
<groupId>com.clickhouse</groupId>
<artifactId>clickhouse-jdbc</artifactId>
<version>0.9.7</version>
<classifier>all</classifier>
</dependency>
<!-- Docker Java (tenant provisioning) -->
<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>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>

View File

@@ -3,6 +3,7 @@ package net.siegeln.cameleer.saas.audit;
public enum AuditAction {
AUTH_REGISTER, AUTH_LOGIN, AUTH_LOGIN_FAILED, AUTH_LOGOUT,
TENANT_CREATE, TENANT_UPDATE, TENANT_SUSPEND, TENANT_REACTIVATE, TENANT_DELETE,
ENVIRONMENT_CREATE, ENVIRONMENT_UPDATE, ENVIRONMENT_DELETE,
APP_CREATE, APP_DEPLOY, APP_PROMOTE, APP_ROLLBACK, APP_SCALE, APP_STOP, APP_DELETE,
SECRET_CREATE, SECRET_READ, SECRET_UPDATE, SECRET_DELETE, SECRET_ROTATE,
CONFIG_UPDATE,

View File

@@ -0,0 +1,38 @@
package net.siegeln.cameleer.saas.audit;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public final class AuditDto {
private AuditDto() {}
public record AuditLogEntry(
UUID id,
String actorEmail,
UUID tenantId,
String action,
String resource,
String environment,
String result,
String sourceIp,
Instant createdAt
) {
public static AuditLogEntry from(AuditEntity e) {
return new AuditLogEntry(
e.getId(), e.getActorEmail(), e.getTenantId(),
e.getAction(), e.getResource(), e.getEnvironment(),
e.getResult(), e.getSourceIp(), e.getCreatedAt()
);
}
}
public record AuditLogPage(
List<AuditLogEntry> content,
int page,
int size,
long totalElements,
int totalPages
) {}
}

View File

@@ -8,7 +8,7 @@ import java.util.List;
import java.util.UUID;
@Repository
public interface AuditRepository extends JpaRepository<AuditEntity, UUID> {
public interface AuditRepository extends JpaRepository<AuditEntity, UUID>, AuditRepositoryCustom {
List<AuditEntity> findByTenantIdAndCreatedAtBetween(UUID tenantId, Instant from, Instant to);

View File

@@ -0,0 +1,14 @@
package net.siegeln.cameleer.saas.audit;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.time.Instant;
import java.util.UUID;
public interface AuditRepositoryCustom {
Page<AuditDto.AuditLogEntry> findFiltered(UUID tenantId, String action, String result,
Instant from, Instant to, String search,
Pageable pageable);
}

View File

@@ -0,0 +1,88 @@
package net.siegeln.cameleer.saas.audit;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Component
public class AuditRepositoryImpl implements AuditRepositoryCustom {
private final JdbcTemplate jdbc;
public AuditRepositoryImpl(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Override
public Page<AuditDto.AuditLogEntry> findFiltered(UUID tenantId, String action, String result,
Instant from, Instant to, String search,
Pageable pageable) {
StringBuilder where = new StringBuilder("WHERE 1=1");
List<Object> params = new ArrayList<>();
if (tenantId != null) {
where.append(" AND tenant_id = ?");
params.add(tenantId);
}
if (action != null && !action.isBlank()) {
where.append(" AND action = ?");
params.add(action);
}
if (result != null && !result.isBlank()) {
where.append(" AND result = ?");
params.add(result);
}
if (from != null) {
where.append(" AND created_at >= ?");
params.add(Timestamp.from(from));
}
if (to != null) {
where.append(" AND created_at <= ?");
params.add(Timestamp.from(to));
}
if (search != null && !search.isBlank()) {
where.append(" AND (actor_email ILIKE ? OR resource ILIKE ?)");
String like = "%" + search + "%";
params.add(like);
params.add(like);
}
String countSql = "SELECT COUNT(*) FROM audit_log " + where;
Long total = jdbc.queryForObject(countSql, Long.class, params.toArray());
long totalCount = total != null ? total : 0;
String dataSql = "SELECT * FROM audit_log " + where
+ " ORDER BY created_at DESC LIMIT ? OFFSET ?";
List<Object> dataParams = new ArrayList<>(params);
dataParams.add(pageable.getPageSize());
dataParams.add(pageable.getOffset());
List<AuditDto.AuditLogEntry> items = jdbc.query(dataSql, (rs, rowNum) -> mapRow(rs), dataParams.toArray());
return new PageImpl<>(items, pageable, totalCount);
}
private AuditDto.AuditLogEntry mapRow(ResultSet rs) throws SQLException {
Timestamp ts = rs.getTimestamp("created_at");
return new AuditDto.AuditLogEntry(
rs.getObject("id", UUID.class),
rs.getString("actor_email"),
rs.getObject("tenant_id", UUID.class),
rs.getString("action"),
rs.getString("resource"),
rs.getString("environment"),
rs.getString("result"),
rs.getString("source_ip"),
ts != null ? ts.toInstant() : null
);
}
}

View File

@@ -1,23 +1,38 @@
package net.siegeln.cameleer.saas.audit;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class AuditService {
private final AuditRepository auditRepository;
private static final Logger log = LoggerFactory.getLogger(AuditService.class);
public AuditService(AuditRepository auditRepository) {
private final AuditRepository auditRepository;
private final LogtoManagementClient logtoClient;
private final ConcurrentHashMap<String, String> userNameCache = new ConcurrentHashMap<>();
public AuditService(AuditRepository auditRepository, LogtoManagementClient logtoClient) {
this.auditRepository = auditRepository;
this.logtoClient = logtoClient;
}
public void log(UUID actorId, String actorEmail, UUID tenantId,
AuditAction action, String resource,
String environment, String sourceIp,
String result, Map<String, Object> metadata) {
if (actorEmail == null && actorId != null) {
actorEmail = resolveActorName(actorId.toString());
}
var entry = new AuditEntity();
entry.setActorId(actorId);
entry.setActorEmail(actorEmail);
@@ -30,4 +45,29 @@ public class AuditService {
entry.setMetadata(metadata);
auditRepository.save(entry);
}
public Page<AuditDto.AuditLogEntry> search(UUID tenantId, String action, String result,
Instant from, Instant to, String search,
Pageable pageable) {
return auditRepository.findFiltered(tenantId, action, result, from, to, search, pageable);
}
private String resolveActorName(String userId) {
return userNameCache.computeIfAbsent(userId, id -> {
try {
var user = logtoClient.getUser(id);
if (user == null) return id;
var username = user.get("username");
if (username != null && !username.toString().isBlank()) return username.toString();
var name = user.get("name");
if (name != null && !name.toString().isBlank()) return name.toString();
var email = user.get("primaryEmail");
if (email != null && !email.toString().isBlank()) return email.toString();
return id;
} catch (Exception e) {
log.warn("Failed to resolve actor name for {}: {}", id, e.getMessage());
return id;
}
});
}
}

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

@@ -0,0 +1,17 @@
package net.siegeln.cameleer.saas.certificate;
import java.util.List;
public record CertValidationResult(
boolean valid,
List<String> errors,
CertificateInfo info
) {
public static CertValidationResult ok(CertificateInfo info) {
return new CertValidationResult(true, List.of(), info);
}
public static CertValidationResult fail(List<String> errors) {
return new CertValidationResult(false, errors, null);
}
}

View File

@@ -0,0 +1,138 @@
package net.siegeln.cameleer.saas.certificate;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/vendor/certificates")
@PreAuthorize("hasAuthority('SCOPE_platform:admin')")
public class CertificateController {
private final CertificateService certificateService;
public CertificateController(CertificateService certificateService) {
this.certificateService = certificateService;
}
// --- Response types ---
public record CertificateResponse(
UUID id, String status, String subject, String issuer,
Instant notBefore, Instant notAfter, String fingerprint,
boolean hasCa, boolean selfSigned, Instant activatedAt, Instant archivedAt
) {
public static CertificateResponse from(CertificateEntity e) {
if (e == null) return null;
return new CertificateResponse(
e.getId(), e.getStatus().name(), e.getSubject(), e.getIssuer(),
e.getNotBefore(), e.getNotAfter(), e.getFingerprint(),
e.isHasCa(), e.isSelfSigned(), e.getActivatedAt(), e.getArchivedAt()
);
}
}
public record OverviewResponse(
CertificateResponse active,
CertificateResponse staged,
CertificateResponse archived,
long staleTenantCount
) {}
public record StageResponse(
boolean valid,
List<String> errors,
CertificateResponse certificate
) {}
// --- Endpoints ---
@GetMapping
public ResponseEntity<OverviewResponse> getOverview() {
var overview = certificateService.getOverview();
long stale = certificateService.countStaleTenants();
return ResponseEntity.ok(new OverviewResponse(
CertificateResponse.from(overview.active()),
CertificateResponse.from(overview.staged()),
CertificateResponse.from(overview.archived()),
stale
));
}
@PostMapping("/stage")
public ResponseEntity<StageResponse> stage(
@RequestParam("cert") MultipartFile certFile,
@RequestParam("key") MultipartFile keyFile,
@RequestParam(value = "ca", required = false) MultipartFile caFile,
@RequestParam(value = "password", required = false) String keyPassword,
@AuthenticationPrincipal Jwt jwt) {
try {
byte[] certPem = certFile.getBytes();
byte[] keyPem = keyFile.getBytes();
byte[] caPem = caFile != null ? caFile.getBytes() : null;
UUID actorId = resolveActorId(jwt);
CertValidationResult result = certificateService.stage(certPem, keyPem, caPem, keyPassword, actorId);
if (!result.valid()) {
return ResponseEntity.badRequest().body(
new StageResponse(false, result.errors(), null));
}
var overview = certificateService.getOverview();
return ResponseEntity.ok(new StageResponse(
true, List.of(), CertificateResponse.from(overview.staged())));
} catch (Exception e) {
return ResponseEntity.badRequest().body(
new StageResponse(false, List.of(e.getMessage()), null));
}
}
@PostMapping("/activate")
public ResponseEntity<Void> activate() {
certificateService.activate();
return ResponseEntity.noContent().build();
}
@PostMapping("/restore")
public ResponseEntity<Void> restore() {
try {
certificateService.restore();
return ResponseEntity.noContent().build();
} catch (IllegalStateException e) {
return ResponseEntity.badRequest().body(null);
}
}
@DeleteMapping("/staged")
public ResponseEntity<Void> discardStaged() {
certificateService.discardStaged();
return ResponseEntity.noContent().build();
}
@GetMapping("/stale-tenants")
public ResponseEntity<Map<String, Long>> staleTenants() {
return ResponseEntity.ok(Map.of("count", certificateService.countStaleTenants()));
}
private UUID resolveActorId(Jwt jwt) {
try {
return UUID.fromString(jwt.getSubject());
} catch (Exception e) {
return UUID.nameUUIDFromBytes(jwt.getSubject().getBytes());
}
}
}

View File

@@ -0,0 +1,119 @@
package net.siegeln.cameleer.saas.certificate;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "certificates")
public class CertificateEntity {
public enum Status { ACTIVE, STAGED, ARCHIVED }
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 10)
private Status status;
@Column(name = "subject", length = 500)
private String subject;
@Column(name = "issuer", length = 500)
private String issuer;
@Column(name = "not_before")
private Instant notBefore;
@Column(name = "not_after")
private Instant notAfter;
@Column(name = "fingerprint", length = 128)
private String fingerprint;
@Column(name = "has_ca", nullable = false)
private boolean hasCa;
@Column(name = "self_signed", nullable = false)
private boolean selfSigned;
@Column(name = "uploaded_by")
private UUID uploadedBy;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "activated_at")
private Instant activatedAt;
@Column(name = "archived_at")
private Instant archivedAt;
@PrePersist
protected void onCreate() {
if (createdAt == null) createdAt = Instant.now();
}
// --- Getters and setters ---
public UUID getId() { return id; }
public Status getStatus() { return status; }
public void setStatus(Status status) { this.status = status; }
public String getSubject() { return subject; }
public void setSubject(String subject) { this.subject = subject; }
public String getIssuer() { return issuer; }
public void setIssuer(String issuer) { this.issuer = issuer; }
public Instant getNotBefore() { return notBefore; }
public void setNotBefore(Instant notBefore) { this.notBefore = notBefore; }
public Instant getNotAfter() { return notAfter; }
public void setNotAfter(Instant notAfter) { this.notAfter = notAfter; }
public String getFingerprint() { return fingerprint; }
public void setFingerprint(String fingerprint) { this.fingerprint = fingerprint; }
public boolean isHasCa() { return hasCa; }
public void setHasCa(boolean hasCa) { this.hasCa = hasCa; }
public boolean isSelfSigned() { return selfSigned; }
public void setSelfSigned(boolean selfSigned) { this.selfSigned = selfSigned; }
public UUID getUploadedBy() { return uploadedBy; }
public void setUploadedBy(UUID uploadedBy) { this.uploadedBy = uploadedBy; }
public Instant getCreatedAt() { return createdAt; }
public Instant getActivatedAt() { return activatedAt; }
public void setActivatedAt(Instant activatedAt) { this.activatedAt = activatedAt; }
public Instant getArchivedAt() { return archivedAt; }
public void setArchivedAt(Instant archivedAt) { this.archivedAt = archivedAt; }
public static CertificateEntity fromInfo(CertificateInfo info, Status status) {
var entity = new CertificateEntity();
entity.setStatus(status);
entity.setSubject(info.subject());
entity.setIssuer(info.issuer());
entity.setNotBefore(info.notBefore());
entity.setNotAfter(info.notAfter());
entity.setFingerprint(info.fingerprint());
entity.setHasCa(info.hasCaBundle());
entity.setSelfSigned(info.selfSigned());
return entity;
}
}

View File

@@ -0,0 +1,13 @@
package net.siegeln.cameleer.saas.certificate;
import java.time.Instant;
public record CertificateInfo(
String subject,
String issuer,
Instant notBefore,
Instant notAfter,
boolean hasCaBundle,
boolean selfSigned,
String fingerprint
) {}

View File

@@ -0,0 +1,42 @@
package net.siegeln.cameleer.saas.certificate;
/**
* Provider interface for certificate file management.
* Docker implementation writes to the certs volume.
* K8s implementation would manage TLS Secrets.
*/
public interface CertificateManager {
boolean isAvailable();
/** Read metadata of the active certificate from the provider storage. */
CertificateInfo getActive();
/** Read metadata of the staged certificate, or null. */
CertificateInfo getStaged();
/** Read metadata of the archived certificate, or null. */
CertificateInfo getArchived();
/**
* Write cert+key+ca to staging area and validate.
* Does NOT activate — call {@link #activate()} to promote.
* @param keyPassword optional password for encrypted private keys (null if unencrypted)
*/
CertValidationResult stage(byte[] certPem, byte[] keyPem, byte[] caBundlePem, String keyPassword);
/** Promote staged -> active. Moves current active to archive (deleting previous archive). */
void activate();
/** Swap archived <-> active. */
void restore();
/** Delete staged files. */
void discardStaged();
/** Generate a self-signed certificate for the given hostname and store as active. */
void generateSelfSigned(String hostname);
/** Read the current CA bundle bytes, or null if none exists. */
byte[] getCaBundle();
}

View File

@@ -0,0 +1,13 @@
package net.siegeln.cameleer.saas.certificate;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface CertificateRepository extends JpaRepository<CertificateEntity, UUID> {
Optional<CertificateEntity> findByStatus(CertificateEntity.Status status);
void deleteByStatus(CertificateEntity.Status status);
}

View File

@@ -0,0 +1,155 @@
package net.siegeln.cameleer.saas.certificate;
import net.siegeln.cameleer.saas.tenant.TenantRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@Service
public class CertificateService {
private static final Logger log = LoggerFactory.getLogger(CertificateService.class);
private final CertificateManager certManager;
private final CertificateRepository certRepository;
private final TenantRepository tenantRepository;
public CertificateService(CertificateManager certManager,
CertificateRepository certRepository,
TenantRepository tenantRepository) {
this.certManager = certManager;
this.certRepository = certRepository;
this.tenantRepository = tenantRepository;
}
public record CertificateOverview(
CertificateEntity active,
CertificateEntity staged,
CertificateEntity archived
) {}
public CertificateOverview getOverview() {
return new CertificateOverview(
certRepository.findByStatus(CertificateEntity.Status.ACTIVE).orElse(null),
certRepository.findByStatus(CertificateEntity.Status.STAGED).orElse(null),
certRepository.findByStatus(CertificateEntity.Status.ARCHIVED).orElse(null)
);
}
@Transactional
public CertValidationResult stage(byte[] certPem, byte[] keyPem, byte[] caBundlePem, String keyPassword, UUID actorId) {
if (!certManager.isAvailable()) {
return CertValidationResult.fail(List.of("Certificate management is not available"));
}
// Discard any existing staged cert
certRepository.findByStatus(CertificateEntity.Status.STAGED).ifPresent(certRepository::delete);
// Stage files and validate
CertValidationResult result = certManager.stage(certPem, keyPem, caBundlePem, keyPassword);
if (!result.valid()) {
return result;
}
// Save metadata to DB
var entity = CertificateEntity.fromInfo(result.info(), CertificateEntity.Status.STAGED);
entity.setUploadedBy(actorId);
certRepository.save(entity);
log.info("Certificate staged by actor {}: subject={}", actorId, result.info().subject());
return result;
}
@Transactional
public void activate() {
var staged = certRepository.findByStatus(CertificateEntity.Status.STAGED)
.orElseThrow(() -> new IllegalStateException("No staged certificate to activate"));
// File operations: delete archive files, move active -> archive, move staged -> active
certManager.activate();
// DB: delete archived, active -> archived, staged -> active
certRepository.findByStatus(CertificateEntity.Status.ARCHIVED).ifPresent(certRepository::delete);
certRepository.findByStatus(CertificateEntity.Status.ACTIVE).ifPresent(active -> {
active.setStatus(CertificateEntity.Status.ARCHIVED);
active.setArchivedAt(Instant.now());
certRepository.save(active);
});
staged.setStatus(CertificateEntity.Status.ACTIVE);
staged.setActivatedAt(Instant.now());
certRepository.save(staged);
log.info("Certificate activated: subject={}", staged.getSubject());
}
@Transactional
public void restore() {
var archived = certRepository.findByStatus(CertificateEntity.Status.ARCHIVED)
.orElseThrow(() -> new IllegalStateException("No archived certificate to restore"));
if (archived.getNotAfter() != null && archived.getNotAfter().isBefore(Instant.now())) {
throw new IllegalStateException("Archived certificate has expired and cannot be restored");
}
// File operations: swap active <-> archive
certManager.restore();
// DB: swap statuses
var active = certRepository.findByStatus(CertificateEntity.Status.ACTIVE).orElse(null);
archived.setStatus(CertificateEntity.Status.ACTIVE);
archived.setActivatedAt(Instant.now());
archived.setArchivedAt(null);
certRepository.save(archived);
if (active != null) {
active.setStatus(CertificateEntity.Status.ARCHIVED);
active.setArchivedAt(Instant.now());
certRepository.save(active);
}
log.info("Certificate restored from archive: subject={}", archived.getSubject());
}
@Transactional
public void discardStaged() {
certManager.discardStaged();
certRepository.findByStatus(CertificateEntity.Status.STAGED).ifPresent(certRepository::delete);
log.info("Staged certificate discarded");
}
/**
* Count tenants whose ca_applied_at is before the active cert's activated_at,
* meaning they haven't picked up the latest CA bundle.
*/
public long countStaleTenants() {
var active = certRepository.findByStatus(CertificateEntity.Status.ACTIVE).orElse(null);
if (active == null || active.getActivatedAt() == null) return 0;
if (!active.isHasCa()) return 0; // no CA bundle to propagate
return tenantRepository.countByCaAppliedAtBeforeOrCaAppliedAtIsNull(active.getActivatedAt());
}
/**
* Seed the DB from the filesystem on startup (for bootstrap-generated certs).
*/
@Transactional
public void seedFromFilesystem() {
if (certRepository.findByStatus(CertificateEntity.Status.ACTIVE).isPresent()) {
return; // Already seeded
}
CertificateInfo activeInfo = certManager.getActive();
if (activeInfo != null) {
var entity = CertificateEntity.fromInfo(activeInfo, CertificateEntity.Status.ACTIVE);
entity.setActivatedAt(Instant.now());
certRepository.save(entity);
log.info("Seeded certificate metadata from filesystem: subject={}", activeInfo.subject());
}
}
}

View File

@@ -0,0 +1,20 @@
package net.siegeln.cameleer.saas.certificate;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
public class CertificateStartupListener {
private final CertificateService certificateService;
public CertificateStartupListener(CertificateService certificateService) {
this.certificateService = certificateService;
}
@EventListener(ApplicationReadyEvent.class)
public void onReady() {
certificateService.seedFromFilesystem();
}
}

View File

@@ -0,0 +1,82 @@
package net.siegeln.cameleer.saas.certificate;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "tenant_ca_certs")
public class TenantCaCertEntity {
public enum Status { ACTIVE, STAGED }
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "tenant_id", nullable = false)
private UUID tenantId;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 10)
private Status status;
@Column(name = "label", length = 200)
private String label;
@Column(name = "subject", length = 500)
private String subject;
@Column(name = "issuer", length = 500)
private String issuer;
@Column(name = "fingerprint", length = 128)
private String fingerprint;
@Column(name = "not_before")
private Instant notBefore;
@Column(name = "not_after")
private Instant notAfter;
@Column(name = "cert_pem", nullable = false, columnDefinition = "TEXT")
private String certPem;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@PrePersist
protected void onCreate() {
if (createdAt == null) createdAt = Instant.now();
}
public UUID getId() { return id; }
public UUID getTenantId() { return tenantId; }
public void setTenantId(UUID tenantId) { this.tenantId = tenantId; }
public Status getStatus() { return status; }
public void setStatus(Status status) { this.status = status; }
public String getLabel() { return label; }
public void setLabel(String label) { this.label = label; }
public String getSubject() { return subject; }
public void setSubject(String subject) { this.subject = subject; }
public String getIssuer() { return issuer; }
public void setIssuer(String issuer) { this.issuer = issuer; }
public String getFingerprint() { return fingerprint; }
public void setFingerprint(String fingerprint) { this.fingerprint = fingerprint; }
public Instant getNotBefore() { return notBefore; }
public void setNotBefore(Instant notBefore) { this.notBefore = notBefore; }
public Instant getNotAfter() { return notAfter; }
public void setNotAfter(Instant notAfter) { this.notAfter = notAfter; }
public String getCertPem() { return certPem; }
public void setCertPem(String certPem) { this.certPem = certPem; }
public Instant getCreatedAt() { return createdAt; }
}

View File

@@ -0,0 +1,18 @@
package net.siegeln.cameleer.saas.certificate;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
import java.util.UUID;
public interface TenantCaCertRepository extends JpaRepository<TenantCaCertEntity, UUID> {
List<TenantCaCertEntity> findByTenantIdOrderByCreatedAtDesc(UUID tenantId);
List<TenantCaCertEntity> findByTenantIdAndStatus(UUID tenantId, TenantCaCertEntity.Status status);
/** All active CAs across all tenants — used to rebuild the aggregated ca.pem. */
@Query("SELECT c FROM TenantCaCertEntity c WHERE c.status = 'ACTIVE'")
List<TenantCaCertEntity> findAllActive();
}

View File

@@ -0,0 +1,196 @@
package net.siegeln.cameleer.saas.certificate;
import net.siegeln.cameleer.saas.provisioning.DockerCertificateManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.ByteArrayInputStream;
import java.security.MessageDigest;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.UUID;
@Service
public class TenantCaCertService {
private static final Logger log = LoggerFactory.getLogger(TenantCaCertService.class);
private final TenantCaCertRepository caCertRepository;
private final CertificateManager certManager;
public TenantCaCertService(TenantCaCertRepository caCertRepository, CertificateManager certManager) {
this.caCertRepository = caCertRepository;
this.certManager = certManager;
}
public List<TenantCaCertEntity> listForTenant(UUID tenantId) {
return caCertRepository.findByTenantIdOrderByCreatedAtDesc(tenantId);
}
@Transactional
public TenantCaCertEntity stage(UUID tenantId, String label, byte[] certPem) {
// Parse and validate
X509Certificate cert;
try {
var cf = CertificateFactory.getInstance("X.509");
cert = (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(certPem));
} catch (Exception e) {
throw new IllegalArgumentException("Invalid CA certificate PEM: " + e.getMessage());
}
String fingerprint;
try {
fingerprint = HexFormat.ofDelimiter(":").formatHex(
MessageDigest.getInstance("SHA-256").digest(cert.getEncoded()));
} catch (Exception e) {
throw new RuntimeException("Failed to compute fingerprint", e);
}
var entity = new TenantCaCertEntity();
entity.setTenantId(tenantId);
entity.setStatus(TenantCaCertEntity.Status.STAGED);
entity.setLabel(label);
entity.setSubject(cert.getSubjectX500Principal().getName());
entity.setIssuer(cert.getIssuerX500Principal().getName());
entity.setFingerprint(fingerprint);
entity.setNotBefore(cert.getNotBefore().toInstant());
entity.setNotAfter(cert.getNotAfter().toInstant());
entity.setCertPem(new String(certPem));
var saved = caCertRepository.save(entity);
log.info("Staged tenant CA cert for tenant {}: subject={}", tenantId, entity.getSubject());
return saved;
}
@Transactional
public TenantCaCertEntity activate(UUID tenantId, UUID certId) {
var entity = caCertRepository.findById(certId)
.orElseThrow(() -> new IllegalArgumentException("CA certificate not found"));
if (!entity.getTenantId().equals(tenantId)) {
throw new IllegalArgumentException("CA certificate does not belong to this tenant");
}
if (entity.getStatus() != TenantCaCertEntity.Status.STAGED) {
throw new IllegalStateException("Only staged certificates can be activated");
}
entity.setStatus(TenantCaCertEntity.Status.ACTIVE);
caCertRepository.save(entity);
rebuildCaBundle();
log.info("Activated tenant CA cert {} for tenant {}", certId, tenantId);
return entity;
}
@Transactional
public void delete(UUID tenantId, UUID certId) {
var entity = caCertRepository.findById(certId)
.orElseThrow(() -> new IllegalArgumentException("CA certificate not found"));
if (!entity.getTenantId().equals(tenantId)) {
throw new IllegalArgumentException("CA certificate does not belong to this tenant");
}
boolean wasActive = entity.getStatus() == TenantCaCertEntity.Status.ACTIVE;
caCertRepository.delete(entity);
if (wasActive) {
rebuildCaBundle();
}
log.info("Deleted tenant CA cert {} for tenant {}", certId, tenantId);
}
/**
* Rebuild the aggregated ca.pem from all active tenant CAs + platform CA.
* Uses the .wip atomic swap pattern via CertificateManager.
*/
public void rebuildCaBundle() {
if (!certManager.isAvailable()) {
log.warn("Certificate manager not available — skipping CA bundle rebuild");
return;
}
List<TenantCaCertEntity> allActive = caCertRepository.findAllActive();
// Collect all PEM certs
var parts = new ArrayList<String>();
// Platform CA (from existing ca.pem staged with platform cert, if any)
// We read the current platform cert's CA from the active cert's staged ca
// Actually, the platform CA is managed separately by CertificateService.
// We only aggregate tenant CAs here + whatever platform CA exists.
byte[] existingPlatformCa = readPlatformCa();
if (existingPlatformCa != null) {
parts.add(new String(existingPlatformCa).trim());
}
for (var cert : allActive) {
parts.add(cert.getCertPem().trim());
}
if (parts.isEmpty()) {
// No CAs at all — remove ca.pem
try {
var certsDir = getCertsPath();
if (certsDir != null) {
java.nio.file.Files.deleteIfExists(certsDir.resolve("ca.pem"));
log.info("Removed ca.pem — no active CA certificates");
}
} catch (Exception e) {
log.warn("Failed to remove ca.pem: {}", e.getMessage());
}
return;
}
byte[] bundleBytes = String.join("\n", parts).concat("\n").getBytes();
// Validate the bundle is parseable
try {
var cf = CertificateFactory.getInstance("X.509");
var certs = cf.generateCertificates(new ByteArrayInputStream(bundleBytes));
if (certs.isEmpty()) {
log.error("Rebuilt CA bundle contains no valid certificates — aborting");
return;
}
log.info("CA bundle rebuilt with {} certificate(s)", certs.size());
} catch (Exception e) {
log.error("Rebuilt CA bundle failed validation — aborting: {}", e.getMessage());
return;
}
// Atomic write via .wip pattern
try {
var certsDir = getCertsPath();
if (certsDir == null) return;
var wipPath = certsDir.resolve("ca.wip");
var targetPath = certsDir.resolve("ca.pem");
java.nio.file.Files.write(wipPath, bundleBytes);
java.nio.file.Files.move(wipPath, targetPath,
java.nio.file.StandardCopyOption.REPLACE_EXISTING,
java.nio.file.StandardCopyOption.ATOMIC_MOVE);
log.info("CA bundle written to {}", targetPath);
} catch (Exception e) {
log.error("Failed to write CA bundle: {}", e.getMessage());
}
}
/** Read the platform CA portion (uploaded with the platform cert, not tenant CAs). */
private byte[] readPlatformCa() {
// The platform CA is stored alongside the platform cert by CertificateService.
// We read it from the cert manager, but we need to distinguish it from the
// aggregated bundle. For now, we don't separate platform CA from tenant CAs
// in the file — the rebuild always produces the full bundle.
// Platform CA would be stored separately if vendor uploaded one with their cert.
return null; // TODO: track platform CA separately if needed
}
private java.nio.file.Path getCertsPath() {
if (certManager instanceof DockerCertificateManager dcm) {
return dcm.getCertsDir();
}
return null;
}
}

View File

@@ -0,0 +1,9 @@
package net.siegeln.cameleer.saas.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
@Configuration
@EnableAsync
public class AsyncConfig {
}

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.saas.identity.logtopublicendpoint:${cameleer.saas.identity.logtoendpoint:}}")
private String logtoPublicEndpoint;
@Value("${cameleer.saas.identity.spaclientid:}")
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,104 @@
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",
"/vendor/**", "/tenant/**",
"/environments/**", "/license", "/admin/**").permitAll()
.requestMatchers("/_app/**", "/favicon.ico", "/favicon.svg", "/logo.svg", "/logo-dark.svg").permitAll()
.requestMatchers("/api/vendor/**").hasAuthority("SCOPE_platform:admin")
.requestMatchers("/api/tenant/**").authenticated()
.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.saas.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,16 @@
package net.siegeln.cameleer.saas.config;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
public class SpaController {
@RequestMapping(value = {
"/", "/login", "/callback",
"/vendor/**", "/tenant/**"
})
public String forward() {
return "forward:/index.html";
}
}

View File

@@ -0,0 +1,99 @@
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;
// Strip context-path prefix to get the application-relative path.
// getServletPath() returns empty string in MockMvc, so use getRequestURI() minus contextPath.
String contextPath = request.getContextPath();
String uri = request.getRequestURI();
String path = (contextPath != null && !contextPath.isEmpty() && uri.startsWith(contextPath))
? uri.substring(contextPath.length()) : uri;
// Vendor endpoints: platform:admin already enforced by Spring Security
if (path.startsWith("/api/vendor/")) {
return true;
}
// 1. Resolve: JWT organization_id -> TenantContext (applies to all non-vendor paths)
Jwt jwt = jwtAuth.getToken();
String orgId = jwt.getClaimAsString("organization_id");
if (orgId != null) {
tenantService.getByLogtoOrgId(orgId)
.ifPresent(tenant -> TenantContext.setTenantId(tenant.getId()));
}
// Tenant portal endpoints: tenant resolved from JWT org context (no path variable)
if (path.startsWith("/api/tenant/")) {
if (TenantContext.getTenantId() == null) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "No organization context");
return false;
}
return true;
}
// 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,25 +1,74 @@
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 {
@Value("${cameleer.identity.logto-endpoint:}")
private static final Logger log = LoggerFactory.getLogger(LogtoConfig.class);
private static final String BOOTSTRAP_FILE = "/data/bootstrap/logto-bootstrap.json";
@Value("${cameleer.saas.identity.logtoendpoint:}")
private String logtoEndpoint;
@Value("${cameleer.identity.m2m-client-id:}")
@Value("${cameleer.saas.identity.m2mclientid:}")
private String m2mClientId;
@Value("${cameleer.identity.m2m-client-secret:}")
@Value("${cameleer.saas.identity.m2mclientsecret:}")
private String m2mClientSecret;
@Value("${cameleer.saas.identity.serverendpoint:http://cameleer3-server:8081}")
private String serverEndpoint;
private String tradAppId;
private String tradAppSecret;
@PostConstruct
public void init() {
if (isConfigured()) return;
// Fall back to bootstrap file for M2M credentials + trad app
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();
}
if (node.has("tradAppId")) {
tradAppId = node.get("tradAppId").asText();
}
if (node.has("tradAppSecret")) {
tradAppSecret = node.get("tradAppSecret").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 String getTradAppId() { return tradAppId; }
public String getTradAppSecret() { return tradAppSecret; }
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,359 @@ public class LogtoManagementClient {
.toBodilessEntity();
}
/** Add redirect URIs to a Logto application (for OIDC callback registration). */
@SuppressWarnings("unchecked")
public void addAppRedirectUris(String appId, List<String> redirectUris, List<String> postLogoutUris) {
if (!isAvailable() || appId == null) return;
try {
String token = getAccessToken();
// GET current app config
var app = (Map<String, Object>) restClient.get()
.uri(config.getLogtoEndpoint() + "/api/applications/" + appId)
.header("Authorization", "Bearer " + token)
.retrieve()
.body(Map.class);
if (app == null) return;
var metadata = (Map<String, Object>) app.get("oidcClientMetadata");
if (metadata == null) return;
// Merge new URIs with existing
var existingRedirects = new ArrayList<>((List<String>) metadata.getOrDefault("redirectUris", List.of()));
var existingPostLogout = new ArrayList<>((List<String>) metadata.getOrDefault("postLogoutRedirectUris", List.of()));
for (String uri : redirectUris) {
if (!existingRedirects.contains(uri)) existingRedirects.add(uri);
}
for (String uri : postLogoutUris) {
if (!existingPostLogout.contains(uri)) existingPostLogout.add(uri);
}
// PATCH app with updated URIs
restClient.patch()
.uri(config.getLogtoEndpoint() + "/api/applications/" + appId)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("oidcClientMetadata", Map.of(
"redirectUris", existingRedirects,
"postLogoutRedirectUris", existingPostLogout
)))
.retrieve()
.toBodilessEntity();
log.info("Updated redirect URIs for app {}: added {}", appId, redirectUris);
} catch (Exception e) {
log.warn("Failed to update redirect URIs for app {}: {}", appId, e.getMessage());
}
}
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();
}
}
/** List members of a Logto organization. */
@SuppressWarnings("unchecked")
public List<Map<String, Object>> listOrganizationMembers(String orgId) {
if (!isAvailable()) return List.of();
try {
var resp = restClient.get()
.uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId + "/users")
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.body(List.class);
return resp != null ? resp : List.of();
} catch (Exception e) {
log.warn("Failed to list org members for {}: {}", orgId, e.getMessage());
return List.of();
}
}
/** Get roles assigned to a user within an organization. */
@SuppressWarnings("unchecked")
public List<Map<String, Object>> getUserOrganizationRoles(String orgId, String userId) {
if (!isAvailable()) return List.of();
try {
var resp = restClient.get()
.uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId + "/users/" + userId + "/roles")
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.body(List.class);
return resp != null ? resp : List.of();
} catch (Exception e) {
log.warn("Failed to get user roles: {}", e.getMessage());
return List.of();
}
}
/** Assign a role to a user in an organization. */
public void assignOrganizationRole(String orgId, String userId, String roleId) {
if (!isAvailable()) return;
try {
restClient.post()
.uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId + "/users/" + userId + "/roles")
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("organizationRoleIds", List.of(roleId)))
.retrieve()
.toBodilessEntity();
} catch (Exception e) {
log.warn("Failed to assign role: {}", e.getMessage());
}
}
/** Remove a user from an organization. */
public void removeUserFromOrganization(String orgId, String userId) {
if (!isAvailable()) return;
try {
restClient.delete()
.uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId + "/users/" + userId)
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.toBodilessEntity();
} catch (Exception e) {
log.warn("Failed to remove user from org: {}", e.getMessage());
}
}
/** Create a user in Logto and add to organization with role. */
@SuppressWarnings("unchecked")
public String createAndInviteUser(String email, String orgId, String roleId) {
if (!isAvailable()) return null;
try {
var userResp = (Map<String, Object>) restClient.post()
.uri(config.getLogtoEndpoint() + "/api/users")
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("primaryEmail", email, "name", email.split("@")[0]))
.retrieve()
.body(Map.class);
String userId = String.valueOf(userResp.get("id"));
addUserToOrganization(orgId, userId);
if (roleId != null) {
assignOrganizationRole(orgId, userId, roleId);
}
return userId;
} catch (Exception e) {
log.error("Failed to create and invite user: {}", e.getMessage());
throw new RuntimeException("Invite failed: " + e.getMessage(), e);
}
}
/** Create a user with username/password and add to org with role. */
@SuppressWarnings("unchecked")
public String createUserWithPassword(String username, String password, String orgId, String roleId) {
if (!isAvailable()) return null;
try {
var userResp = (Map<String, Object>) restClient.post()
.uri(config.getLogtoEndpoint() + "/api/users")
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("username", username, "password", password, "name", username))
.retrieve()
.body(Map.class);
String userId = String.valueOf(userResp.get("id"));
addUserToOrganization(orgId, userId);
if (roleId != null) {
assignOrganizationRole(orgId, userId, roleId);
}
log.info("Created user '{}' and added to org {} with role {}", username, orgId, roleId);
return userId;
} catch (Exception e) {
log.error("Failed to create user '{}': {}", username, e.getMessage());
throw new RuntimeException("User creation failed: " + e.getMessage(), e);
}
}
/** Find org role ID by name (e.g., "owner", "operator", "viewer"). */
@SuppressWarnings("unchecked")
public String findOrgRoleIdByName(String roleName) {
var roles = listOrganizationRoles();
return roles.stream()
.filter(r -> roleName.equals(r.get("name")))
.map(r -> String.valueOf(r.get("id")))
.findFirst()
.orElse(null);
}
/** List available organization roles. */
@SuppressWarnings("unchecked")
public List<Map<String, Object>> listOrganizationRoles() {
if (!isAvailable()) return List.of();
try {
var resp = restClient.get()
.uri(config.getLogtoEndpoint() + "/api/organization-roles")
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.body(List.class);
return resp != null ? resp : List.of();
} catch (Exception e) {
log.warn("Failed to list org roles: {}", e.getMessage());
return List.of();
}
}
// --- SSO Connector Management ---
/** List all SSO connectors. */
@SuppressWarnings("unchecked")
public List<Map<String, Object>> listSsoConnectors() {
if (!isAvailable()) return List.of();
try {
var resp = restClient.get()
.uri(config.getLogtoEndpoint() + "/api/sso-connectors?page=1&page_size=100")
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.body(List.class);
return resp != null ? resp : List.of();
} catch (Exception e) {
log.warn("Failed to list SSO connectors: {}", e.getMessage());
return List.of();
}
}
/** Create an SSO connector. */
@SuppressWarnings("unchecked")
public Map<String, Object> createSsoConnector(String providerName, String connectorName,
Map<String, Object> connectorConfig, List<String> domains) {
if (!isAvailable()) return null;
var body = new java.util.HashMap<String, Object>();
body.put("providerName", providerName);
body.put("connectorName", connectorName);
if (connectorConfig != null && !connectorConfig.isEmpty()) body.put("config", connectorConfig);
if (domains != null && !domains.isEmpty()) body.put("domains", domains);
return (Map<String, Object>) restClient.post()
.uri(config.getLogtoEndpoint() + "/api/sso-connectors")
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.retrieve()
.body(Map.class);
}
/** Get an SSO connector by ID. */
@SuppressWarnings("unchecked")
public Map<String, Object> getSsoConnector(String connectorId) {
if (!isAvailable()) return null;
return (Map<String, Object>) restClient.get()
.uri(config.getLogtoEndpoint() + "/api/sso-connectors/" + connectorId)
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.body(Map.class);
}
/** Update an SSO connector (partial update). */
@SuppressWarnings("unchecked")
public Map<String, Object> updateSsoConnector(String connectorId, Map<String, Object> updates) {
if (!isAvailable()) return null;
return (Map<String, Object>) restClient.patch()
.uri(config.getLogtoEndpoint() + "/api/sso-connectors/" + connectorId)
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(updates)
.retrieve()
.body(Map.class);
}
/** Delete an SSO connector. */
public void deleteSsoConnector(String connectorId) {
if (!isAvailable()) return;
restClient.delete()
.uri(config.getLogtoEndpoint() + "/api/sso-connectors/" + connectorId)
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.toBodilessEntity();
}
/** List SSO connectors linked to an organization via JIT provisioning. */
@SuppressWarnings("unchecked")
public List<Map<String, Object>> getOrgJitSsoConnectors(String orgId) {
if (!isAvailable()) return List.of();
try {
var resp = restClient.get()
.uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId + "/jit/sso-connectors?page=1&page_size=100")
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.body(List.class);
return resp != null ? resp : List.of();
} catch (Exception e) {
log.warn("Failed to list org JIT SSO connectors for {}: {}", orgId, e.getMessage());
return List.of();
}
}
/** Link an SSO connector to an organization for JIT provisioning. */
public void linkSsoConnectorToOrg(String orgId, String connectorId) {
if (!isAvailable()) return;
restClient.post()
.uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId + "/jit/sso-connectors")
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("ssoConnectorIds", List.of(connectorId)))
.retrieve()
.toBodilessEntity();
}
/** Unlink an SSO connector from an organization's JIT provisioning. */
public void unlinkSsoConnectorFromOrg(String orgId, String connectorId) {
if (!isAvailable()) return;
restClient.delete()
.uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId + "/jit/sso-connectors/" + connectorId)
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.toBodilessEntity();
}
/** Update a user's password. */
public void updateUserPassword(String userId, String newPassword) {
if (!isAvailable()) throw new IllegalStateException("Logto not configured");
restClient.patch()
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/password")
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("password", newPassword))
.retrieve()
.toBodilessEntity();
}
/** Get a user by ID. Returns username, primaryEmail, name. */
@SuppressWarnings("unchecked")
public Map<String, Object> getUser(String userId) {
if (!isAvailable() || userId == null) return null;
try {
return (Map<String, Object>) restClient.get()
.uri(config.getLogtoEndpoint() + "/api/users/" + userId)
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.body(Map.class);
} catch (Exception e) {
log.warn("Failed to get user {}: {}", userId, e.getMessage());
return null;
}
}
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 +440,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,202 @@
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();
}
/** Health check for a specific tenant's server. */
@SuppressWarnings("unchecked")
public ServerHealthResponse getHealth(String serverEndpoint) {
try {
String url = serverEndpoint + "/api/v1/health";
var resp = RestClient.create().get().uri(url)
.header("Authorization", "Bearer " + getAccessToken())
.header("X-Cameleer-Protocol-Version", "1")
.retrieve()
.body(Map.class);
String status = resp != null ? String.valueOf(resp.get("status")) : "UNKNOWN";
return new ServerHealthResponse("UP".equals(status), status);
} catch (Exception e) {
log.warn("Health check failed for {}: {}", serverEndpoint, e.getMessage());
return new ServerHealthResponse(false, "DOWN");
}
}
/** Push OIDC configuration to a tenant's server. */
public void pushOidcConfig(String serverEndpoint, Map<String, Object> oidcConfig) {
try {
RestClient.create().put()
.uri(serverEndpoint + "/api/v1/admin/oidc")
.header("Authorization", "Bearer " + getAccessToken())
.header("X-Cameleer-Protocol-Version", "1")
.header("Content-Type", "application/json")
.body(oidcConfig)
.retrieve()
.toBodilessEntity();
log.info("Pushed OIDC config to {}", serverEndpoint);
} catch (Exception e) {
log.error("Failed to push OIDC config to {}: {}", serverEndpoint, e.getMessage());
throw new RuntimeException("OIDC config push failed: " + e.getMessage(), e);
}
}
/** Get OIDC configuration from a tenant's server. */
@SuppressWarnings("unchecked")
public Map<String, Object> getOidcConfig(String serverEndpoint) {
try {
return RestClient.create().get()
.uri(serverEndpoint + "/api/v1/admin/oidc")
.header("Authorization", "Bearer " + getAccessToken())
.header("X-Cameleer-Protocol-Version", "1")
.retrieve()
.body(Map.class);
} catch (Exception e) {
log.warn("Failed to get OIDC config from {}: {}", serverEndpoint, e.getMessage());
return Map.of();
}
}
/** Fetch agent count from a tenant's server. */
public int getAgentCount(String serverEndpoint) {
try {
var resp = RestClient.create().get()
.uri(serverEndpoint + "/api/v1/agents")
.header("Authorization", "Bearer " + getAccessToken())
.header("X-Cameleer-Protocol-Version", "1")
.retrieve()
.body(java.util.List.class);
return resp != null ? resp.size() : 0;
} catch (Exception e) {
log.warn("Agent count fetch failed for {}: {}", serverEndpoint, e.getMessage());
return 0;
}
}
/** Fetch environment count from a tenant's server. */
public int getEnvironmentCount(String serverEndpoint) {
try {
var resp = RestClient.create().get()
.uri(serverEndpoint + "/api/v1/admin/environments")
.header("Authorization", "Bearer " + getAccessToken())
.header("X-Cameleer-Protocol-Version", "1")
.retrieve()
.body(java.util.List.class);
return resp != null ? resp.size() : 0;
} catch (Exception e) {
log.warn("Environment count fetch failed for {}: {}", serverEndpoint, e.getMessage());
return 0;
}
}
/** Reset the built-in admin password on a tenant's server. */
public void resetServerAdminPassword(String serverEndpoint, String newPassword) {
RestClient.create(serverEndpoint)
.post()
.uri("/api/v1/admin/users/user:admin/password")
.header("Authorization", "Bearer " + getAccessToken())
.header("X-Cameleer-Protocol-Version", "1")
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("password", newPassword))
.retrieve()
.toBodilessEntity();
}
public record ServerHealthResponse(boolean healthy, String status) {}
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,31 @@ public class LicenseService {
return licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId);
}
public void revokeLicense(UUID tenantId, UUID actorId) {
licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId)
.ifPresent(license -> {
license.setRevokedAt(Instant.now());
licenseRepository.save(license);
auditService.log(actorId, null, tenantId,
AuditAction.LICENSE_REVOKE, "license",
null, null, null, Map.of("licenseId", license.getId().toString()));
});
}
/**
* 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,5 +1,7 @@
package net.siegeln.cameleer.saas.license.dto;
import net.siegeln.cameleer.saas.license.LicenseEntity;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
@@ -13,4 +15,13 @@ public record LicenseResponse(
Instant issuedAt,
Instant expiresAt,
String token
) {}
) {
public static LicenseResponse from(LicenseEntity e) {
return new LicenseResponse(
e.getId(), e.getTenantId(), e.getTier(),
e.getFeatures(), e.getLimits(),
e.getIssuedAt(), e.getExpiresAt(),
e.getToken()
);
}
}

View File

@@ -0,0 +1,45 @@
package net.siegeln.cameleer.saas.portal;
import net.siegeln.cameleer.saas.audit.AuditDto.AuditLogPage;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.config.TenantContext;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
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.UUID;
@RestController
@RequestMapping("/api/tenant/audit")
public class TenantAuditController {
private final AuditService auditService;
public TenantAuditController(AuditService auditService) {
this.auditService = auditService;
}
@GetMapping
public ResponseEntity<AuditLogPage> list(
@RequestParam(required = false) String action,
@RequestParam(required = false) String result,
@RequestParam(required = false) String search,
@RequestParam(required = false) Instant from,
@RequestParam(required = false) Instant to,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "25") int size) {
UUID tenantId = TenantContext.getTenantId();
size = Math.min(size, 100);
var pageResult = auditService.search(tenantId, action, result, from, to, search,
PageRequest.of(page, size));
return ResponseEntity.ok(new AuditLogPage(
pageResult.getContent(), pageResult.getNumber(), pageResult.getSize(),
pageResult.getTotalElements(), pageResult.getTotalPages()));
}
}

View File

@@ -0,0 +1,195 @@
package net.siegeln.cameleer.saas.portal;
import net.siegeln.cameleer.saas.certificate.TenantCaCertEntity;
import net.siegeln.cameleer.saas.certificate.TenantCaCertService;
import net.siegeln.cameleer.saas.config.TenantContext;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/tenant")
public class TenantPortalController {
private final TenantPortalService portalService;
private final TenantCaCertService caCertService;
public TenantPortalController(TenantPortalService portalService, TenantCaCertService caCertService) {
this.portalService = portalService;
this.caCertService = caCertService;
}
// --- Request bodies ---
public record InviteRequest(String email, String roleId) {}
public record RoleChangeRequest(String roleId) {}
public record PasswordChangeRequest(String password) {}
// --- Endpoints ---
@GetMapping("/dashboard")
public ResponseEntity<TenantPortalService.DashboardData> getDashboard() {
return ResponseEntity.ok(portalService.getDashboard());
}
@GetMapping("/license")
public ResponseEntity<TenantPortalService.LicenseData> getLicense() {
var license = portalService.getLicense();
if (license == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(license);
}
@GetMapping("/team")
public ResponseEntity<List<Map<String, Object>>> listTeamMembers() {
return ResponseEntity.ok(portalService.listTeamMembers());
}
@PostMapping("/team/invite")
public ResponseEntity<Map<String, String>> inviteTeamMember(@RequestBody InviteRequest body) {
String userId = portalService.inviteTeamMember(body.email(), body.roleId());
return ResponseEntity.ok(Map.of("userId", userId != null ? userId : ""));
}
@DeleteMapping("/team/{userId}")
public ResponseEntity<Void> removeTeamMember(@PathVariable String userId) {
portalService.removeTeamMember(userId);
return ResponseEntity.noContent().build();
}
@PatchMapping("/team/{userId}/role")
public ResponseEntity<Void> changeTeamMemberRole(@PathVariable String userId,
@RequestBody RoleChangeRequest body) {
portalService.changeTeamMemberRole(userId, body.roleId());
return ResponseEntity.ok().build();
}
@PostMapping("/server/admin-password")
public ResponseEntity<Void> resetServerAdminPassword(@RequestBody PasswordChangeRequest body) {
try {
portalService.resetServerAdminPassword(body.password());
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
} catch (IllegalStateException e) {
return ResponseEntity.badRequest().build();
}
}
@PostMapping("/password")
public ResponseEntity<Void> changeOwnPassword(@AuthenticationPrincipal Jwt jwt,
@RequestBody PasswordChangeRequest body) {
try {
portalService.changePassword(jwt.getSubject(), body.password());
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
}
@PostMapping("/team/{userId}/password")
public ResponseEntity<Void> resetTeamMemberPassword(@PathVariable String userId,
@RequestBody PasswordChangeRequest body) {
try {
portalService.resetTeamMemberPassword(userId, body.password());
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
}
@PostMapping("/server/restart")
public ResponseEntity<Void> restartServer() {
portalService.restartServer();
return ResponseEntity.noContent().build();
}
@PostMapping("/server/upgrade")
public ResponseEntity<Void> upgradeServer() {
portalService.upgradeServer();
return ResponseEntity.noContent().build();
}
@GetMapping("/settings")
public ResponseEntity<TenantPortalService.TenantSettingsData> getSettings() {
return ResponseEntity.ok(portalService.getSettings());
}
// --- CA Certificate management ---
public record CaCertResponse(
UUID id, String status, String label, String subject, String issuer,
String fingerprint, Instant notBefore, Instant notAfter, Instant createdAt
) {
public static CaCertResponse from(TenantCaCertEntity e) {
return new CaCertResponse(
e.getId(), e.getStatus().name(), e.getLabel(), e.getSubject(), e.getIssuer(),
e.getFingerprint(), e.getNotBefore(), e.getNotAfter(), e.getCreatedAt()
);
}
}
@GetMapping("/ca")
public ResponseEntity<List<CaCertResponse>> listCaCerts() {
UUID tenantId = TenantContext.getTenantId();
return ResponseEntity.ok(
caCertService.listForTenant(tenantId).stream().map(CaCertResponse::from).toList()
);
}
@PostMapping("/ca")
public ResponseEntity<CaCertResponse> stageCaCert(
@RequestParam("cert") MultipartFile certFile,
@RequestParam(value = "label", required = false) String label) {
try {
UUID tenantId = TenantContext.getTenantId();
var entity = caCertService.stage(tenantId, label, certFile.getBytes());
return ResponseEntity.ok(CaCertResponse.from(entity));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
} catch (Exception e) {
return ResponseEntity.internalServerError().build();
}
}
@PostMapping("/ca/{id}/activate")
public ResponseEntity<CaCertResponse> activateCaCert(@PathVariable UUID id) {
try {
UUID tenantId = TenantContext.getTenantId();
var entity = caCertService.activate(tenantId, id);
return ResponseEntity.ok(CaCertResponse.from(entity));
} catch (IllegalArgumentException | IllegalStateException e) {
return ResponseEntity.badRequest().build();
}
}
@DeleteMapping("/ca/{id}")
public ResponseEntity<Void> deleteCaCert(@PathVariable UUID id) {
try {
UUID tenantId = TenantContext.getTenantId();
caCertService.delete(tenantId, id);
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
}

View File

@@ -0,0 +1,260 @@
package net.siegeln.cameleer.saas.portal;
import net.siegeln.cameleer.saas.config.TenantContext;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.identity.ServerApiClient;
import net.siegeln.cameleer.saas.license.LicenseEntity;
import net.siegeln.cameleer.saas.license.LicenseService;
import net.siegeln.cameleer.saas.provisioning.ProvisioningProperties;
import net.siegeln.cameleer.saas.provisioning.TenantProvisioner;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import net.siegeln.cameleer.saas.tenant.TenantService;
import net.siegeln.cameleer.saas.vendor.VendorTenantService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@Service
public class TenantPortalService {
private static final Logger log = LoggerFactory.getLogger(TenantPortalService.class);
private final TenantService tenantService;
private final LicenseService licenseService;
private final ServerApiClient serverApiClient;
private final LogtoManagementClient logtoClient;
private final TenantProvisioner tenantProvisioner;
private final ProvisioningProperties provisioningProps;
private final VendorTenantService vendorTenantService;
public TenantPortalService(TenantService tenantService,
LicenseService licenseService,
ServerApiClient serverApiClient,
LogtoManagementClient logtoClient,
TenantProvisioner tenantProvisioner,
ProvisioningProperties provisioningProps,
@Lazy VendorTenantService vendorTenantService) {
this.tenantService = tenantService;
this.licenseService = licenseService;
this.serverApiClient = serverApiClient;
this.logtoClient = logtoClient;
this.tenantProvisioner = tenantProvisioner;
this.provisioningProps = provisioningProps;
this.vendorTenantService = vendorTenantService;
}
// --- Inner records ---
public record DashboardData(
String name, String slug, String tier, String status,
boolean serverHealthy, String serverStatus, String serverEndpoint,
String licenseTier, long licenseDaysRemaining,
Map<String, Object> limits, Map<String, Object> features,
int agentCount, int environmentCount
) {}
public record LicenseData(
UUID id, String tier, Map<String, Object> features, Map<String, Object> limits,
Instant issuedAt, Instant expiresAt, String token, long daysRemaining
) {}
public record TenantSettingsData(
String name, String slug, String tier, String status,
String serverEndpoint, Instant createdAt
) {}
// --- Helpers ---
private TenantEntity resolveTenant() {
UUID tenantId = TenantContext.getTenantId();
return tenantService.getById(tenantId)
.orElseThrow(() -> new IllegalStateException("Tenant not found: " + tenantId));
}
private long daysUntil(Instant instant) {
if (instant == null) return 0;
long days = ChronoUnit.DAYS.between(Instant.now(), instant);
return Math.max(0, days);
}
// --- Service methods ---
public DashboardData getDashboard() {
TenantEntity tenant = resolveTenant();
String endpoint = tenant.getServerEndpoint();
boolean serverHealthy = false;
String serverStatus = "NO_ENDPOINT";
int agentCount = 0;
int environmentCount = 0;
if (endpoint != null && !endpoint.isBlank()) {
var health = serverApiClient.getHealth(endpoint);
serverHealthy = health.healthy();
serverStatus = health.status();
if (serverHealthy) {
agentCount = serverApiClient.getAgentCount(endpoint);
environmentCount = serverApiClient.getEnvironmentCount(endpoint);
}
}
String licenseTier = null;
long licenseDaysRemaining = 0;
Map<String, Object> limits = Map.of();
Map<String, Object> features = Map.of();
var licenseOpt = licenseService.getActiveLicense(tenant.getId());
if (licenseOpt.isPresent()) {
LicenseEntity lic = licenseOpt.get();
licenseTier = lic.getTier();
licenseDaysRemaining = daysUntil(lic.getExpiresAt());
limits = lic.getLimits() != null ? lic.getLimits() : Map.of();
features = lic.getFeatures() != null ? lic.getFeatures() : Map.of();
}
return new DashboardData(
tenant.getName(), tenant.getSlug(),
tenant.getTier().name(), tenant.getStatus().name(),
serverHealthy, serverStatus, endpoint,
licenseTier, licenseDaysRemaining,
limits, features, agentCount, environmentCount
);
}
public LicenseData getLicense() {
TenantEntity tenant = resolveTenant();
return licenseService.getActiveLicense(tenant.getId())
.map(lic -> new LicenseData(
lic.getId(), lic.getTier(),
lic.getFeatures() != null ? lic.getFeatures() : Map.of(),
lic.getLimits() != null ? lic.getLimits() : Map.of(),
lic.getIssuedAt(), lic.getExpiresAt(),
lic.getToken(), daysUntil(lic.getExpiresAt())
))
.orElse(null);
}
public List<Map<String, Object>> listTeamMembers() {
TenantEntity tenant = resolveTenant();
String orgId = tenant.getLogtoOrgId();
if (orgId == null || orgId.isBlank()) {
return List.of();
}
return logtoClient.listOrganizationMembers(orgId);
}
public String inviteTeamMember(String email, String roleId) {
TenantEntity tenant = resolveTenant();
String orgId = tenant.getLogtoOrgId();
if (orgId == null || orgId.isBlank()) {
throw new IllegalStateException("Tenant has no Logto organization configured");
}
return logtoClient.createAndInviteUser(email, orgId, roleId);
}
public void removeTeamMember(String userId) {
TenantEntity tenant = resolveTenant();
String orgId = tenant.getLogtoOrgId();
if (orgId == null || orgId.isBlank()) {
throw new IllegalStateException("Tenant has no Logto organization configured");
}
logtoClient.removeUserFromOrganization(orgId, userId);
}
public void changeTeamMemberRole(String userId, String roleId) {
TenantEntity tenant = resolveTenant();
String orgId = tenant.getLogtoOrgId();
if (orgId == null || orgId.isBlank()) {
throw new IllegalStateException("Tenant has no Logto organization configured");
}
logtoClient.assignOrganizationRole(orgId, userId, roleId);
}
public void resetServerAdminPassword(String newPassword) {
TenantEntity tenant = resolveTenant();
String endpoint = tenant.getServerEndpoint();
if (endpoint == null || endpoint.isBlank()) {
throw new IllegalStateException("Server not provisioned yet");
}
if (newPassword == null || newPassword.length() < 8) {
throw new IllegalArgumentException("Password must be at least 8 characters");
}
serverApiClient.resetServerAdminPassword(endpoint, newPassword);
}
public void changePassword(String userId, String newPassword) {
if (newPassword == null || newPassword.length() < 8) {
throw new IllegalArgumentException("Password must be at least 8 characters");
}
logtoClient.updateUserPassword(userId, newPassword);
}
public void resetTeamMemberPassword(String userId, String newPassword) {
TenantEntity tenant = resolveTenant();
String orgId = tenant.getLogtoOrgId();
if (orgId == null || orgId.isBlank()) {
throw new IllegalStateException("Tenant has no Logto organization configured");
}
// Verify the target user belongs to this tenant's org
var members = logtoClient.listOrganizationMembers(orgId);
boolean isMember = members.stream()
.anyMatch(m -> userId.equals(String.valueOf(m.get("id"))));
if (!isMember) {
throw new IllegalArgumentException("User is not a member of this organization");
}
if (newPassword == null || newPassword.length() < 8) {
throw new IllegalArgumentException("Password must be at least 8 characters");
}
logtoClient.updateUserPassword(userId, newPassword);
}
public TenantSettingsData getSettings() {
TenantEntity tenant = resolveTenant();
String publicEndpoint = provisioningProps.publicProtocol() + "://"
+ provisioningProps.publicHost() + "/t/" + tenant.getSlug() + "/";
return new TenantSettingsData(
tenant.getName(), tenant.getSlug(),
tenant.getTier().name(), tenant.getStatus().name(),
publicEndpoint, tenant.getCreatedAt()
);
}
public void restartServer() {
TenantEntity tenant = resolveTenant();
if (!tenantProvisioner.isAvailable()) return;
tenantProvisioner.stop(tenant.getSlug());
try {
tenantProvisioner.start(tenant.getSlug());
} catch (RuntimeException e) {
if (e.getMessage() != null && e.getMessage().contains("re-provision required")) {
log.info("Containers missing for '{}' — re-provisioning", tenant.getSlug());
tenantProvisioner.remove(tenant.getSlug());
var license = licenseService.getActiveLicense(tenant.getId()).orElse(null);
String token = license != null ? license.getToken() : "";
vendorTenantService.provisionAsync(
tenant.getId(), tenant.getSlug(), tenant.getTier().name(), token, null);
return;
}
throw e;
}
}
public void upgradeServer() {
TenantEntity tenant = resolveTenant();
if (!tenantProvisioner.isAvailable()) return;
tenantProvisioner.upgrade(tenant.getSlug());
var license = licenseService.getActiveLicense(tenant.getId()).orElse(null);
String token = license != null ? license.getToken() : "";
vendorTenantService.provisionAsync(
tenant.getId(), tenant.getSlug(), tenant.getTier().name(), token, null);
}
}

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