38 Commits

Author SHA1 Message Date
hsiegeln
df25dcf81a fix(tenant): reuse existing Logto users and clean up on delete
All checks were successful
CI / build (push) Successful in 2m14s
CI / docker (push) Successful in 1m10s
Create: if admin email matches an existing Logto user, add them to the
tenant org instead of creating a duplicate account. Only creates a new
user when no match is found and a password is provided.

Delete: before deleting the Logto org, list its members. After org
deletion, delete tenant-only users (those with no remaining org
memberships). Users who belong to other orgs are preserved.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 20:25:21 +02:00
hsiegeln
029f2ef0de fix(onboarding): skip org name prompt for vendor-created admins
All checks were successful
CI / build (push) Successful in 2m11s
CI / docker (push) Successful in 1m40s
Vendor-created tenant admins already have an org membership. When they
land on /onboarding (first login, token lacks org claims), detect the
existing tenant via /api/me and trigger a re-auth to pick up org
membership instead of showing the org name form.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 20:05:29 +02:00
hsiegeln
345bc4a92b feat(tenant): welcome email, admin email display, async delete fix
All checks were successful
CI / build (push) Successful in 2m20s
CI / docker (push) Successful in 1m29s
- Send branded welcome email to tenant admin after provisioning completes
  (includes username and dashboard URL)
