Compare commits
38 Commits
feature/ve
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df25dcf81a | ||
|
|
029f2ef0de | ||
|
|
345bc4a92b | ||
|
|
bd301ad1fe | ||
|
|
15c47fe36c | ||
|
|
61fc7f224f | ||
|
|
11646b93ff | ||
|
|
fcb25778e1 | ||
|
|
3aba32302a | ||
|
|
2fa8ba07de | ||
|
|
966691f2c8 | ||
|
|
6ac06d6859 | ||
|
|
ac8d628271 | ||
|
|
bc32d7e994 | ||
|
|
c43d7f639f | ||
|
|
5f210b76a9 | ||
|
|
06134d6e67 | ||
|
|
7fe9c581b0 | ||
|
|
7fc8a4d407 | ||
|
|
e21a9d6046 | ||
|
|
0481cefaf4 | ||
|
|
040ae60be5 | ||
|
|
d8f7452ab7 | ||
|
|
c4fe16048c | ||
|
|
cba420fbeb | ||
|
|
67ec409383 | ||
|
|
3384510f3c | ||
|
|
18e6f32f90 | ||
|
|
4df6fc9e03 | ||
|
|
2aa5100530 | ||
|
|
c360d9ad5f | ||
|
|
e7952dd9de | ||
|
|
687598952f | ||
|
|
c22580e124 | ||
|
|
a5c20830a7 | ||
|
|
9231a1fc60 | ||
| f325416833 | |||
| 0b4d0e3b2f |
10
.env.example
10
.env.example
@@ -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
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
15
CLAUDE.md
15
CLAUDE.md
@@ -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
|
||||
|
||||
|
||||
11
Dockerfile
11
Dockerfile
@@ -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"]
|
||||
|
||||
2
HOWTO.md
2
HOWTO.md
@@ -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`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
17
docker/runtime-loader/Dockerfile
Normal file
17
docker/runtime-loader/Dockerfile
Normal 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"]
|
||||
29
docker/runtime-loader/README.md
Normal file
29
docker/runtime-loader/README.md
Normal 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.
|
||||
25
docker/runtime-loader/entrypoint.sh
Normal file
25
docker/runtime-loader/entrypoint.sh
Normal 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)"
|
||||
@@ -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`) |
|
||||
|
||||
@@ -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
|
||||
|
||||
4
pom.xml
4
pom.xml
@@ -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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.siegeln.cameleer.saas;
|
||||
package io.cameleer.saas;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -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,
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.siegeln.cameleer.saas.audit;
|
||||
package io.cameleer.saas.audit;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.siegeln.cameleer.saas.audit;
|
||||
package io.cameleer.saas.audit;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.siegeln.cameleer.saas.certificate;
|
||||
package io.cameleer.saas.certificate;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -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;
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.siegeln.cameleer.saas.certificate;
|
||||
package io.cameleer.saas.certificate;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.siegeln.cameleer.saas.certificate;
|
||||
package io.cameleer.saas.certificate;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.siegeln.cameleer.saas.certificate;
|
||||
package io.cameleer.saas.certificate;
|
||||
|
||||
/**
|
||||
* Provider interface for certificate file management.
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.siegeln.cameleer.saas.certificate;
|
||||
package io.cameleer.saas.certificate;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.siegeln.cameleer.saas.certificate;
|
||||
package io.cameleer.saas.certificate;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.siegeln.cameleer.saas.config;
|
||||
package io.cameleer.saas.config;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.siegeln.cameleer.saas.license;
|
||||
package io.cameleer.saas.license;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.siegeln.cameleer.saas.license;
|
||||
package io.cameleer.saas.license;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.siegeln.cameleer.saas.license;
|
||||
package io.cameleer.saas.license;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.siegeln.cameleer.saas.license;
|
||||
package io.cameleer.saas.license;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -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;
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.siegeln.cameleer.saas.license.dto;
|
||||
package io.cameleer.saas.license.dto;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,3 +1,3 @@
|
||||
package net.siegeln.cameleer.saas.license.dto;
|
||||
package io.cameleer.saas.license.dto;
|
||||
|
||||
public record VerifyLicenseRequest(String token) {}
|
||||
@@ -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;
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.siegeln.cameleer.saas.notification;
|
||||
package io.cameleer.saas.notification;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.siegeln.cameleer.saas.provisioning;
|
||||
package io.cameleer.saas.provisioning;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.siegeln.cameleer.saas.provisioning;
|
||||
package io.cameleer.saas.provisioning;
|
||||
|
||||
public record ProvisionResult(
|
||||
boolean success,
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.siegeln.cameleer.saas.provisioning;
|
||||
package io.cameleer.saas.provisioning;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.siegeln.cameleer.saas.provisioning;
|
||||
package io.cameleer.saas.provisioning;
|
||||
|
||||
public record ServerStatus(
|
||||
State state,
|
||||
@@ -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));
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.siegeln.cameleer.saas.provisioning;
|
||||
package io.cameleer.saas.provisioning;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.siegeln.cameleer.saas.provisioning;
|
||||
package io.cameleer.saas.provisioning;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.siegeln.cameleer.saas.provisioning;
|
||||
package io.cameleer.saas.provisioning;
|
||||
|
||||
public interface TenantProvisioner {
|
||||
boolean isAvailable();
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.siegeln.cameleer.saas.tenant;
|
||||
package io.cameleer.saas.tenant;
|
||||
|
||||
public enum TenantStatus {
|
||||
PROVISIONING, ACTIVE, SUSPENDED, DELETED
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.siegeln.cameleer.saas.tenant;
|
||||
package io.cameleer.saas.tenant;
|
||||
|
||||
public enum Tier {
|
||||
STARTER, TEAM, BUSINESS, ENTERPRISE
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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()
|
||||
);
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.siegeln.cameleer.saas.vendor;
|
||||
package io.cameleer.saas.vendor;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Email;
|
||||
@@ -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;
|
||||
@@ -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
Reference in New Issue
Block a user