- Store admin_email on tenant entity (V004 migration)
- Show admin email in vendor tenant list table and detail page
- Fix ClickHouse cleanup: skip materialized views (can't ALTER DELETE on MVs)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 19:53:47 +02:00
hsiegeln
bd301ad1fe refactor(tenant): replace tier+username with email-first creation
All checks were successful
CI / build (push) Successful in 2m9s
CI / docker (push) Successful in 1m37s
- Remove tier from create tenant form (always defaults to STARTER,
  controlled via license minting)
- Admin email is now the primary identity field
- Username auto-derived from email local part, optionally overridable
- Set primaryEmail on Logto user at creation (prevents invalid accounts)
- Async tenant delete: PG/ClickHouse cleanup runs after commit instead
  of blocking the HTTP response
- Remove legacy /server/* OIDC redirect URIs from bootstrap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 19:34:00 +02:00
hsiegeln
15c47fe36c fix(auth): register tenant /login as OIDC post-logout redirect URI
All checks were successful
CI / build (push) Successful in 2m22s
CI / docker (push) Successful in 1m7s
Server sends /t/{slug}/login as post_logout_redirect_uri on logout but
only /t/{slug} and /t/{slug}/login?local were registered, causing
"post_logout_redirect_uri not registered" error from Logto.

Also removes legacy /server/* redirect URIs from bootstrap (greenfield).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 19:15:18 +02:00
hsiegeln
61fc7f224f fix(ci): remove log-appender from runtime base image
All checks were successful
CI / build (push) Successful in 2m25s
CI / docker (push) Successful in 1m11s
The log appender is now embedded in cameleer-core, so it no longer
needs to be downloaded separately and baked into the runtime base
image. Removes the Maven download step and the Dockerfile COPY.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 18:56:29 +02:00
hsiegeln
11646b93ff fix(ci): update Maven artifact paths from com/cameleer to io/cameleer
Some checks failed
CI / build (push) Successful in 3m19s
CI / docker (push) Failing after 13s
The agent and log-appender SNAPSHOTs were republished under the
io.cameleer groupId after the rebrand. The runtime base image build
was failing because the old com/cameleer paths no longer resolve.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 18:45:36 +02:00
hsiegeln
fcb25778e1 fix(sign-in): TOTP enrollment QR branding and verification failure
Some checks failed
CI / build (push) Successful in 3m6s
CI / docker (push) Failing after 10s
Two bugs in the sign-in UI's TOTP MFA enrollment flow:

1. Auth app displayed the PC hostname and "Platform Owner" instead of
   "Cameleer" and the user's email. The sign-in UI was rendering Logto's
   pre-generated QR code which uses the ENDPOINT hostname as issuer.
   Now generates our own otpauth:// URI with proper branding, rendered
   client-side via qrcode.react.

2. TOTP code verification returned 400 "Invalid TOTP code". The
   verifyTotpSetup() call was missing the required verificationId
   parameter — Logto's Experience API needs it to locate the pending
   secret during enrollment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 18:34:52 +02:00
hsiegeln
3aba32302a refactor: update license-minter dependency to io.cameleer
All checks were successful
CI / build (push) Successful in 2m57s
CI / docker (push) Successful in 1m35s
Other teams completed their com.cameleer → io.cameleer migration.
Update Maven groupId and Java imports to match.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 17:13:28 +02:00
hsiegeln
2fa8ba07de fix: swap Chainguard JRE to BellSoft Liberica JRE 21
All checks were successful
CI / build (push) Successful in 2m16s
CI / docker (push) Successful in 1m40s
Chainguard free tier only offers :latest (currently JDK 26, unpinned);
the :openjdk-21 tag requires a paid subscription, breaking CI.

Switch both Dockerfiles to bellsoft/liberica-runtime-container:jre-21-slim-glibc:
- Pinned to JDK 21 LTS
- Smallest image (199 MB vs 441/491 MB)
- glibc-based Alpaquita Linux, sh-only (no bash, no pkg manager)
- Free, multi-arch (amd64 + arm64)
- Has sh — required by cameleer-server's DeploymentExecutor (withCmd "sh -c")

Use nobody:nobody (65534) instead of Chainguard's nonroot (65532).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 16:52:55 +02:00
hsiegeln
966691f2c8 refactor: rebrand from net.siegeln to io.cameleer
Some checks failed
CI / build (push) Successful in 3m7s
CI / docker (push) Failing after 7s
Institutionalize the product identity ahead of public release:

- Java package: net.siegeln.cameleer.saas → io.cameleer.saas (109 files)
- Maven groupId: net.siegeln.cameleer → io.cameleer
- Public image defaults: gitea.siegeln.net/cameleer/ → registry.cameleer.io/cameleer/
- Updated docs (architecture, user-manual, HOWTO, runtime-loader README)
- Updated CLAUDE.md path references

Internal build infra (CI workflows, .gitmodules, npm registry, Maven repo)
intentionally kept at gitea.siegeln.net — code stays on internal Gitea.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 16:26:34 +02:00
hsiegeln
6ac06d6859 docs: document the runtime-loader image (moved here from cameleer-server)
Some checks failed
CI / build (push) Successful in 3m10s
CI / docker (push) Failing after 8s
Note that the loader source now lives at docker/runtime-loader/, that
the contract is owned by cameleer-server's DockerRuntimeOrchestrator
(don't change env vars / mount path / exit codes without a coordinated
commit there), and that cameleer-server's LoaderHardeningIT is the
cross-repo regression guard. Also document the chown-/app/jars line
(strip it and tenant deploys break with "wget: Permission denied").
2026-04-28 13:05:49 +02:00
hsiegeln
ac8d628271 feat(ci): build and push cameleer-runtime-loader image
Some checks failed
CI / build (push) Successful in 2m1s
CI / docker (push) Failing after 7s
Move the init-container loader image build from cameleer-server CI into
this repo so all sidecar/infra image builds (runtime-base, postgres,
clickhouse, traefik, logto, and now runtime-loader) live in one place.

The loader is consumed by cameleer-server's DockerRuntimeOrchestrator as
a per-replica init container that fetches the tenant JAR from a signed
URL into a named volume before the main container starts. Source +
Dockerfile copied verbatim from cameleer-server@c2efb7fb (the image with
the volume-permission fix). The published tag path is unchanged
(gitea.siegeln.net/cameleer/cameleer-runtime-loader:latest), so running
tenant servers continue pulling the same image.

Build step matches the runtime-base/postgres/clickhouse/traefik pattern
(unconditional rebuild on every push, sha + branch tags, --provenance=false
for Gitea). cameleer-server will follow up with a commit removing its
loader-build step and switching its LoaderHardeningIT to pull the
published image instead of building from a local Dockerfile.
2026-04-28 13:00:23 +02:00
hsiegeln
bc32d7e994 fix: use license/usage endpoint for agent/env/app counts
Some checks failed
CI / docker (push) Has been cancelled
CI / build (push) Has been cancelled
Server moved GET /agents to /environments/{envSlug}/agents and removed
GET /admin/apps. Replace three broken individual calls with a single
GET /admin/license/usage call that returns all counts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 12:58:35 +02:00
hsiegeln
c43d7f639f harden: swap cameleer-saas runtime stage to Chainguard JRE
Some checks failed
CI / build (push) Successful in 3m22s
CI / docker (push) Failing after 9s
Replace eclipse-temurin:21-jre-alpine with cgr.dev/chainguard/jre:openjdk-21
for the SaaS management plane image. Use Chainguard's built-in nonroot user
instead of custom adduser. Build stages unchanged (ephemeral).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 09:42:38 +02:00
hsiegeln
5f210b76a9 harden: swap runtime base to Chainguard JRE, remove dead ENTRYPOINT
Replace eclipse-temurin:21-jre-alpine (musl) with cgr.dev/chainguard/jre:openjdk-21
(Wolfi/glibc, daily CVE refresh, signed images + SBOM). Remove the dead ENTRYPOINT
block — DeploymentExecutor overrides it at container creation anyway.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 09:32:49 +02:00
hsiegeln
06134d6e67 fix: TOTP label includes org name, passkeys show device as default name
Some checks failed
CI / build (push) Successful in 2m10s
CI / docker (push) Successful in 1m27s
SonarQube Analysis / sonarqube (push) Failing after 2m57s
- TOTP otpauth URI issuer changed from "Cameleer" to "Cameleer - <org>"
  so authenticator apps display the organization name
- Passkeys without a custom name now show parsed device info (e.g.
  "Chrome on Windows") instead of "Unnamed passkey"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 22:53:05 +02:00
hsiegeln
7fe9c581b0 fix: remove MFA card from tenant settings, constrain card widths
All checks were successful
CI / build (push) Successful in 2m10s
CI / docker (push) Successful in 1m25s
MFA enrollment now happens during sign-in. Tenant settings page reduced
to: Tenant Details + Auth Policy side-by-side (max 520px each), Passkeys
full-width below.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 22:45:31 +02:00
hsiegeln
7fc8a4d407 fix: team invite role resolution, user cleanup, and settings page redesign
All checks were successful
CI / build (push) Successful in 2m9s
CI / docker (push) Successful in 1m33s
- Resolve org role names to Logto role IDs in invite and role change flows
  (fixes entity.relation_foreign_key_not_found on invite)
- Handle existing Logto users on re-invite instead of failing with
  email_already_in_use
- Delete users from Logto when removed from last org membership
- Consolidate tenant settings page into 3 cards: Tenant Details, MFA,
  Authentication Policy — remove duplicate MFA Enforcement and Change
  Password (now in Account Settings)
- Make passkey list scrollable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 22:36:21 +02:00
hsiegeln
e21a9d6046 fix: override WebAuthn type in authentication verify too
All checks were successful
CI / build (push) Successful in 1m57s
CI / docker (push) Successful in 1m30s
Same fix as registration verify — @simplewebauthn/browser returns
type: "public-key" but Logto expects type: "WebAuthn".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 20:57:37 +02:00
hsiegeln
0481cefaf4 fix: sign-in MFA flow overhaul — passkey verify, backup codes, defaults
All checks were successful
CI / build (push) Successful in 2m19s
CI / docker (push) Successful in 1m4s
Four fixes for the MFA sign-in flow:

1. Fix passkey verify crash: extract authenticationOptions from Logto
   response (was passing full response as optionsJSON). Pass
   verificationId to the verify endpoint.

2. Default to passkey verification when no MFA method preference is
   stored (was showing method picker which offered TOTP to passkey-only
   users).

3. Show backup codes after MFA enrollment: new mfaEnrollBackupCodes
   mode with copy/download buttons and confirmation checkbox. Users
   must save codes before completing sign-in.

4. Remove duplicate error alerts in enrollment screens (top-level
   alert handles all modes).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 20:49:32 +02:00
hsiegeln
040ae60be5 fix: correct Experience API endpoints for TOTP and backup codes
All checks were successful
CI / build (push) Successful in 2m4s
CI / docker (push) Successful in 1m1s
- TOTP secret: /verification/totp/secret (not /verification/totp)
- Backup codes: generate via /verification/backup-code/generate first,
  then bind with the returned verificationId. Cannot bind BackupCode
  without generating codes first.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 19:30:54 +02:00
hsiegeln
d8f7452ab7 feat: full MFA enrollment during sign-in — passkey + TOTP + backup codes
All checks were successful
CI / build (push) Successful in 2m2s
CI / docker (push) Successful in 1m10s
- Bind BackupCode after primary MFA factor (WebAuthn or TOTP) to satisfy
  Logto's requirement that backup codes accompany any MFA method.
- Add TOTP enrollment option alongside passkey on the enrollment screen:
  "Use passkey" / "Use authenticator app" / "Set up later".
- TOTP enrollment shows QR code + secret + 6-digit verification inline
  in the sign-in UI, using Experience API endpoints.
- Added createTotpSecret() and verifyTotpSetup() to experience-api.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 19:22:53 +02:00
hsiegeln
c4fe16048c fix: include WebAuthn in bootstrap MFA factors
All checks were successful
CI / build (push) Successful in 2m14s
CI / docker (push) Successful in 25s
Bootstrap only set [Totp, BackupCode] — WebAuthn was missing. Now
matches LogtoStartupConfig: all three factors available from first boot.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 19:04:47 +02:00
hsiegeln
cba420fbeb fix: always offer MFA+passkey enrollment, separate availability from enforcement
All checks were successful
CI / build (push) Successful in 2m19s
CI / docker (push) Successful in 1m43s
Two fundamental fixes:

- user.missing_mfa now triggers MfaEnrollmentError (enroll UI) instead
  of MfaRequiredError (verify UI). Users without MFA were shown a TOTP
  code prompt they couldn't fill.
- Logto MFA factors always set to [Totp, WebAuthn, BackupCode] with
  UserControlled policy on startup. Availability is always-on for all
  users. The vendor auth policy controls enforcement (via
  MfaEnforcementFilter), not what Logto offers during sign-in.
- Removed syncMfaConfigToLogto from VendorAuthPolicyController — vendor
  policy changes no longer modify Logto's sign-in experience.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:59:21 +02:00
hsiegeln
67ec409383 fix: null display name, settings scrollbar, redundant passkey offer
All checks were successful
CI / build (push) Successful in 2m20s
CI / docker (push) Successful in 1m36s
- Profile API returns empty string instead of "null" when Logto user
  has no display name set (String.valueOf(null) → "null" bug).
- SettingsPage: add overflowY auto + flex 1 so content scrolls within
  the AppShell layout (which uses overflow: hidden).
- Remove redundant passkey offer from onboarding page — passkey
  enrollment now happens during sign-in via the Experience API.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:53:13 +02:00
hsiegeln
3384510f3c fix: passkeys work independently of MFA mode
All checks were successful
CI / build (push) Successful in 2m15s
CI / docker (push) Successful in 1m1s
When MFA mode is off but passkeys are enabled, WebAuthn + BackupCode
factors are still synced to Logto. Previously, MFA off cleared all
factors including WebAuthn, so passkey enrollment was never offered.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:45:30 +02:00
hsiegeln
18e6f32f90 refactor: move passkey enrollment to sign-in UI via Experience API
All checks were successful
CI / build (push) Successful in 2m12s
CI / docker (push) Successful in 1m49s
Remove the SaaS backend proxy approach for passkey registration (Account
API binding, Management API proxy, password modal in PasskeySection).
Instead, offer passkey enrollment natively during sign-in via Logto's
Experience API — the correct architectural layer.

Sign-in flow: when Logto returns MFA enrollment available (422), show a
"Secure your account" screen with Register passkey / Set up later. Uses
Experience API WebAuthn registration endpoints. Works for all users
(SaaS and future server users) since the sign-in UI is shared.

PasskeySection in account settings now only manages existing passkeys
(list/rename/delete) and directs users to register during sign-in.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:33:46 +02:00
hsiegeln
4df6fc9e03 fix: proxy passkey bind through Management API
All checks were successful
CI / build (push) Successful in 2m17s
CI / docker (push) Successful in 1m29s
Logto's /api/my-account/ endpoints reject the opaque access token with
401 even though /api/verifications/ accepts it. The bind step now goes
through the SaaS backend which calls the Management API instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:23:41 +02:00
hsiegeln
2aa5100530 fix: add password re-verification before passkey registration
All checks were successful
CI / build (push) Successful in 2m28s
CI / docker (push) Successful in 1m32s
Logto Account API requires identity verification (logto-verification-id
header) for sensitive MFA operations. Adds a password prompt modal before
the WebAuthn ceremony — verifies password first, then proceeds with
passkey registration using the verification record ID.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:10:47 +02:00
hsiegeln
c360d9ad5f fix: override WebAuthn credential type for Logto Account API
All checks were successful
CI / build (push) Successful in 3m29s
CI / docker (push) Successful in 2m14s
@simplewebauthn/browser returns type: "public-key" (W3C standard) but
Logto's verify endpoint expects type: "WebAuthn".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 17:59:53 +02:00
hsiegeln
e7952dd9de fix: keep vendor sidebar active on account settings page
All checks were successful
CI / build (push) Successful in 1m54s
CI / docker (push) Successful in 1m26s
Vendor sidebar collapsed and tenant sidebar appeared when navigating to
/settings/account because onVendorRoute was false for non-/vendor paths.
Now vendor users stay on vendor sidebar for all routes except /tenant/*.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 17:18:09 +02:00
hsiegeln
687598952f fix: correct Account Center enablement — mfa field is a string enum
All checks were successful
CI / build (push) Successful in 2m6s
CI / docker (push) Successful in 1m6s
Logto's PATCH /api/account-center expects mfa as 'Off'|'ReadOnly'|'Edit',
not a nested object. Fixes 400 Bad Request on startup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 17:14:31 +02:00
hsiegeln
c22580e124 feat: always enable WebAuthn in MFA factors and add passkey registration
All checks were successful
CI / build (push) Successful in 2m3s
CI / docker (push) Successful in 1m26s
- Sync vendor auth policy to Logto sign-in experience on save and on
  startup. Always include WebAuthn + TOTP + BackupCode in MFA factors
  when MFA is enabled — no reason to gate passkeys behind a toggle.
- Enable Logto Account Center on startup for user-facing MFA management.
- Add passkey registration to account settings via Logto Account API.
  Frontend calls Logto directly (same domain) for the WebAuthn ceremony:
  generate options, browser credential creation, verify, and bind.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 17:01:58 +02:00
hsiegeln
a5c20830a7 fix: prevent MFA lockout and move enrollment to modal dialog
All checks were successful
CI / build (push) Successful in 1m58s
CI / docker (push) Successful in 1m47s
Three fixes for MFA enrollment and sign-in:

- Defer TOTP registration with Logto until after 6-digit code verification.
  Previously setupTotp() immediately registered the secret, so abandoning
  enrollment mid-way left MFA active without a working authenticator.
- Move entire MFA enrollment flow (QR code, verify, backup codes) into a
  Modal dialog instead of replacing the Card content inline.
- Fix sign-in MFA flow: submitMfa() no longer calls identifyUser() after
  TOTP verify — user is already identified, and passing the MFA
  verificationId to identification returned 422 ("method not activated").

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 16:25:15 +02:00
hsiegeln
9231a1fc60 fix: move forgot password link below sign-in button
All checks were successful
CI / build (push) Successful in 2m0s
CI / docker (push) Successful in 1m7s
Repositions the "Forgot password?" link from above the sign-in button
to below it, matching the desired layout. Updates link style to be
centered with link color instead of right-aligned muted text.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 16:06:36 +02:00
f325416833 Merge pull request 'feature/vendor-admin-account-settings' (#60) from feature/vendor-admin-account-settings into main
All checks were successful
CI / build (push) Successful in 3m22s
CI / docker (push) Successful in 27s
Reviewed-on: #60
2026-04-27 15:58:05 +02:00
0b4d0e3b2f Merge pull request 'feat: vendor admin management and shared account settings' (#59) from feature/vendor-admin-account-settings into main
All checks were successful
CI / build (push) Successful in 2m15s
CI / docker (push) Successful in 20s
Reviewed-on: #59
2026-04-27 15:20:23 +02:00
144 changed files with 1512 additions and 880 deletions

View File

@@ -48,8 +48,8 @@ VENDOR_SEED_ENABLED=false
# DOCKER_GID=0
# 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
# TRAEFIK_IMAGE=registry.cameleer.io/cameleer/cameleer-traefik
# POSTGRES_IMAGE=registry.cameleer.io/cameleer/cameleer-postgres
# CLICKHOUSE_IMAGE=registry.cameleer.io/cameleer/cameleer-clickhouse
# LOGTO_IMAGE=registry.cameleer.io/cameleer/cameleer-logto
# CAMELEER_IMAGE=registry.cameleer.io/cameleer/cameleer-saas

View File

@@ -111,17 +111,12 @@ jobs:
- name: Build and push runtime base image
run: |
AGENT_VERSION=$(curl -sf "https://gitea.siegeln.net/api/packages/cameleer/maven/com/cameleer/cameleer-agent/1.0-SNAPSHOT/maven-metadata.xml" \
AGENT_VERSION=$(curl -sf "https://gitea.siegeln.net/api/packages/cameleer/maven/io/cameleer/cameleer-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/cameleer/cameleer-agent/1.0-SNAPSHOT/cameleer-agent-${AGENT_VERSION}-shaded.jar"
APPENDER_VERSION=$(curl -sf "https://gitea.siegeln.net/api/packages/cameleer/maven/com/cameleer/cameleer-log-appender/1.0-SNAPSHOT/maven-metadata.xml" \
| sed -n 's/.*<value>\([^<]*\)<\/value>.*/\1/p' | tail -1)
echo "Log appender version: $APPENDER_VERSION"
curl -sf -o docker/runtime-base/cameleer-log-appender.jar \
"https://gitea.siegeln.net/api/packages/cameleer/maven/com/cameleer/cameleer-log-appender/1.0-SNAPSHOT/cameleer-log-appender-${APPENDER_VERSION}.jar"
ls -la docker/runtime-base/agent.jar docker/runtime-base/cameleer-log-appender.jar
"https://gitea.siegeln.net/api/packages/cameleer/maven/io/cameleer/cameleer-agent/1.0-SNAPSHOT/cameleer-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"
@@ -131,6 +126,17 @@ jobs:
--provenance=false \
--push docker/runtime-base/
- name: Build and push runtime-loader image
run: |
TAGS="-t gitea.siegeln.net/cameleer/cameleer-runtime-loader:${{ github.sha }}"
for TAG in $IMAGE_TAGS; do
TAGS="$TAGS -t gitea.siegeln.net/cameleer/cameleer-runtime-loader:$TAG"
done
docker buildx build --platform linux/amd64 \
$TAGS \
--provenance=false \
--push docker/runtime-loader/
- name: Build and push Logto image
run: |
TAGS="-t gitea.siegeln.net/cameleer/cameleer-logto:${{ github.sha }}"

View File

@@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **cameleer-saas** (3336 symbols, 7094 relationships, 281 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **cameleer-saas** (3458 symbols, 7429 relationships, 292 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.

View File

@@ -21,7 +21,7 @@ Agent-server protocol is defined in `cameleer/cameleer-common/PROTOCOL.md`. The
## Key Packages
### Java Backend (`src/main/java/net/siegeln/cameleer/saas/`)
### Java Backend (`src/main/java/io/cameleer/saas/`)
| Package | Purpose | Key classes |
|---------|---------|-------------|
@@ -70,6 +70,7 @@ PostgreSQL (Flyway): `src/main/resources/db/migration/`
- `cameleer-logto` — custom Logto with sign-in UI baked in
- `cameleer-server` / `cameleer-server-ui` — provisioned per-tenant (not in compose, created by `DockerTenantProvisioner`)
- `cameleer-runtime-base` — base image for deployed apps (agent JAR + `cameleer-log-appender.jar` + JRE). CI downloads latest agent and log appender SNAPSHOTs from Gitea Maven registry. The Dockerfile ENTRYPOINT is overridden by `DockerRuntimeOrchestrator` at container creation; agent config uses `CAMELEER_AGENT_*` env vars set by `DeploymentExecutor`.
- `cameleer-runtime-loader` (`docker/runtime-loader/`) — tiny init-container image (busybox + 26-line `entrypoint.sh`) consumed as a sidecar by `DockerRuntimeOrchestrator` in **cameleer-server**. Per-replica: fetches the tenant JAR from a signed URL into a named volume RW-mounted at `/app/jars`, then exits 0; the main runtime container mounts the same volume RO. Source moved here from cameleer-server in April 2026 to colocate with the other infra/sidecar images. **Contract is owned by cameleer-server** (env vars `ARTIFACT_URL` + `ARTIFACT_EXPECTED_SIZE`, output path `/app/jars/app.jar`, exit 0/non-zero semantics) — don't change those without a coordinated commit on the cameleer-server side. cameleer-server's `LoaderHardeningIT` is the cross-repo regression guard; it pulls `:latest` and asserts exit 0 under the orchestrator's hardening shape.
- Docker builds: `--no-cache`, `--provenance=false` for Gitea compatibility
- `docker-compose.yml` (root) — thin dev overlay (ports, volume mounts, `SPRING_PROFILES_ACTIVE: dev`). Chained on top of production templates from the installer submodule via `COMPOSE_FILE` in `.env`.
- Installer is a **git submodule** at `installer/` pointing to `cameleer/cameleer-saas-installer` (public repo). Compose templates live there — single source of truth, no duplication. Run `git submodule update --remote installer` to pull template updates.
@@ -82,7 +83,7 @@ PostgreSQL (Flyway): `src/main/resources/db/migration/`
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **vendor-admin-account** (3510 symbols, 7678 relationships, 298 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **cameleer-saas** (3624 symbols, 7877 relationships, 300 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.
@@ -98,7 +99,7 @@ This project is indexed by GitNexus as **vendor-admin-account** (3510 symbols, 7
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/vendor-admin-account/process/{processName}` — trace the full execution flow step by step
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
@@ -137,10 +138,10 @@ This project is indexed by GitNexus as **vendor-admin-account** (3510 symbols, 7
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/vendor-admin-account/context` | Codebase overview, check index freshness |
| `gitnexus://repo/vendor-admin-account/clusters` | All functional areas |
| `gitnexus://repo/vendor-admin-account/processes` | All execution flows |
| `gitnexus://repo/vendor-admin-account/process/{name}` | Step-by-step execution trace |
| `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

View File

@@ -20,12 +20,11 @@ COPY src/ src/
COPY --from=frontend /ui/dist/ src/main/resources/static/
RUN --mount=type=cache,target=/root/.m2/repository ./mvnw package -DskipTests -U -B
# Runtime: target platform (amd64)
FROM eclipse-temurin:21-jre-alpine
# Runtime: BellSoft Liberica JRE 21 on Alpaquita Linux (glibc, minimal, 199 MB)
FROM bellsoft/liberica-runtime-container:jre-21-slim-glibc
WORKDIR /app
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
RUN mkdir -p /data/jars && chown -R nobody:nobody /data /app
COPY --chown=nobody:nobody --from=build /build/target/*.jar app.jar
USER nobody
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@@ -444,4 +444,4 @@ VERSION=local docker compose -f docker-compose.yml -f docker-compose.dev.yml up
**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`
**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 registry.cameleer.io/cameleer/cameleer-runtime-base:latest`

View File

@@ -67,6 +67,7 @@ Key files:
- `DeploymentExecutor.java` (in cameleer-server) — async staged deployment, runtime type auto-detection
- `DockerRuntimeOrchestrator.java` (in cameleer-server) — Docker client, container lifecycle, builds runtime-type-specific entrypoints (spring-boot uses `-cp` + `PropertiesLauncher` with `-Dloader.path` for log appender; quarkus uses `-jar`; plain-java uses `-cp` + detected main class; native exec directly). Overrides the Dockerfile ENTRYPOINT.
- `docker/runtime-base/Dockerfile` — base image with agent JAR + `cameleer-log-appender.jar` + JRE. The Dockerfile ENTRYPOINT (`-jar /app/app.jar`) is a fallback — `DockerRuntimeOrchestrator` overrides it at container creation.
- `docker/runtime-loader/Dockerfile` + `entrypoint.sh` — tiny per-replica init-container image (busybox + 26-line shell). Consumed by cameleer-server's `DockerRuntimeOrchestrator` as a sidecar that fetches the tenant JAR from a signed URL into a named volume RW-mounted at `/app/jars`, then exits 0. The main runtime container mounts that volume RO. Image lives here so all infra/sidecar image builds are colocated, but the **runtime contract** (env vars `ARTIFACT_URL` + `ARTIFACT_EXPECTED_SIZE`, output path `/app/jars/app.jar`, exit 0/non-zero semantics) is owned by cameleer-server's orchestrator. Don't change those without a coordinated commit on the cameleer-server side; cameleer-server's `LoaderHardeningIT` is the cross-repo regression guard. Pre-creates `/app/jars` owned by `loader:loader` (UID 1000) so the orchestrator's fresh named volume initialises with that ownership — stripping that line breaks tenant deploys with "wget: Permission denied".
- `RuntimeDetector.java` (in cameleer-server) — detects runtime type from JAR manifest `Main-Class`; derives correct `PropertiesLauncher` package (Spring Boot 3.2+ vs pre-3.2)
- `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)

View File

@@ -46,8 +46,8 @@ AUTH="${AUTH_HOST:-$HOST}"
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\"]"
TRAD_REDIRECT_URIS="[\"${PROTO}://${HOST}/oidc/callback\"]"
TRAD_POST_LOGOUT_URIS="[\"${PROTO}://${HOST}\"]"
log() { echo "[bootstrap] $1"; }
pgpass() { PGPASSWORD="${PG_PASSWORD:-cameleer_dev}"; export PGPASSWORD; }
@@ -616,7 +616,7 @@ api_patch "/api/sign-in-exp" '{
]
},
"mfa": {
"factors": ["Totp", "BackupCode"],
"factors": ["Totp", "WebAuthn", "BackupCode"],
"policy": "UserControlled"
}
}' >/dev/null 2>&1

View File

@@ -1,19 +1,17 @@
FROM eclipse-temurin:21-jre-alpine
# BellSoft Liberica JRE 21 on Alpaquita Linux (glibc, minimal, 199 MB).
# Pin by digest in production overlays.
FROM bellsoft/liberica-runtime-container:jre-21-slim-glibc
WORKDIR /app
# Agent JAR and log appender JAR are copied during CI build from Gitea Maven registry
# Agent is baked in; log appender is embedded in cameleer-core.
# Tenant JAR is delivered at deploy time by cameleer-runtime-loader
# into the RO-mounted /app/jars volume.
COPY agent.jar /app/agent.jar
COPY cameleer-log-appender.jar /app/cameleer-log-appender.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
# No ENTRYPOINT here. cameleer-server's DeploymentExecutor builds the
# per-runtime-type entrypoint (spring-boot/quarkus: -jar; plain-java:
# -cp + main; native: exec) and overrides via withCmd("sh","-c",...).
# Setting one here only creates drift between this image and the actual
# runtime command.
USER nobody

View File

@@ -0,0 +1,17 @@
# Tiny init-container image. No app code, no shell-injection surface — script
# only sees env vars set by the orchestrator.
FROM busybox:1.37-musl
# Run as non-root (UID 1000 inside the container; with userns_mode this is
# remapped to host UID ~101000 — fully unprivileged on the host).
# Pre-create /app/jars owned by `loader` so the orchestrator's named-volume
# mount inherits that ownership at first init — without it the empty named
# volume comes up as root:root 0755 and wget can't write app.jar.
RUN adduser -D -u 1000 loader && mkdir -p /app/jars && chown -R loader:loader /app
COPY entrypoint.sh /usr/local/bin/loader
RUN chmod +x /usr/local/bin/loader
USER loader
WORKDIR /app
ENTRYPOINT ["/usr/local/bin/loader"]

View File

@@ -0,0 +1,29 @@
# cameleer-runtime-loader
Init container that fetches the deployable JAR into a shared volume before the
main runtime container starts. The image is consumed by
`DockerRuntimeOrchestrator` in the **cameleer-server** repo as a tenant
sidecar — see that repo's `.claude/rules/docker-orchestration.md`
("Init-Container Loader Pattern") for the contract.
## Build
CI (`.gitea/workflows/ci.yml`, `docker` job, "Build and push runtime-loader
image" step) builds and pushes this image on every main / feature-branch
push. Manual build for local testing:
docker build -t registry.cameleer.io/cameleer/cameleer-runtime-loader:<tag> .
docker push registry.cameleer.io/cameleer/cameleer-runtime-loader:<tag>
## Contract (consumed by cameleer-server)
- Env: `ARTIFACT_URL` (signed download URL), `ARTIFACT_EXPECTED_SIZE` (bytes).
- Volume: writes `/app/jars/app.jar`.
- Exit 0 on success; non-zero on fetch/size failure.
- Runs as UID 1000 (loader user), drops all caps, read-only rootfs except `/app/jars`.
Contract regression coverage lives on the cameleer-server side
(`LoaderHardeningIT`); pulls the published `:latest` and asserts exit 0
under the orchestrator's hardening shape. Don't change the env vars,
mount path, or exit-code semantics without updating the cameleer-server
side in the same change.

View File

@@ -0,0 +1,25 @@
#!/bin/sh
# cameleer-runtime-loader: fetches one JAR from a signed URL into the shared
# /app/jars/ volume, verifies size, exits. Runs in the same hardened sandbox as
# the main container (cap_drop ALL, read-only rootfs, etc.) — only /app/jars/
# is writeable.
set -eu
: "${ARTIFACT_URL:?ARTIFACT_URL is required}"
: "${ARTIFACT_EXPECTED_SIZE:?ARTIFACT_EXPECTED_SIZE is required}"
OUT=/app/jars/app.jar
mkdir -p /app/jars
echo "loader: fetching artifact (expected $ARTIFACT_EXPECTED_SIZE bytes)"
# -q quiet, -O output, --tries=3 retry transient network blips,
# --timeout=30 cap stalls. wget exits non-zero on HTTP >=400.
wget -q --tries=3 --timeout=30 -O "$OUT" "$ARTIFACT_URL"
actual=$(wc -c < "$OUT")
if [ "$actual" -ne "$ARTIFACT_EXPECTED_SIZE" ]; then
echo "loader: size mismatch — expected $ARTIFACT_EXPECTED_SIZE, got $actual" >&2
exit 2
fi
echo "loader: artifact written to $OUT ($actual bytes)"

View File

@@ -79,8 +79,8 @@ logging. Serves a React SPA that wraps the full user experience.
| 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 |
| cameleer-server | `gitea.siegeln.net/cameleer/cameleer-server`| 8081 | cameleer | Observability backend |
| cameleer-saas | `registry.cameleer.io/cameleer/cameleer-saas` | 8080 | cameleer | SaaS API + SPA serving |
| cameleer-server | `registry.cameleer.io/cameleer/cameleer-server`| 8081 | cameleer | Observability backend |
| clickhouse | `clickhouse/clickhouse-server:latest` | 8123 | cameleer | Time-series telemetry storage |
### Docker Network
@@ -876,8 +876,8 @@ state (`currentTenantId`). Provides `logout` and `signIn` callbacks.
| Variable | Default | Description |
|-----------------------------------|------------------------------------|----------------------------------|
| `CAMELEER_SAAS_PROVISIONING_SERVERIMAGE` | `gitea.siegeln.net/cameleer/cameleer-server:latest` | Docker image for per-tenant server |
| `CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE` | `gitea.siegeln.net/cameleer/cameleer-server-ui:latest` | Docker image for per-tenant UI |
| `CAMELEER_SAAS_PROVISIONING_SERVERIMAGE` | `registry.cameleer.io/cameleer/cameleer-server:latest` | Docker image for per-tenant server |
| `CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE` | `registry.cameleer.io/cameleer/cameleer-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`) |

View File

@@ -573,7 +573,7 @@ The Cameleer SaaS application itself does not need any changes -- all identity c
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`.
3. Pull the runtime base image manually: `docker pull registry.cameleer.io/cameleer/cameleer-runtime-base:latest`.
4. Check available disk space: `docker system df`.
### Agent Not Connecting to Server

View File

@@ -11,7 +11,7 @@
<relativePath/>
</parent>
<groupId>net.siegeln.cameleer</groupId>
<groupId>io.cameleer</groupId>
<artifactId>cameleer-saas</artifactId>
<version>0.1.0-SNAPSHOT</version>
<name>Cameleer SaaS Platform</name>
@@ -102,7 +102,7 @@
<!-- License Minter (Ed25519 signing) -->
<dependency>
<groupId>com.cameleer</groupId>
<groupId>io.cameleer</groupId>
<artifactId>cameleer-license-minter</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas;
package io.cameleer.saas;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

View File

@@ -1,6 +1,6 @@
package net.siegeln.cameleer.saas.account;
package io.cameleer.saas.account;
import net.siegeln.cameleer.saas.account.AccountService.*;
import io.cameleer.saas.account.AccountService.*;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
@@ -62,7 +62,7 @@ public class AccountController {
@PostMapping("/mfa/totp/verify")
public Map<String, Boolean> verifyTotp(@AuthenticationPrincipal Jwt jwt,
@RequestBody TotpVerifyRequest request) {
boolean ok = accountService.verifyTotpCode(request.secret(), request.code());
boolean ok = accountService.verifyAndEnableTotp(jwt.getSubject(), request.secret(), request.code());
return Map.of("verified", ok);
}

View File

@@ -1,7 +1,7 @@
package net.siegeln.cameleer.saas.account;
package io.cameleer.saas.account;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.notification.PasswordResetNotificationService;
import io.cameleer.saas.identity.LogtoManagementClient;
import io.cameleer.saas.notification.PasswordResetNotificationService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
@@ -46,10 +46,12 @@ public class AccountService {
if (user == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found");
}
Object nameVal = user.get("name");
Object emailVal = user.get("primaryEmail");
return new ProfileData(
userId,
String.valueOf(user.getOrDefault("name", "")),
String.valueOf(user.getOrDefault("primaryEmail", ""))
nameVal != null ? String.valueOf(nameVal) : "",
emailVal != null ? String.valueOf(emailVal) : ""
);
}
@@ -108,11 +110,32 @@ public class AccountService {
new SecureRandom().nextBytes(secretBytes);
String secret = base32Encode(secretBytes);
var result = logtoClient.createTotpVerification(userId, secret);
String qrCode = result.containsKey("secretQrCode")
? String.valueOf(result.get("secretQrCode"))
: String.valueOf(result.getOrDefault("qrCode", ""));
return new MfaSetupData(secret, qrCode);
// Build otpauth URI locally do NOT register with Logto yet.
// The secret is only registered after the user verifies the 6-digit code.
var user = logtoClient.getUser(userId);
String email = user != null ? String.valueOf(user.getOrDefault("primaryEmail", "")) : "";
String account = email.isBlank() ? userId : email;
// Include org name in issuer so authenticator apps show "Cameleer - OrgName"
String issuer = "Cameleer";
var orgs = logtoClient.getUserOrganizations(userId);
if (!orgs.isEmpty()) {
issuer = "Cameleer - " + orgs.getFirst().get("name");
}
String encodedIssuer = java.net.URLEncoder.encode(issuer, java.nio.charset.StandardCharsets.UTF_8);
String encodedAccount = java.net.URLEncoder.encode(account, java.nio.charset.StandardCharsets.UTF_8);
String otpauthUri = String.format(
"otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=6&period=30",
encodedIssuer, encodedAccount, secret, encodedIssuer);
return new MfaSetupData(secret, otpauthUri);
}
public boolean verifyAndEnableTotp(String userId, String secret, String code) {
if (!verifyTotpCode(secret, code)) return false;
logtoClient.createTotpVerification(userId, secret);
return true;
}
public boolean verifyTotpCode(String secret, String code) {

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.audit;
package io.cameleer.saas.audit;
public enum AuditAction {
AUTH_REGISTER, AUTH_LOGIN, AUTH_LOGIN_FAILED, AUTH_LOGOUT,

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.audit;
package io.cameleer.saas.audit;
import java.time.Instant;
import java.util.List;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.audit;
package io.cameleer.saas.audit;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.audit;
package io.cameleer.saas.audit;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.audit;
package io.cameleer.saas.audit;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.audit;
package io.cameleer.saas.audit;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;

View File

@@ -1,6 +1,6 @@
package net.siegeln.cameleer.saas.audit;
package io.cameleer.saas.audit;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import io.cameleer.saas.identity.LogtoManagementClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.certificate;
package io.cameleer.saas.certificate;
import java.util.List;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.certificate;
package io.cameleer.saas.certificate;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.certificate;
package io.cameleer.saas.certificate;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.certificate;
package io.cameleer.saas.certificate;
import java.time.Instant;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.certificate;
package io.cameleer.saas.certificate;
/**
* Provider interface for certificate file management.

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.certificate;
package io.cameleer.saas.certificate;
import org.springframework.data.jpa.repository.JpaRepository;

View File

@@ -1,6 +1,6 @@
package net.siegeln.cameleer.saas.certificate;
package io.cameleer.saas.certificate;
import net.siegeln.cameleer.saas.tenant.TenantRepository;
import io.cameleer.saas.tenant.TenantRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.certificate;
package io.cameleer.saas.certificate;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.certificate;
package io.cameleer.saas.certificate;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.certificate;
package io.cameleer.saas.certificate;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

View File

@@ -1,6 +1,6 @@
package net.siegeln.cameleer.saas.certificate;
package io.cameleer.saas.certificate;
import net.siegeln.cameleer.saas.provisioning.DockerCertificateManager;
import io.cameleer.saas.provisioning.DockerCertificateManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.config;
package io.cameleer.saas.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.config;
package io.cameleer.saas.config;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;

View File

@@ -0,0 +1,40 @@
package io.cameleer.saas.config;
import io.cameleer.saas.identity.LogtoManagementClient;
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 java.util.List;
import java.util.Map;
/**
* Ensures Logto sign-in experience always offers TOTP + WebAuthn + BackupCode
* on startup. Availability is always-on; enforcement is handled separately by
* MfaEnforcementFilter based on the vendor auth policy.
*/
@Component
public class LogtoStartupConfig {
private static final Logger log = LoggerFactory.getLogger(LogtoStartupConfig.class);
private final LogtoManagementClient logtoClient;
public LogtoStartupConfig(LogtoManagementClient logtoClient) {
this.logtoClient = logtoClient;
}
@EventListener(ApplicationReadyEvent.class)
public void onStartup() {
try {
List<String> factors = List.of("Totp", "WebAuthn", "BackupCode");
logtoClient.updateSignInExperience(Map.of(
"mfa", Map.of("factors", factors, "policy", "UserControlled")));
log.info("Logto MFA factors set to {} (UserControlled)", factors);
} catch (Exception e) {
log.warn("Failed to sync MFA factors on startup: {}", e.getMessage());
}
}
}

View File

@@ -1,7 +1,7 @@
package net.siegeln.cameleer.saas.config;
package io.cameleer.saas.config;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.tenant.TenantService;
import io.cameleer.saas.identity.LogtoManagementClient;
import io.cameleer.saas.tenant.TenantService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.jwt.Jwt;

View File

@@ -1,12 +1,12 @@
package net.siegeln.cameleer.saas.config;
package io.cameleer.saas.config;
import com.fasterxml.jackson.databind.ObjectMapper;
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 net.siegeln.cameleer.saas.vendor.VendorAuthPolicyRepository;
import io.cameleer.saas.tenant.TenantService;
import io.cameleer.saas.vendor.VendorAuthPolicyRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;

View File

@@ -1,8 +1,8 @@
package net.siegeln.cameleer.saas.config;
package io.cameleer.saas.config;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.siegeln.cameleer.saas.vendor.VendorAuthPolicyRepository;
import io.cameleer.saas.vendor.VendorAuthPolicyRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.config;
package io.cameleer.saas.config;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.config;
package io.cameleer.saas.config;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.config;
package io.cameleer.saas.config;
import java.util.UUID;

View File

@@ -1,8 +1,8 @@
package net.siegeln.cameleer.saas.config;
package io.cameleer.saas.config;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import net.siegeln.cameleer.saas.tenant.TenantService;
import io.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;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.config;
package io.cameleer.saas.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.identity;
package io.cameleer.saas.identity;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.identity;
package io.cameleer.saas.identity;
import com.fasterxml.jackson.databind.JsonNode;
import org.slf4j.Logger;
@@ -209,19 +209,67 @@ public class LogtoManagementClient {
}
}
/** Create a user in Logto and add to organization with role. */
/** Delete a user from Logto entirely. */
public void deleteUser(String userId) {
if (!isAvailable() || userId == null) return;
try {
restClient.delete()
.uri(config.getLogtoEndpoint() + "/api/users/" + userId)
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.toBodilessEntity();
log.info("Deleted user {} from Logto", userId);
} catch (Exception e) {
log.warn("Failed to delete user {}: {}", userId, e.getMessage());
}
}
/** Find a user by email. Returns user map or null if not found. */
@SuppressWarnings("unchecked")
public Map<String, Object> findUserByEmail(String email) {
if (!isAvailable() || email == null) return null;
try {
var resp = restClient.get()
.uri(config.getLogtoEndpoint() + "/api/users?search="
+ java.net.URLEncoder.encode(email, java.nio.charset.StandardCharsets.UTF_8)
+ "&search.primaryEmail="
+ java.net.URLEncoder.encode(email, java.nio.charset.StandardCharsets.UTF_8)
+ "&page_size=5")
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.body(List.class);
if (resp == null) return null;
return ((List<Map<String, Object>>) resp).stream()
.filter(u -> email.equalsIgnoreCase(String.valueOf(u.get("primaryEmail"))))
.findFirst()
.orElse(null);
} catch (Exception e) {
log.warn("Failed to find user by email: {}", e.getMessage());
return null;
}
}
/** Create a user in Logto (or find existing by email) 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"));
String userId;
// Check if user already exists in Logto
var existing = findUserByEmail(email);
if (existing != null) {
userId = String.valueOf(existing.get("id"));
log.info("User '{}' already exists in Logto ({}), adding to org", email, userId);
} else {
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);
userId = String.valueOf(userResp.get("id"));
}
if (orgId != null) {
addUserToOrganization(orgId, userId);
if (roleId != null) {

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.identity;
package io.cameleer.saas.identity;
import com.fasterxml.jackson.databind.JsonNode;
import org.slf4j.Logger;
@@ -125,51 +125,37 @@ public class ServerApiClient {
}
}
/** 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;
}
public record UsageCounts(int agents, int environments, int apps) {
public static final UsageCounts ZERO = new UsageCounts(0, 0, 0);
}
/** Fetch environment count from a tenant's server. */
public int getEnvironmentCount(String serverEndpoint) {
/** Fetch usage counts from a tenant's server via the license usage endpoint. */
@SuppressWarnings("unchecked")
public UsageCounts getUsageCounts(String serverEndpoint) {
try {
var resp = RestClient.create().get()
.uri(serverEndpoint + "/api/v1/admin/environments")
.uri(serverEndpoint + "/api/v1/admin/license/usage")
.header("Authorization", "Bearer " + getAccessToken())
.header("X-Cameleer-Protocol-Version", "1")
.retrieve()
.body(java.util.List.class);
return resp != null ? resp.size() : 0;
.body(Map.class);
if (resp == null) return UsageCounts.ZERO;
var limits = (List<Map<String, Object>>) resp.get("limits");
if (limits == null) return UsageCounts.ZERO;
int agents = 0, environments = 0, apps = 0;
for (var row : limits) {
String key = (String) row.get("key");
int current = ((Number) row.get("current")).intValue();
switch (key) {
case "max_agents" -> agents = current;
case "max_environments" -> environments = current;
case "max_apps" -> apps = current;
}
}
return new UsageCounts(agents, environments, apps);
} catch (Exception e) {
log.warn("Environment count fetch failed for {}: {}", serverEndpoint, e.getMessage());
return 0;
}
}
/** Fetch app count from a tenant's server. */
public int getAppCount(String serverEndpoint) {
try {
var resp = RestClient.create().get()
.uri(serverEndpoint + "/api/v1/admin/apps")
.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("App count fetch failed for {}: {}", serverEndpoint, e.getMessage());
return 0;
log.warn("Usage counts fetch failed for {}: {}", serverEndpoint, e.getMessage());
return UsageCounts.ZERO;
}
}

View File

@@ -1,7 +1,7 @@
package net.siegeln.cameleer.saas.license;
package io.cameleer.saas.license;
import net.siegeln.cameleer.saas.license.dto.LicenseResponse;
import net.siegeln.cameleer.saas.tenant.TenantService;
import io.cameleer.saas.license.dto.LicenseResponse;
import io.cameleer.saas.tenant.TenantService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;

View File

@@ -1,6 +1,6 @@
package net.siegeln.cameleer.saas.license;
package io.cameleer.saas.license;
import net.siegeln.cameleer.saas.tenant.Tier;
import io.cameleer.saas.tenant.Tier;
import java.util.Map;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.license;
package io.cameleer.saas.license;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.license;
package io.cameleer.saas.license;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

View File

@@ -1,11 +1,11 @@
package net.siegeln.cameleer.saas.license;
package io.cameleer.saas.license;
import com.cameleer.license.minter.LicenseMinter;
import com.cameleer.license.LicenseInfo;
import com.cameleer.license.LicenseValidator;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import io.cameleer.license.minter.LicenseMinter;
import io.cameleer.license.LicenseInfo;
import io.cameleer.license.LicenseValidator;
import io.cameleer.saas.audit.AuditAction;
import io.cameleer.saas.audit.AuditService;
import io.cameleer.saas.tenant.TenantEntity;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.license;
package io.cameleer.saas.license;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.license;
package io.cameleer.saas.license;
import org.springframework.data.jpa.repository.JpaRepository;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.license;
package io.cameleer.saas.license;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

View File

@@ -1,6 +1,6 @@
package net.siegeln.cameleer.saas.license.dto;
package io.cameleer.saas.license.dto;
import net.siegeln.cameleer.saas.license.LicenseEntity;
import io.cameleer.saas.license.LicenseEntity;
import java.time.Instant;
import java.util.Map;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.license.dto;
package io.cameleer.saas.license.dto;
import java.util.Map;

View File

@@ -1,6 +1,6 @@
package net.siegeln.cameleer.saas.license.dto;
package io.cameleer.saas.license.dto;
import net.siegeln.cameleer.saas.license.LicenseEntity;
import io.cameleer.saas.license.LicenseEntity;
import java.time.Instant;
import java.util.Map;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.license.dto;
package io.cameleer.saas.license.dto;
import java.time.Instant;
import java.util.Map;

View File

@@ -1,3 +1,3 @@
package net.siegeln.cameleer.saas.license.dto;
package io.cameleer.saas.license.dto;
public record VerifyLicenseRequest(String token) {}

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.license.dto;
package io.cameleer.saas.license.dto;
import java.time.Instant;
import java.util.Map;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.notification;
package io.cameleer.saas.notification;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

View File

@@ -1,8 +1,8 @@
package net.siegeln.cameleer.saas.notification;
package io.cameleer.saas.notification;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.provisioning.ProvisioningProperties;
import net.siegeln.cameleer.saas.vendor.EmailConnectorService;
import io.cameleer.saas.identity.LogtoManagementClient;
import io.cameleer.saas.provisioning.ProvisioningProperties;
import io.cameleer.saas.vendor.EmailConnectorService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.ClassPathResource;

View File

@@ -0,0 +1,109 @@
package io.cameleer.saas.notification;
import io.cameleer.saas.identity.LogtoManagementClient;
import io.cameleer.saas.provisioning.ProvisioningProperties;
import io.cameleer.saas.vendor.EmailConnectorService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.ClassPathResource;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Properties;
@Service
public class TenantWelcomeNotificationService {
private static final Logger log = LoggerFactory.getLogger(TenantWelcomeNotificationService.class);
private final EmailConnectorService emailConnectorService;
private final LogtoManagementClient logtoClient;
private final ProvisioningProperties provisioningProps;
public TenantWelcomeNotificationService(EmailConnectorService emailConnectorService,
LogtoManagementClient logtoClient,
ProvisioningProperties provisioningProps) {
this.emailConnectorService = emailConnectorService;
this.logtoClient = logtoClient;
this.provisioningProps = provisioningProps;
}
/**
* Sends a welcome email to the tenant admin after provisioning completes.
* Fire-and-forget: logs a warning on failure but does not throw.
*/
public void sendWelcomeEmail(String toEmail, String username, String tenantName, String slug) {
try {
doSend(toEmail, username, tenantName, slug);
} catch (Exception e) {
log.warn("Failed to send welcome email to {}: {}", toEmail, e.getMessage());
}
}
@SuppressWarnings("unchecked")
private void doSend(String toEmail, String username, String tenantName, String slug) throws Exception {
var connectorStatus = emailConnectorService.getEmailConnector();
if (connectorStatus == null) {
log.debug("No email connector configured — skipping welcome email for {}", toEmail);
return;
}
var connectors = logtoClient.listConnectors();
var raw = connectors.stream()
.filter(c -> "Email".equals(c.get("type")))
.findFirst()
.orElse(null);
if (raw == null) return;
var config = (Map<String, Object>) raw.getOrDefault("config", Map.of());
var auth = (Map<String, Object>) config.getOrDefault("auth", Map.of());
String host = connectorStatus.host();
int port = connectorStatus.port();
String smtpUsername = connectorStatus.username();
String fromEmail = connectorStatus.fromEmail();
String password = String.valueOf(auth.getOrDefault("pass", ""));
String dashboardUrl = provisioningProps.publicProtocol() + "://"
+ provisioningProps.publicHost() + "/t/" + slug;
String watermarkUrl = provisioningProps.publicProtocol() + "://"
+ provisioningProps.publicHost() + "/platform/assets/email-watermark.png";
String htmlBody = new ClassPathResource("email-templates/welcome-tenant.html")
.getContentAsString(StandardCharsets.UTF_8)
.replace("{{watermarkUrl}}", watermarkUrl)
.replace("{{tenantName}}", tenantName)
.replace("{{username}}", username)
.replace("{{dashboardUrl}}", dashboardUrl);
var sender = new JavaMailSenderImpl();
sender.setHost(host);
sender.setPort(port);
sender.setUsername(smtpUsername);
sender.setPassword(password);
sender.setDefaultEncoding("UTF-8");
Properties props = sender.getJavaMailProperties();
props.put("mail.transport.protocol", "smtp");
props.put("mail.smtp.auth", "true");
if (port == 465) {
props.put("mail.smtp.ssl.enable", "true");
} else {
props.put("mail.smtp.starttls.enable", "true");
}
props.put("mail.smtp.timeout", "10000");
props.put("mail.smtp.connectiontimeout", "10000");
var mimeMessage = sender.createMimeMessage();
var helper = new MimeMessageHelper(mimeMessage, false, "UTF-8");
helper.setTo(toEmail);
helper.setFrom(fromEmail);
helper.setSubject("Your Cameleer tenant is ready");
helper.setText(htmlBody, true);
sender.send(mimeMessage);
log.info("Welcome email sent to {} for tenant {}", toEmail, slug);
}
}

View File

@@ -1,11 +1,11 @@
package net.siegeln.cameleer.saas.onboarding;
package io.cameleer.saas.onboarding;
import jakarta.validation.Valid;
import java.util.Map;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import net.siegeln.cameleer.saas.tenant.dto.TenantResponse;
import net.siegeln.cameleer.saas.tenant.TenantRepository;
import net.siegeln.cameleer.saas.tenant.TenantStatus;
import io.cameleer.saas.tenant.TenantEntity;
import io.cameleer.saas.tenant.dto.TenantResponse;
import io.cameleer.saas.tenant.TenantRepository;
import io.cameleer.saas.tenant.TenantStatus;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;

View File

@@ -1,10 +1,10 @@
package net.siegeln.cameleer.saas.onboarding;
package io.cameleer.saas.onboarding;
import net.siegeln.cameleer.saas.account.AccountService;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
import net.siegeln.cameleer.saas.vendor.VendorTenantService;
import io.cameleer.saas.account.AccountService;
import io.cameleer.saas.identity.LogtoManagementClient;
import io.cameleer.saas.tenant.TenantEntity;
import io.cameleer.saas.tenant.dto.CreateTenantRequest;
import io.cameleer.saas.vendor.VendorTenantService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@@ -44,7 +44,7 @@ public class OnboardingService {
// Create tenant via the existing vendor flow (no admin user we'll add the caller)
UUID actorId = resolveActorId(logtoUserId);
var request = new CreateTenantRequest(name, slug, "STARTER", null, null);
var request = new CreateTenantRequest(name, slug, null, null, null);
TenantEntity tenant = vendorTenantService.createAndProvision(request, actorId);
// Add the calling user to the Logto org as owner

View File

@@ -1,8 +1,8 @@
package net.siegeln.cameleer.saas.portal;
package io.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 io.cameleer.saas.audit.AuditDto.AuditLogPage;
import io.cameleer.saas.audit.AuditService;
import io.cameleer.saas.config.TenantContext;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;

View File

@@ -1,9 +1,9 @@
package net.siegeln.cameleer.saas.portal;
package io.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 net.siegeln.cameleer.saas.tenant.TenantService;
import io.cameleer.saas.certificate.TenantCaCertEntity;
import io.cameleer.saas.certificate.TenantCaCertService;
import io.cameleer.saas.config.TenantContext;
import io.cameleer.saas.tenant.TenantService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;

View File

@@ -1,16 +1,16 @@
package net.siegeln.cameleer.saas.portal;
package io.cameleer.saas.portal;
import net.siegeln.cameleer.saas.account.AccountService;
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 io.cameleer.saas.account.AccountService;
import io.cameleer.saas.config.TenantContext;
import io.cameleer.saas.identity.LogtoManagementClient;
import io.cameleer.saas.identity.ServerApiClient;
import io.cameleer.saas.license.LicenseEntity;
import io.cameleer.saas.license.LicenseService;
import io.cameleer.saas.provisioning.ProvisioningProperties;
import io.cameleer.saas.provisioning.TenantProvisioner;
import io.cameleer.saas.tenant.TenantEntity;
import io.cameleer.saas.tenant.TenantService;
import io.cameleer.saas.vendor.VendorTenantService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Lazy;
@@ -113,8 +113,9 @@ public class TenantPortalService {
serverHealthy = health.healthy();
serverStatus = health.status();
if (serverHealthy) {
agentCount = serverApiClient.getAgentCount(endpoint);
environmentCount = serverApiClient.getEnvironmentCount(endpoint);
var counts = serverApiClient.getUsageCounts(endpoint);
agentCount = counts.agents();
environmentCount = counts.environments();
}
}
@@ -160,9 +161,10 @@ public class TenantPortalService {
Map<String, Integer> usage = new HashMap<>();
String endpoint = tenant.getServerEndpoint();
if (endpoint != null && !endpoint.isBlank()) {
usage.put("agents", serverApiClient.getAgentCount(endpoint));
usage.put("environments", serverApiClient.getEnvironmentCount(endpoint));
usage.put("apps", serverApiClient.getAppCount(endpoint));
var counts = serverApiClient.getUsageCounts(endpoint);
usage.put("agents", counts.agents());
usage.put("environments", counts.environments());
usage.put("apps", counts.apps());
}
// User count from Logto org membership
String orgId = tenant.getLogtoOrgId();
@@ -181,13 +183,14 @@ public class TenantPortalService {
return logtoClient.listOrganizationMembers(orgId);
}
public String inviteTeamMember(String email, String roleId) {
public String inviteTeamMember(String email, String roleName) {
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);
String resolvedRoleId = resolveOrgRoleId(roleName);
return logtoClient.createAndInviteUser(email, orgId, resolvedRoleId);
}
public void removeTeamMember(String userId) {
@@ -197,15 +200,33 @@ public class TenantPortalService {
throw new IllegalStateException("Tenant has no Logto organization configured");
}
logtoClient.removeUserFromOrganization(orgId, userId);
// If the user has no remaining org memberships, delete from Logto entirely
var remainingOrgs = logtoClient.getUserOrganizations(userId);
if (remainingOrgs.isEmpty()) {
log.info("User {} has no remaining org memberships — deleting from Logto", userId);
logtoClient.deleteUser(userId);
}
}
public void changeTeamMemberRole(String userId, String roleId) {
public void changeTeamMemberRole(String userId, String roleName) {
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);
String resolvedRoleId = resolveOrgRoleId(roleName);
logtoClient.assignOrganizationRole(orgId, userId, resolvedRoleId);
}
/** Resolve a role name (e.g. "viewer") to a Logto organization role ID. */
private String resolveOrgRoleId(String roleName) {
if (roleName == null || roleName.isBlank()) return null;
String resolved = logtoClient.findOrgRoleIdByName(roleName);
if (resolved == null) {
throw new IllegalArgumentException("Unknown organization role: " + roleName);
}
return resolved;
}
public void resetServerAdminPassword(String newPassword) {

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.portal;
package io.cameleer.saas.portal;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

View File

@@ -1,9 +1,9 @@
package net.siegeln.cameleer.saas.portal;
package io.cameleer.saas.portal;
import net.siegeln.cameleer.saas.config.TenantContext;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import net.siegeln.cameleer.saas.tenant.TenantService;
import io.cameleer.saas.config.TenantContext;
import io.cameleer.saas.identity.LogtoManagementClient;
import io.cameleer.saas.tenant.TenantEntity;
import io.cameleer.saas.tenant.TenantService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

View File

@@ -1,6 +1,6 @@
package net.siegeln.cameleer.saas.provisioning;
package io.cameleer.saas.provisioning;
import net.siegeln.cameleer.saas.certificate.CertificateManager;
import io.cameleer.saas.certificate.CertificateManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;

View File

@@ -1,8 +1,8 @@
package net.siegeln.cameleer.saas.provisioning;
package io.cameleer.saas.provisioning;
import net.siegeln.cameleer.saas.certificate.CertificateInfo;
import net.siegeln.cameleer.saas.certificate.CertificateManager;
import net.siegeln.cameleer.saas.certificate.CertValidationResult;
import io.cameleer.saas.certificate.CertificateInfo;
import io.cameleer.saas.certificate.CertificateManager;
import io.cameleer.saas.certificate.CertValidationResult;
import java.util.List;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.provisioning;
package io.cameleer.saas.provisioning;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

View File

@@ -1,8 +1,8 @@
package net.siegeln.cameleer.saas.provisioning;
package io.cameleer.saas.provisioning;
import net.siegeln.cameleer.saas.certificate.CertificateInfo;
import net.siegeln.cameleer.saas.certificate.CertificateManager;
import net.siegeln.cameleer.saas.certificate.CertValidationResult;
import io.cameleer.saas.certificate.CertificateInfo;
import io.cameleer.saas.certificate.CertificateManager;
import io.cameleer.saas.certificate.CertValidationResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.provisioning;
package io.cameleer.saas.provisioning;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.command.CreateContainerResponse;
@@ -21,10 +21,10 @@ public class DockerTenantProvisioner implements TenantProvisioner {
private final DockerClient docker;
private final ProvisioningProperties props;
private final net.siegeln.cameleer.saas.license.SigningKeyService signingKeyService;
private final io.cameleer.saas.license.SigningKeyService signingKeyService;
public DockerTenantProvisioner(DockerClientConfig config, ProvisioningProperties props,
net.siegeln.cameleer.saas.license.SigningKeyService signingKeyService) {
io.cameleer.saas.license.SigningKeyService signingKeyService) {
this.props = props;
this.signingKeyService = signingKeyService;
DockerHttpClient httpClient = new ZerodepDockerHttpClient.Builder()

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.provisioning;
package io.cameleer.saas.provisioning;
public record ProvisionResult(
boolean success,

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.provisioning;
package io.cameleer.saas.provisioning;
import org.springframework.boot.context.properties.ConfigurationProperties;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.provisioning;
package io.cameleer.saas.provisioning;
public record ServerStatus(
State state,

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.provisioning;
package io.cameleer.saas.provisioning;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -44,11 +44,13 @@ public class TenantDataCleanupService {
try (Connection conn = DriverManager.getConnection(url, props.clickhouseUser(), props.clickhousePassword());
Statement stmt = conn.createStatement()) {
// Find all tables with a tenant_id column
// Find all base tables with a tenant_id column (skip materialized views)
List<String> tables = new ArrayList<>();
try (ResultSet rs = stmt.executeQuery(
"SELECT DISTINCT table FROM system.columns " +
"WHERE database = currentDatabase() AND name = 'tenant_id'")) {
"SELECT DISTINCT c.table FROM system.columns c " +
"INNER JOIN system.tables t ON c.table = t.name AND c.database = t.database " +
"WHERE c.database = currentDatabase() AND c.name = 'tenant_id' " +
"AND t.engine NOT LIKE '%MaterializedView%'")) {
while (rs.next()) {
tables.add(rs.getString(1));
}

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.provisioning;
package io.cameleer.saas.provisioning;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.provisioning;
package io.cameleer.saas.provisioning;
import java.util.UUID;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.provisioning;
package io.cameleer.saas.provisioning;
public interface TenantProvisioner {
boolean isAvailable();

View File

@@ -1,8 +1,8 @@
package net.siegeln.cameleer.saas.provisioning;
package io.cameleer.saas.provisioning;
import com.github.dockerjava.core.DefaultDockerClientConfig;
import com.github.dockerjava.core.DockerClientConfig;
import net.siegeln.cameleer.saas.license.SigningKeyService;
import io.cameleer.saas.license.SigningKeyService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.EnableConfigurationProperties;

View File

@@ -1,8 +1,8 @@
package net.siegeln.cameleer.saas.tenant;
package io.cameleer.saas.tenant;
import jakarta.validation.Valid;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
import net.siegeln.cameleer.saas.tenant.dto.TenantResponse;
import io.cameleer.saas.tenant.dto.CreateTenantRequest;
import io.cameleer.saas.tenant.dto.TenantResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.tenant;
package io.cameleer.saas.tenant;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
@@ -61,6 +61,9 @@ public class TenantEntity {
@Column(name = "db_password")
private String dbPassword;
@Column(name = "admin_email")
private String adminEmail;
@Column(name = "ca_applied_at")
private Instant caAppliedAt;
@@ -105,6 +108,8 @@ public class TenantEntity {
public void setProvisionError(String provisionError) { this.provisionError = provisionError; }
public String getDbPassword() { return dbPassword; }
public void setDbPassword(String dbPassword) { this.dbPassword = dbPassword; }
public String getAdminEmail() { return adminEmail; }
public void setAdminEmail(String adminEmail) { this.adminEmail = adminEmail; }
public Instant getCaAppliedAt() { return caAppliedAt; }
public void setCaAppliedAt(Instant caAppliedAt) { this.caAppliedAt = caAppliedAt; }
public Instant getCreatedAt() { return createdAt; }

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.tenant;
package io.cameleer.saas.tenant;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

View File

@@ -1,9 +1,9 @@
package net.siegeln.cameleer.saas.tenant;
package io.cameleer.saas.tenant;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
import io.cameleer.saas.audit.AuditAction;
import io.cameleer.saas.audit.AuditService;
import io.cameleer.saas.identity.LogtoManagementClient;
import io.cameleer.saas.tenant.dto.CreateTenantRequest;
import org.springframework.stereotype.Service;
import java.util.List;
@@ -31,7 +31,7 @@ public class TenantService {
var entity = new TenantEntity();
entity.setName(request.name());
entity.setSlug(request.slug());
entity.setTier(request.tier() != null ? Tier.valueOf(request.tier()) : Tier.STARTER);
entity.setTier(Tier.STARTER);
entity.setStatus(TenantStatus.PROVISIONING);
var saved = tenantRepository.save(entity);

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.tenant;
package io.cameleer.saas.tenant;
public enum TenantStatus {
PROVISIONING, ACTIVE, SUSPENDED, DELETED

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.tenant;
package io.cameleer.saas.tenant;
public enum Tier {
STARTER, TEAM, BUSINESS, ENTERPRISE

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.tenant.dto;
package io.cameleer.saas.tenant.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
@@ -7,7 +7,7 @@ import jakarta.validation.constraints.Size;
public record CreateTenantRequest(
@NotBlank @Size(max = 255) String name,
@NotBlank @Size(max = 100) @Pattern(regexp = "^[a-z0-9][a-z0-9-]*[a-z0-9]$", message = "Slug must be lowercase alphanumeric with hyphens") String slug,
String tier,
String adminEmail,
String adminUsername,
String adminPassword
) {}

View File

@@ -1,6 +1,6 @@
package net.siegeln.cameleer.saas.tenant.dto;
package io.cameleer.saas.tenant.dto;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import io.cameleer.saas.tenant.TenantEntity;
import java.time.Instant;
import java.util.UUID;
@@ -11,6 +11,7 @@ public record TenantResponse(
String slug,
String tier,
String status,
String adminEmail,
String serverEndpoint,
String provisionError,
Instant createdAt,
@@ -20,6 +21,7 @@ public record TenantResponse(
return new TenantResponse(
e.getId(), e.getName(), e.getSlug(),
e.getTier().name(), e.getStatus().name(),
e.getAdminEmail(),
e.getServerEndpoint(), e.getProvisionError(),
e.getCreatedAt(), e.getUpdatedAt()
);

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.vendor;
package io.cameleer.saas.vendor;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;

View File

@@ -1,7 +1,7 @@
package net.siegeln.cameleer.saas.vendor;
package io.cameleer.saas.vendor;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.provisioning.ProvisioningProperties;
import io.cameleer.saas.identity.LogtoManagementClient;
import io.cameleer.saas.provisioning.ProvisioningProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.ClassPathResource;

View File

@@ -1,4 +1,4 @@
package net.siegeln.cameleer.saas.vendor;
package io.cameleer.saas.vendor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;

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