Compare commits
162 Commits
37668dcfe0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eaf55839c3 | ||
|
|
53484ec935 | ||
|
|
ddbee0fbd4 | ||
|
|
ed079a0c70 | ||
|
|
433fc428c1 | ||
|
|
5c9db5addf | ||
|
|
0ef0563810 | ||
|
|
417e6024b0 | ||
|
|
385d79aa0f | ||
|
|
5e19e07257 | ||
|
|
809f1e8a09 | ||
|
|
cb411ff337 | ||
|
|
da52707aec | ||
|
|
88733d76c0 | ||
|
|
295a185a03 | ||
|
|
529028f0c3 | ||
|
|
df03c1a4cd | ||
|
|
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 | |||
|
|
ab800bbef9 | ||
|
|
15d6c7abc1 | ||
| 0b4d0e3b2f | |||
|
|
f823a409d0 | ||
|
|
e9e18f6c38 | ||
|
|
372d3c77a0 | ||
|
|
e5e0cad7c3 | ||
|
|
8668642b8d | ||
|
|
d44ee4b977 | ||
|
|
5d1d263c74 | ||
|
|
e563631efb | ||
|
|
bf42f13afc | ||
|
|
0da1ffea7f | ||
|
|
022b6d9722 | ||
|
|
665ffefd3e | ||
|
|
cc3d2dc111 | ||
|
|
ab240e42b0 | ||
|
|
b63e5e9c81 | ||
|
|
90d84ffd00 | ||
|
|
19428b4e27 | ||
|
|
316e5ef6c1 | ||
|
|
86d9ba4985 | ||
|
|
292adeea4c | ||
|
|
43a1058f33 | ||
|
|
60a800f757 | ||
|
|
76a62135ab | ||
|
|
17ba02c30d | ||
|
|
9b898924ab | ||
|
|
8de16019b7 | ||
|
|
ad2b16f26d | ||
|
|
2007a4b2da | ||
|
|
9057479da7 | ||
|
|
89c83ec7b8 | ||
|
|
b3104dc410 | ||
|
|
5bf94c6d4e | ||
|
|
40daca36a0 | ||
|
|
8c9edfdb55 | ||
|
|
25f4afcddc | ||
|
|
02be1d9264 | ||
|
|
cc7c87a520 | ||
|
|
ca19faf4f0 | ||
|
|
b86cc812b7 | ||
|
|
f0dda0d2ee | ||
|
|
3cd6bd5585 | ||
|
|
25d66af45e | ||
|
|
d783040030 | ||
|
|
6afc337b16 | ||
|
|
e881e302b6 | ||
|
|
d7ef2c488b | ||
|
|
088bc34e67 | ||
|
|
73e41e5607 | ||
|
|
f5b68c212b | ||
|
|
7c82ba93b0 | ||
|
|
1066101e8a | ||
|
|
ffb7ef0839 | ||
|
|
4dea1c6764 | ||
|
|
6c3f21d4db | ||
|
|
7a8960ca46 | ||
|
|
fdc7187424 | ||
|
|
2fd14165bc | ||
|
|
13bd03997a | ||
|
|
e64bf4f0d1 | ||
|
|
883e10ba7c | ||
|
|
0413a5b882 | ||
|
|
c6b6bafc0f | ||
|
|
988035b952 | ||
|
|
c55427c22b | ||
|
|
f681784e7e | ||
|
|
7b57ee8246 | ||
|
|
6e6e4218c9 | ||
|
|
469b36613b | ||
|
|
bcb8a040f4 | ||
|
|
d52084a081 | ||
|
|
7e7407b137 | ||
|
|
0a77080bca | ||
|
|
a5b30cd1ea | ||
|
|
ffb65edcec | ||
|
|
8b8909e488 | ||
|
|
94de4c2a5b | ||
|
|
66477ff575 | ||
|
|
6c70efcb54 | ||
|
|
1f3a9551c5 | ||
|
|
08a3ad03b7 | ||
|
|
cfcf852e2d | ||
|
|
67f7d634c9 | ||
|
|
6f984c6b78 | ||
|
|
5754b0ca81 | ||
|
|
484a388b62 | ||
|
|
d720c0500f | ||
|
|
cfa9d41b36 | ||
|
|
b974f233f4 | ||
|
|
3741ac2658 | ||
|
|
e8a726af80 | ||
|
|
53f0e55e93 | ||
|
|
06d114b46b | ||
|
|
171ed1a6ab | ||
|
|
dee1f39554 | ||
|
|
adb4ef1af8 | ||
|
|
4cc3e096b5 | ||
|
|
1d26ae481e | ||
|
|
8fe18c7f83 | ||
|
|
929e7d5aed | ||
|
|
3ab6408258 | ||
|
|
f0aa2b7d3a | ||
|
|
9bf6c17d63 | ||
|
|
1a4ae5b49b | ||
|
|
400c32a539 | ||
|
|
2cb818ec71 |
14
.env.example
14
.env.example
@@ -25,7 +25,9 @@ POSTGRES_DB=cameleer_saas
|
||||
CLICKHOUSE_PASSWORD=change_me_in_production
|
||||
|
||||
# Admin user (created by bootstrap)
|
||||
SAAS_ADMIN_USER=admin
|
||||
# In SaaS mode, this must be an email address (primary user identity).
|
||||
# In standalone mode, any username is accepted.
|
||||
SAAS_ADMIN_USER=admin@example.com
|
||||
SAAS_ADMIN_PASS=change_me_in_production
|
||||
|
||||
# SMTP / email connector configuration is managed at runtime via the vendor
|
||||
@@ -46,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
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
|
||||
- name: Build and Test (unit tests only)
|
||||
run: >-
|
||||
mvn clean verify -B
|
||||
mvn clean verify -U -B
|
||||
-Dsurefire.excludes="**/AuthControllerTest.java,**/TenantControllerTest.java,**/LicenseControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java,**/EnvironmentControllerTest.java,**/AppControllerTest.java,**/DeploymentControllerTest.java,**/AgentStatusControllerTest.java,**/VendorTenantControllerTest.java,**/TenantPortalControllerTest.java"
|
||||
|
||||
- name: Build sign-in UI
|
||||
@@ -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** (2838 symbols, 6037 relationships, 239 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **cameleer-saas** (3717 symbols, 8054 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.
|
||||
|
||||
|
||||
16
CLAUDE.md
16
CLAUDE.md
@@ -6,6 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
Cameleer SaaS — **vendor management plane** for the Cameleer observability stack. Three personas: **vendor** (platform:admin) manages the platform and provisions tenants; **tenant admin** (tenant:manage) manages their observability instance; **new user** (authenticated, no scopes) goes through self-service onboarding. Tenants can be created by the vendor OR via self-service sign-up (email registration + onboarding wizard). Each tenant gets per-tenant cameleer-server + UI instances via Docker API.
|
||||
|
||||
**Email is the primary user identity** in SaaS mode. `SAAS_ADMIN_USER` IS the email address — there is no separate `SAAS_ADMIN_EMAIL`. The installer enforces email format in SaaS mode (must contain `@`; auto-appends `@<PUBLIC_HOST>` if missing). The bootstrap uses `SAAS_ADMIN_USER` as both the Logto username and primaryEmail. In standalone mode, any username is accepted. Self-service registration (email + password + verification code) is disabled by default and enabled via the vendor UI after configuring an email connector.
|
||||
|
||||
## Ecosystem
|
||||
|
||||
This repo is the SaaS layer on top of two proven components:
|
||||
@@ -19,20 +21,22 @@ 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 |
|
||||
|---------|---------|-------------|
|
||||
| `config/` | Security, tenant isolation, web config | `SecurityConfig`, `TenantIsolationInterceptor`, `TenantContext`, `PublicConfigController`, `MeController` |
|
||||
| `tenant/` | Tenant data model | `TenantEntity` (JPA: id, name, slug, tier, status, logto_org_id, db_password) |
|
||||
| `vendor/` | Vendor console (platform:admin) | `VendorTenantService`, `VendorTenantController`, `InfrastructureService`, `EmailConnectorService`, `EmailConnectorController` |
|
||||
| `account/` | Shared user account operations | `AccountService` (profile, password, MFA, passkeys), `AccountController` (`/api/account/*`) |
|
||||
| `vendor/` | Vendor console (platform:admin) | `VendorTenantService`, `VendorTenantController`, `InfrastructureService`, `EmailConnectorService`, `EmailConnectorController`, `VendorAuthPolicyController`, `VendorAuthPolicyEntity`, `VendorAdminService`, `VendorAdminController` |
|
||||
| `onboarding/` | Self-service sign-up onboarding | `OnboardingController`, `OnboardingService` |
|
||||
| `portal/` | Tenant admin portal (org-scoped) | `TenantPortalService`, `TenantPortalController` |
|
||||
| `portal/` | Tenant admin portal (org-scoped) | `TenantPortalService` (delegates user-level ops to AccountService), `TenantPortalController` |
|
||||
| `provisioning/` | Pluggable tenant provisioning | `DockerTenantProvisioner`, `TenantDatabaseService`, `TenantDataCleanupService` |
|
||||
| `certificate/` | TLS certificate lifecycle | `CertificateService`, `CertificateController`, `TenantCaCertService` |
|
||||
| `license/` | License management | `LicenseService`, `LicenseController` |
|
||||
| `identity/` | Logto & server integration | `LogtoManagementClient`, `ServerApiClient` |
|
||||
| `audit/` | Audit logging | `AuditService` |
|
||||
| `webhook/` | Logto webhook receiver | `LogtoWebhookController` |
|
||||
|
||||
### Frontend
|
||||
|
||||
@@ -54,6 +58,9 @@ For detailed architecture docs, see the directory-scoped CLAUDE.md files (loaded
|
||||
|
||||
PostgreSQL (Flyway): `src/main/resources/db/migration/`
|
||||
- V001 — consolidated baseline: tenants (with db_password, server_endpoint, provision_error, ca_applied_at), licenses, audit_log, certificates, tenant_ca_certs
|
||||
- V002 — license minter: signing_keys table, tier renames, license label + grace period
|
||||
- V003 — passkey MFA: vendor_auth_policy single-row config table (mfa_mode, passkey_enabled, passkey_mode)
|
||||
- V006 — audit_log immutability: triggers preventing UPDATE/DELETE on audit_log (SOC 2 CC7.2/CC7.3)
|
||||
|
||||
## Related Conventions
|
||||
|
||||
@@ -65,6 +72,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.
|
||||
@@ -77,7 +85,7 @@ PostgreSQL (Flyway): `src/main/resources/db/migration/`
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **cameleer-saas** (2881 symbols, 6138 relationships, 243 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **cameleer-saas** (3717 symbols, 8054 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.
|
||||
|
||||
|
||||
15
Dockerfile
15
Dockerfile
@@ -15,17 +15,16 @@ WORKDIR /build
|
||||
COPY .mvn/ .mvn/
|
||||
COPY mvnw pom.xml ./
|
||||
# Cache deps — BuildKit cache mount persists across --no-cache builds
|
||||
RUN --mount=type=cache,target=/root/.m2/repository ./mvnw dependency:go-offline -B || true
|
||||
RUN --mount=type=cache,target=/root/.m2/repository ./mvnw dependency:go-offline -U -B || true
|
||||
COPY src/ src/
|
||||
COPY --from=frontend /ui/dist/ src/main/resources/static/
|
||||
RUN --mount=type=cache,target=/root/.m2/repository ./mvnw package -DskipTests -B
|
||||
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)
|
||||
@@ -80,7 +81,7 @@ Idempotent script run inside the Logto container entrypoint. **Clean slate** —
|
||||
3. Create Logto apps (SPA, Traditional Web App with `skipConsent`, M2M with Management API role + server API role)
|
||||
3b. Create API resource scopes (1 platform + 9 tenant + 3 server scopes)
|
||||
4. Create org roles (owner, operator, viewer with API resource scope assignments) + M2M server role (`cameleer-m2m-server` with `server:admin` scope)
|
||||
5. Create admin user (SaaS admin with Logto console access)
|
||||
5. Create admin user (SaaS admin with Logto console access). `SAAS_ADMIN_USER` is the admin's email address in SaaS mode — used as both the Logto username and primaryEmail. No separate `SAAS_ADMIN_EMAIL`.
|
||||
7b. Configure Logto Custom JWT for access tokens (maps org roles -> `roles` claim: owner->server:admin, operator->server:operator, viewer->server:viewer; saas-vendor global role -> server:admin)
|
||||
8. Configure Logto sign-in branding (Cameleer colors `#C6820E`/`#D4941E`, logo from `/platform/logo.svg`)
|
||||
8c. Configure sign-in experience (sign-in only) — sets `signInMode: "SignIn"` with username+password method. Registration is disabled by default; the vendor admin enables it via the Email Connector UI after configuring SMTP delivery.
|
||||
|
||||
@@ -25,8 +25,18 @@ API_RESOURCE_INDICATOR="https://api.cameleer.local"
|
||||
API_RESOURCE_NAME="Cameleer SaaS API"
|
||||
|
||||
# Users (configurable via env vars)
|
||||
# In SaaS mode, SAAS_ADMIN_USER is the admin's email address (e.g. admin@company.com).
|
||||
# The local part (before @) is used as the Logto username; the full value as primaryEmail.
|
||||
SAAS_ADMIN_USER="${SAAS_ADMIN_USER:-admin}"
|
||||
SAAS_ADMIN_PASS="${SAAS_ADMIN_PASS:-admin}"
|
||||
# Extract username (local part) for Logto — Logto rejects @ in usernames
|
||||
if echo "$SAAS_ADMIN_USER" | grep -q '@'; then
|
||||
ADMIN_USERNAME="${SAAS_ADMIN_USER%%@*}"
|
||||
ADMIN_EMAIL="$SAAS_ADMIN_USER"
|
||||
else
|
||||
ADMIN_USERNAME="$SAAS_ADMIN_USER"
|
||||
ADMIN_EMAIL=""
|
||||
fi
|
||||
|
||||
# No server config — servers are provisioned dynamically by the admin console
|
||||
|
||||
@@ -36,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; }
|
||||
@@ -389,19 +399,27 @@ log "API resource scopes assigned to organization roles."
|
||||
# ============================================================
|
||||
|
||||
# --- Platform Owner ---
|
||||
log "Checking for platform owner user '$SAAS_ADMIN_USER'..."
|
||||
ADMIN_USER_ID=$(api_get "/api/users?search=$SAAS_ADMIN_USER" | jq -r ".[] | select(.username == \"$SAAS_ADMIN_USER\") | .id")
|
||||
log "Checking for platform owner user '$ADMIN_USERNAME'..."
|
||||
ADMIN_USER_ID=$(api_get "/api/users?search=$ADMIN_USERNAME" | jq -r ".[] | select(.username == \"$ADMIN_USERNAME\") | .id")
|
||||
if [ -n "$ADMIN_USER_ID" ]; then
|
||||
log "Platform owner exists: $ADMIN_USER_ID"
|
||||
else
|
||||
log "Creating platform owner '$SAAS_ADMIN_USER'..."
|
||||
ADMIN_RESPONSE=$(api_post "/api/users" "{
|
||||
\"username\": \"$SAAS_ADMIN_USER\",
|
||||
\"password\": \"$SAAS_ADMIN_PASS\",
|
||||
\"name\": \"Platform Owner\"
|
||||
}")
|
||||
# Build user JSON — include primaryEmail only if SAAS_ADMIN_USER is an email
|
||||
ADMIN_USER_JSON="{\"username\": \"$ADMIN_USERNAME\", \"password\": \"$SAAS_ADMIN_PASS\", \"name\": \"Platform Owner\""
|
||||
if [ -n "$ADMIN_EMAIL" ]; then
|
||||
ADMIN_USER_JSON="$ADMIN_USER_JSON, \"primaryEmail\": \"$ADMIN_EMAIL\""
|
||||
log "Creating platform owner '$ADMIN_USERNAME' (email: $ADMIN_EMAIL)..."
|
||||
else
|
||||
log "Creating platform owner '$ADMIN_USERNAME'..."
|
||||
fi
|
||||
ADMIN_USER_JSON="$ADMIN_USER_JSON}"
|
||||
ADMIN_RESPONSE=$(api_post "/api/users" "$ADMIN_USER_JSON")
|
||||
ADMIN_USER_ID=$(echo "$ADMIN_RESPONSE" | jq -r '.id')
|
||||
log "Created platform owner: $ADMIN_USER_ID"
|
||||
if [ -z "$ADMIN_USER_ID" ] || [ "$ADMIN_USER_ID" = "null" ]; then
|
||||
log "ERROR: Failed to create platform owner. Response: $(echo "$ADMIN_RESPONSE" | head -c 300)"
|
||||
else
|
||||
log "Created platform owner: $ADMIN_USER_ID"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Grant SaaS admin Logto console access (admin tenant, port 3002) ---
|
||||
@@ -441,12 +459,12 @@ else
|
||||
-d "$2" "${LOGTO_ADMIN_ENDPOINT}${1}" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Check if admin user already exists on admin tenant
|
||||
ADMIN_TENANT_USER_ID=$(admin_api_get "/api/users?search=$SAAS_ADMIN_USER" | jq -r ".[] | select(.username == \"$SAAS_ADMIN_USER\") | .id" 2>/dev/null)
|
||||
# Check if admin user already exists on admin tenant (uses ADMIN_USERNAME, not email)
|
||||
ADMIN_TENANT_USER_ID=$(admin_api_get "/api/users?search=$ADMIN_USERNAME" | jq -r ".[] | select(.username == \"$ADMIN_USERNAME\") | .id" 2>/dev/null)
|
||||
if [ -z "$ADMIN_TENANT_USER_ID" ] || [ "$ADMIN_TENANT_USER_ID" = "null" ]; then
|
||||
log "Creating admin console user '$SAAS_ADMIN_USER'..."
|
||||
log "Creating admin console user '$ADMIN_USERNAME'..."
|
||||
ADMIN_TENANT_RESPONSE=$(admin_api_post "/api/users" "{
|
||||
\"username\": \"$SAAS_ADMIN_USER\",
|
||||
\"username\": \"$ADMIN_USERNAME\",
|
||||
\"password\": \"$SAAS_ADMIN_PASS\",
|
||||
\"name\": \"Platform Admin\"
|
||||
}")
|
||||
@@ -534,7 +552,15 @@ CUSTOM_JWT_SCRIPT='const getCustomJwtClaims = async ({ token, context, environme
|
||||
if (role.name === "saas-vendor") roles.add("server:admin");
|
||||
}
|
||||
}
|
||||
return roles.size > 0 ? { roles: [...roles] } : {};
|
||||
const mfaFactors = context?.user?.mfaVerificationFactors || [];
|
||||
const mfaEnrolled = mfaFactors.some(f => f.type === "Totp" || f.type === "WebAuthn");
|
||||
const passkeyEnrolled = mfaFactors.some(f => f.type === "WebAuthn");
|
||||
const claims = {};
|
||||
if (roles.size > 0) claims.roles = [...roles];
|
||||
claims.mfa_enrolled = mfaEnrolled;
|
||||
claims.passkey_enrolled = passkeyEnrolled;
|
||||
claims.mfa_method_preference = context?.user?.customData?.mfa_method_preference || null;
|
||||
return claims;
|
||||
};'
|
||||
|
||||
CUSTOM_JWT_PAYLOAD=$(jq -n --arg script "$CUSTOM_JWT_SCRIPT" '{ script: $script }')
|
||||
@@ -575,6 +601,12 @@ api_patch "/api/sign-in-exp" '{
|
||||
"signInMode": "SignIn",
|
||||
"signIn": {
|
||||
"methods": [
|
||||
{
|
||||
"identifier": "email",
|
||||
"password": true,
|
||||
"verificationCode": false,
|
||||
"isPasswordPrimary": true
|
||||
},
|
||||
{
|
||||
"identifier": "username",
|
||||
"password": true,
|
||||
@@ -582,6 +614,10 @@ api_patch "/api/sign-in-exp" '{
|
||||
"isPasswordPrimary": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"mfa": {
|
||||
"factors": ["Totp", "WebAuthn", "BackupCode"],
|
||||
"policy": "UserControlled"
|
||||
}
|
||||
}' >/dev/null 2>&1
|
||||
log "Sign-in experience configured: SignIn only (registration disabled until email is configured)."
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
449
docs/superpowers/plans/2026-04-26-email-template-polish-plan.md
Normal file
449
docs/superpowers/plans/2026-04-26-email-template-polish-plan.md
Normal file
@@ -0,0 +1,449 @@
|
||||
# Email Template Polish Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace inline HTML email templates with polished, branded HTML files loaded from classpath, featuring playful desert/caravan copy, structured card layout with watermark, and proper header/footer.
|
||||
|
||||
**Architecture:** Extract 4 email templates from `EmailConnectorService.buildSmtpConfig()` into standalone HTML files at `src/main/resources/email-templates/`. Generate a pre-faded watermark PNG served as a static asset. Inject `ProvisioningProperties` to resolve the watermark URL at runtime.
|
||||
|
||||
**Tech Stack:** Java 21, Spring Boot, ImageMagick (one-time asset generation), HTML email (inline styles only)
|
||||
|
||||
---
|
||||
|
||||
### File Map
|
||||
|
||||
| Action | File | Purpose |
|
||||
|--------|------|---------|
|
||||
| Create | `src/main/resources/email-templates/register.html` | Registration verification email |
|
||||
| Create | `src/main/resources/email-templates/sign-in.html` | Sign-in verification email |
|
||||
| Create | `src/main/resources/email-templates/forgot-password.html` | Password reset email |
|
||||
| Create | `src/main/resources/email-templates/generic.html` | Generic verification email |
|
||||
| Create | `src/main/resources/static/assets/email-watermark.png` | Pre-faded logo at 7% opacity |
|
||||
| Modify | `src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java` | Load templates from classpath, inject watermark URL |
|
||||
| Modify | `src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java:49` | Permit `/assets/**` for unauthenticated email clients |
|
||||
| Create | `src/test/java/net/siegeln/cameleer/saas/vendor/EmailTemplateLoadingTest.java` | Verify templates load and placeholders resolve |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Generate the pre-faded watermark PNG
|
||||
|
||||
**Files:**
|
||||
- Create: `src/main/resources/static/assets/email-watermark.png`
|
||||
|
||||
- [ ] **Step 1: Generate the faded watermark using ImageMagick**
|
||||
|
||||
Source the logo from the design-system sibling repo. Apply 7% opacity on a transparent background, output to the static assets directory:
|
||||
|
||||
```bash
|
||||
magick "C:/Users/Hendrik/Documents/projects/design-system/assets/cameleer-logo.png" \
|
||||
-channel A -evaluate Multiply 0.07 +channel \
|
||||
-resize 320x320 \
|
||||
"src/main/resources/static/assets/email-watermark.png"
|
||||
```
|
||||
|
||||
If `magick` is not available, use Python Pillow as fallback:
|
||||
|
||||
```bash
|
||||
python3 -c "
|
||||
from PIL import Image
|
||||
img = Image.open('C:/Users/Hendrik/Documents/projects/design-system/assets/cameleer-logo.png').convert('RGBA')
|
||||
img = img.resize((320, 320), Image.LANCZOS)
|
||||
r, g, b, a = img.split()
|
||||
a = a.point(lambda x: int(x * 0.07))
|
||||
img = Image.merge('RGBA', (r, g, b, a))
|
||||
img.save('src/main/resources/static/assets/email-watermark.png')
|
||||
print('Saved watermark')
|
||||
"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the file exists and is reasonable size**
|
||||
|
||||
```bash
|
||||
ls -la src/main/resources/static/assets/email-watermark.png
|
||||
```
|
||||
|
||||
Expected: File exists, roughly 5-30 KB.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/resources/static/assets/email-watermark.png
|
||||
git commit -m "feat: add pre-faded logo watermark for email templates"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Permit static assets in SecurityConfig
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java:49`
|
||||
|
||||
The watermark image must be loadable by email clients without authentication. The current security config has `.anyRequest().authenticated()` as catch-all, so `/assets/**` needs an explicit permit.
|
||||
|
||||
- [ ] **Step 1: Add `/assets/**` to the permitAll list**
|
||||
|
||||
In `SecurityConfig.java`, find the existing line:
|
||||
|
||||
```java
|
||||
.requestMatchers("/_app/**", "/favicon.ico", "/favicon.svg", "/logo.svg", "/logo-dark.svg").permitAll()
|
||||
```
|
||||
|
||||
Change it to:
|
||||
|
||||
```java
|
||||
.requestMatchers("/_app/**", "/assets/**", "/favicon.ico", "/favicon.svg", "/logo.svg", "/logo-dark.svg").permitAll()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java
|
||||
git commit -m "feat: permit /assets/** for unauthenticated access (email watermark)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Create the 4 HTML email template files
|
||||
|
||||
**Files:**
|
||||
- Create: `src/main/resources/email-templates/register.html`
|
||||
- Create: `src/main/resources/email-templates/sign-in.html`
|
||||
- Create: `src/main/resources/email-templates/forgot-password.html`
|
||||
- Create: `src/main/resources/email-templates/generic.html`
|
||||
|
||||
All templates use the same card structure. The `{{code}}` placeholder is Logto's built-in substitution. The `{{watermarkUrl}}` placeholder is replaced by `EmailConnectorService` at runtime.
|
||||
|
||||
- [ ] **Step 1: Create `register.html`**
|
||||
|
||||
```html
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
|
||||
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
|
||||
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
|
||||
</div>
|
||||
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
|
||||
<img src="{{watermarkUrl}}" width="320" height="320" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;" alt="" />
|
||||
<div style="position:relative;">
|
||||
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Welcome to the caravan!</p>
|
||||
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">Enter this code to verify your email and claim your spot. The dunes wait for no one.</p>
|
||||
<div style="text-align:center;margin:0 0 24px;">
|
||||
<div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
|
||||
<span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">This code expires in 10 minutes. If you didn't request this, you can safely ignore this email — no camels were harmed.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
|
||||
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
|
||||
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `sign-in.html`**
|
||||
|
||||
```html
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
|
||||
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
|
||||
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
|
||||
</div>
|
||||
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
|
||||
<img src="{{watermarkUrl}}" width="320" height="320" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;" alt="" />
|
||||
<div style="position:relative;">
|
||||
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Back at the oasis already?</p>
|
||||
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">Here's your sign-in code. The caravan master is checking credentials.</p>
|
||||
<div style="text-align:center;margin:0 0 24px;">
|
||||
<div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
|
||||
<span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">This code expires in 10 minutes.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
|
||||
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
|
||||
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create `forgot-password.html`**
|
||||
|
||||
```html
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
|
||||
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
|
||||
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
|
||||
</div>
|
||||
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
|
||||
<img src="{{watermarkUrl}}" width="320" height="320" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;" alt="" />
|
||||
<div style="position:relative;">
|
||||
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Lost in the dunes?</p>
|
||||
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">No worries — enter this code to reset your password and get back on the trail.</p>
|
||||
<div style="text-align:center;margin:0 0 24px;">
|
||||
<div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
|
||||
<span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">This code expires in 10 minutes. If you didn't request a password reset, you can safely ignore this email.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
|
||||
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
|
||||
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create `generic.html`**
|
||||
|
||||
```html
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
|
||||
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
|
||||
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
|
||||
</div>
|
||||
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
|
||||
<img src="{{watermarkUrl}}" width="320" height="320" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;" alt="" />
|
||||
<div style="position:relative;">
|
||||
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Quick checkpoint</p>
|
||||
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">Here's your verification code. Just making sure it's really you at the reins.</p>
|
||||
<div style="text-align:center;margin:0 0 24px;">
|
||||
<div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
|
||||
<span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">This code expires in 10 minutes.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
|
||||
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
|
||||
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/resources/email-templates/
|
||||
git commit -m "feat: add branded HTML email templates with desert/caravan copy"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Refactor EmailConnectorService to load templates from classpath
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/test/java/net/siegeln/cameleer/saas/vendor/EmailTemplateLoadingTest.java`:
|
||||
|
||||
```java
|
||||
package net.siegeln.cameleer.saas.vendor;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class EmailTemplateLoadingTest {
|
||||
|
||||
private static final String[] TEMPLATE_FILES = {
|
||||
"email-templates/register.html",
|
||||
"email-templates/sign-in.html",
|
||||
"email-templates/forgot-password.html",
|
||||
"email-templates/generic.html"
|
||||
};
|
||||
|
||||
@Test
|
||||
void allTemplateFilesExistOnClasspath() {
|
||||
for (String path : TEMPLATE_FILES) {
|
||||
var resource = new ClassPathResource(path);
|
||||
assertTrue(resource.exists(), "Template file missing: " + path);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void templatesContainCodePlaceholder() throws IOException {
|
||||
for (String path : TEMPLATE_FILES) {
|
||||
String content = new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
|
||||
assertTrue(content.contains("{{code}}"),
|
||||
path + " must contain {{code}} placeholder");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void templatesContainWatermarkPlaceholder() throws IOException {
|
||||
for (String path : TEMPLATE_FILES) {
|
||||
String content = new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
|
||||
assertTrue(content.contains("{{watermarkUrl}}"),
|
||||
path + " must contain {{watermarkUrl}} placeholder");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void watermarkPlaceholderIsReplaced() throws IOException {
|
||||
String content = new ClassPathResource("email-templates/register.html")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
String resolved = content.replace("{{watermarkUrl}}",
|
||||
"https://example.com/platform/assets/email-watermark.png");
|
||||
assertFalse(resolved.contains("{{watermarkUrl}}"));
|
||||
assertTrue(resolved.contains("https://example.com/platform/assets/email-watermark.png"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void templatesContainBrandElements() throws IOException {
|
||||
for (String path : TEMPLATE_FILES) {
|
||||
String content = new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
|
||||
assertTrue(content.contains("Cameleer.io"),
|
||||
path + " must contain Cameleer.io header");
|
||||
assertTrue(content.contains("Apache Camel observability"),
|
||||
path + " must contain tagline");
|
||||
assertTrue(content.contains("#C6820E"),
|
||||
path + " must use brand color");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they pass (templates exist from Task 3)**
|
||||
|
||||
```bash
|
||||
./mvnw test -pl . -Dtest=EmailTemplateLoadingTest -Dspring.profiles.active=test
|
||||
```
|
||||
|
||||
Expected: All 5 tests PASS.
|
||||
|
||||
- [ ] **Step 3: Add `ProvisioningProperties` dependency to `EmailConnectorService`**
|
||||
|
||||
Replace the constructor and add the template loading logic. The full updated `EmailConnectorService.java`:
|
||||
|
||||
Change the imports and fields at the top of the class — add `ProvisioningProperties` import and field:
|
||||
|
||||
```java
|
||||
import net.siegeln.cameleer.saas.provisioning.ProvisioningProperties;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
```
|
||||
|
||||
Replace the constructor:
|
||||
|
||||
```java
|
||||
private final LogtoManagementClient logtoClient;
|
||||
private final ProvisioningProperties provisioningProps;
|
||||
|
||||
public EmailConnectorService(LogtoManagementClient logtoClient, ProvisioningProperties provisioningProps) {
|
||||
this.logtoClient = logtoClient;
|
||||
this.provisioningProps = provisioningProps;
|
||||
}
|
||||
```
|
||||
|
||||
Replace the `buildSmtpConfig` method (lines 157-191) with:
|
||||
|
||||
```java
|
||||
/** Load an email template from classpath and resolve the watermark URL placeholder. */
|
||||
private String loadTemplate(String filename) {
|
||||
try {
|
||||
String content = new ClassPathResource("email-templates/" + filename)
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
String watermarkUrl = provisioningProps.publicProtocol() + "://"
|
||||
+ provisioningProps.publicHost() + "/platform/assets/email-watermark.png";
|
||||
return content.replace("{{watermarkUrl}}", watermarkUrl);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Failed to load email template: " + filename, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the Logto SMTP connector config with Cameleer-branded email templates. */
|
||||
private Map<String, Object> buildSmtpConfig(SmtpConfig smtp) {
|
||||
var config = new HashMap<String, Object>();
|
||||
config.put("host", smtp.host());
|
||||
config.put("port", smtp.port());
|
||||
config.put("auth", Map.of("user", smtp.username(), "pass", smtp.password()));
|
||||
config.put("fromEmail", smtp.fromEmail());
|
||||
config.put("templates", List.of(
|
||||
Map.of(
|
||||
"usageType", "Register",
|
||||
"contentType", "text/html",
|
||||
"subject", "Your caravan pass is almost ready",
|
||||
"content", loadTemplate("register.html")
|
||||
),
|
||||
Map.of(
|
||||
"usageType", "SignIn",
|
||||
"contentType", "text/html",
|
||||
"subject", "Your Cameleer sign-in code",
|
||||
"content", loadTemplate("sign-in.html")
|
||||
),
|
||||
Map.of(
|
||||
"usageType", "ForgotPassword",
|
||||
"contentType", "text/html",
|
||||
"subject", "Reset your Cameleer password",
|
||||
"content", loadTemplate("forgot-password.html")
|
||||
),
|
||||
Map.of(
|
||||
"usageType", "Generic",
|
||||
"contentType", "text/html",
|
||||
"subject", "Your Cameleer verification code",
|
||||
"content", loadTemplate("generic.html")
|
||||
)
|
||||
));
|
||||
return config;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify the project compiles**
|
||||
|
||||
```bash
|
||||
./mvnw compile -pl .
|
||||
```
|
||||
|
||||
Expected: BUILD SUCCESS
|
||||
|
||||
- [ ] **Step 5: Run the template tests again to confirm nothing broke**
|
||||
|
||||
```bash
|
||||
./mvnw test -pl . -Dtest=EmailTemplateLoadingTest -Dspring.profiles.active=test
|
||||
```
|
||||
|
||||
Expected: All 5 tests PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java
|
||||
git add src/test/java/net/siegeln/cameleer/saas/vendor/EmailTemplateLoadingTest.java
|
||||
git commit -m "feat: load email templates from classpath with watermark URL resolution"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Run the full test suite
|
||||
|
||||
**Files:** None (verification only)
|
||||
|
||||
- [ ] **Step 1: Run all tests**
|
||||
|
||||
```bash
|
||||
./mvnw test -Dspring.profiles.active=test
|
||||
```
|
||||
|
||||
Expected: BUILD SUCCESS, all tests pass. If any existing tests fail due to the new `ProvisioningProperties` constructor parameter on `EmailConnectorService`, they will need their mocks updated — but there are no existing tests for this class.
|
||||
|
||||
- [ ] **Step 2: Verify the watermark is accessible without auth by checking SecurityConfig**
|
||||
|
||||
Confirm the `/assets/**` matcher is in the `permitAll()` chain (done in Task 2). With context-path `/platform`, the full public URL will be `https://<host>/platform/assets/email-watermark.png`.
|
||||
|
||||
- [ ] **Step 3: Final commit if any fixes were needed**
|
||||
|
||||
Only if test failures required changes:
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: resolve test failures from email template refactor"
|
||||
```
|
||||
614
docs/superpowers/plans/2026-04-26-license-minter-integration.md
Normal file
614
docs/superpowers/plans/2026-04-26-license-minter-integration.md
Normal file
@@ -0,0 +1,614 @@
|
||||
# License Minter Integration — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace UUID-based license tokens with Ed25519-signed tokens minted by `cameleer-license-minter`, with full vendor UI for configurable minting, distribution, and verification.
|
||||
|
||||
**Architecture:** The SaaS platform embeds `cameleer-license-minter` as a Maven dependency and calls `LicenseMinter.mint()` with an Ed25519 private key stored in the DB. Signed tokens are pushed to tenant servers via env vars and REST API. The vendor UI provides tier presets with per-limit customization, copy/email distribution as env-var bundles, and a token verification tool.
|
||||
|
||||
**Tech Stack:** Spring Boot 3.4, JPA/Flyway/PostgreSQL, Ed25519 (JCE), `cameleer-license-minter` + `cameleer-server-core` (LicenseInfo, LicenseValidator), React 19, @cameleer/design-system, TanStack Query.
|
||||
|
||||
**Decisions:**
|
||||
- Tiers renamed: LOW→STARTER, MID→TEAM, HIGH→BUSINESS, BUSINESS→ENTERPRISE
|
||||
- Tiers are presets only — vendor can customize any limit (becomes "Custom" in UI)
|
||||
- Private key stored in DB (signing_keys table)
|
||||
- Features concept dropped — server enforces caps, not feature flags
|
||||
- Standalone distribution: license bundle = token + public key + tenant ID as env vars
|
||||
- Verify tool: paste token → decode + validate signature → show envelope + state
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Backend Foundation
|
||||
|
||||
### Task 1: Maven dependency + Flyway migration
|
||||
|
||||
**Files:**
|
||||
- Modify: `pom.xml`
|
||||
- Create: `src/main/resources/db/migration/V002__license_minter.sql`
|
||||
|
||||
- [ ] **Step 1: Add minter dependency to pom.xml**
|
||||
|
||||
Add inside `<dependencies>`:
|
||||
```xml
|
||||
<!-- License Minter (Ed25519 signing) -->
|
||||
<dependency>
|
||||
<groupId>com.cameleer</groupId>
|
||||
<artifactId>cameleer-license-minter</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
This transitively brings `cameleer-server-core` (for `LicenseInfo`, `LicenseValidator`).
|
||||
|
||||
- [ ] **Step 2: Create Flyway V002 migration**
|
||||
|
||||
```sql
|
||||
-- V002: License minter integration
|
||||
-- Signing keys for Ed25519 license minting
|
||||
CREATE TABLE signing_keys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
public_key_b64 TEXT NOT NULL,
|
||||
private_key_b64 TEXT NOT NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Rename tiers: LOW→STARTER, MID→TEAM, HIGH→BUSINESS, BUSINESS→ENTERPRISE
|
||||
UPDATE tenants SET tier = 'STARTER' WHERE tier = 'LOW';
|
||||
UPDATE tenants SET tier = 'TEAM' WHERE tier = 'MID';
|
||||
UPDATE tenants SET tier = 'BUSINESS' WHERE tier = 'HIGH';
|
||||
UPDATE tenants SET tier = 'ENTERPRISE' WHERE tier = 'BUSINESS';
|
||||
-- Fix double-rename: HIGH→BUSINESS rows that got caught by BUSINESS→ENTERPRISE
|
||||
-- Use a single pass via CASE to avoid this:
|
||||
-- Actually, redo with CASE statement in a single UPDATE:
|
||||
|
||||
-- (Replace the 4 UPDATEs above with this single safe statement)
|
||||
UPDATE tenants SET tier = CASE tier
|
||||
WHEN 'LOW' THEN 'STARTER'
|
||||
WHEN 'MID' THEN 'TEAM'
|
||||
WHEN 'HIGH' THEN 'BUSINESS'
|
||||
WHEN 'BUSINESS' THEN 'ENTERPRISE'
|
||||
ELSE tier
|
||||
END WHERE tier IN ('LOW', 'MID', 'HIGH', 'BUSINESS');
|
||||
|
||||
-- Same for licenses table
|
||||
UPDATE licenses SET tier = CASE tier
|
||||
WHEN 'LOW' THEN 'STARTER'
|
||||
WHEN 'MID' THEN 'TEAM'
|
||||
WHEN 'HIGH' THEN 'BUSINESS'
|
||||
WHEN 'BUSINESS' THEN 'ENTERPRISE'
|
||||
ELSE tier
|
||||
END WHERE tier IN ('LOW', 'MID', 'HIGH', 'BUSINESS');
|
||||
|
||||
-- Add new license columns
|
||||
ALTER TABLE licenses ADD COLUMN label VARCHAR(255);
|
||||
ALTER TABLE licenses ADD COLUMN grace_period_days INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- Drop features column (server enforces caps, not feature flags)
|
||||
ALTER TABLE licenses DROP COLUMN features;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify build compiles**
|
||||
|
||||
Run: `mvn compile -q` (just compile, no tests yet — tests will break until Tier enum is updated)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```
|
||||
feat: add cameleer-license-minter dependency and V002 migration
|
||||
|
||||
Adds Ed25519 license minting library, signing_keys table,
|
||||
renames tiers (LOW→STARTER, MID→TEAM, HIGH→BUSINESS, BUSINESS→ENTERPRISE),
|
||||
adds label + grace_period_days to licenses, drops features column.
|
||||
```
|
||||
|
||||
### Task 2: Tier enum rename + LicenseDefaults rewrite
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/tenant/Tier.java`
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/tenant/TenantEntity.java`
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java`
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/license/LicenseDefaults.java`
|
||||
|
||||
- [ ] **Step 1: Update Tier enum**
|
||||
|
||||
```java
|
||||
package net.siegeln.cameleer.saas.tenant;
|
||||
|
||||
public enum Tier {
|
||||
STARTER, TEAM, BUSINESS, ENTERPRISE
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update TenantEntity default**
|
||||
|
||||
Change `private Tier tier = Tier.LOW;` to `private Tier tier = Tier.STARTER;`
|
||||
|
||||
- [ ] **Step 3: Update TenantService fallback**
|
||||
|
||||
Change `Tier.valueOf(request.tier()) : Tier.LOW` to `Tier.valueOf(request.tier()) : Tier.STARTER`
|
||||
|
||||
- [ ] **Step 4: Rewrite LicenseDefaults**
|
||||
|
||||
Replace entire file with 13-key limits per tier matching the handoff cap matrix. Drop `featuresForTier()`. Only `limitsForTier()`.
|
||||
|
||||
```java
|
||||
package net.siegeln.cameleer.saas.license;
|
||||
|
||||
import net.siegeln.cameleer.saas.tenant.Tier;
|
||||
import java.util.Map;
|
||||
|
||||
public final class LicenseDefaults {
|
||||
|
||||
private LicenseDefaults() {}
|
||||
|
||||
public static final int DEFAULT_GRACE_PERIOD_DAYS = 14;
|
||||
public static final int DEFAULT_LICENSE_DAYS = 365;
|
||||
|
||||
public static Map<String, Integer> limitsForTier(Tier tier) {
|
||||
return switch (tier) {
|
||||
case STARTER -> Map.ofEntries(
|
||||
Map.entry("max_environments", 2),
|
||||
Map.entry("max_apps", 10),
|
||||
Map.entry("max_agents", 20),
|
||||
Map.entry("max_users", 5),
|
||||
Map.entry("max_outbound_connections", 5),
|
||||
Map.entry("max_alert_rules", 10),
|
||||
Map.entry("max_total_cpu_millis", 8000),
|
||||
Map.entry("max_total_memory_mb", 8192),
|
||||
Map.entry("max_total_replicas", 25),
|
||||
Map.entry("max_execution_retention_days", 7),
|
||||
Map.entry("max_log_retention_days", 7),
|
||||
Map.entry("max_metric_retention_days", 7),
|
||||
Map.entry("max_jar_retention_count", 5)
|
||||
);
|
||||
case TEAM -> Map.ofEntries(
|
||||
Map.entry("max_environments", 5),
|
||||
Map.entry("max_apps", 50),
|
||||
Map.entry("max_agents", 100),
|
||||
Map.entry("max_users", 25),
|
||||
Map.entry("max_outbound_connections", 25),
|
||||
Map.entry("max_alert_rules", 50),
|
||||
Map.entry("max_total_cpu_millis", 32000),
|
||||
Map.entry("max_total_memory_mb", 32768),
|
||||
Map.entry("max_total_replicas", 100),
|
||||
Map.entry("max_execution_retention_days", 30),
|
||||
Map.entry("max_log_retention_days", 30),
|
||||
Map.entry("max_metric_retention_days", 30),
|
||||
Map.entry("max_jar_retention_count", 10)
|
||||
);
|
||||
case BUSINESS -> Map.ofEntries(
|
||||
Map.entry("max_environments", 10),
|
||||
Map.entry("max_apps", 200),
|
||||
Map.entry("max_agents", 500),
|
||||
Map.entry("max_users", 100),
|
||||
Map.entry("max_outbound_connections", 100),
|
||||
Map.entry("max_alert_rules", 200),
|
||||
Map.entry("max_total_cpu_millis", 128000),
|
||||
Map.entry("max_total_memory_mb", 131072),
|
||||
Map.entry("max_total_replicas", 500),
|
||||
Map.entry("max_execution_retention_days", 90),
|
||||
Map.entry("max_log_retention_days", 90),
|
||||
Map.entry("max_metric_retention_days", 90),
|
||||
Map.entry("max_jar_retention_count", 25)
|
||||
);
|
||||
case ENTERPRISE -> Map.ofEntries(
|
||||
Map.entry("max_environments", 50),
|
||||
Map.entry("max_apps", 1000),
|
||||
Map.entry("max_agents", 5000),
|
||||
Map.entry("max_users", 1000),
|
||||
Map.entry("max_outbound_connections", 500),
|
||||
Map.entry("max_alert_rules", 1000),
|
||||
Map.entry("max_total_cpu_millis", 512000),
|
||||
Map.entry("max_total_memory_mb", 524288),
|
||||
Map.entry("max_total_replicas", 2000),
|
||||
Map.entry("max_execution_retention_days", 365),
|
||||
Map.entry("max_log_retention_days", 180),
|
||||
Map.entry("max_metric_retention_days", 180),
|
||||
Map.entry("max_jar_retention_count", 50)
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
refactor: rename tiers and rewrite LicenseDefaults to 13-key cap matrix
|
||||
```
|
||||
|
||||
### Task 3: SigningKeyService + SigningKeyEntity
|
||||
|
||||
**Files:**
|
||||
- Create: `src/main/java/net/siegeln/cameleer/saas/license/SigningKeyEntity.java`
|
||||
- Create: `src/main/java/net/siegeln/cameleer/saas/license/SigningKeyRepository.java`
|
||||
- Create: `src/main/java/net/siegeln/cameleer/saas/license/SigningKeyService.java`
|
||||
|
||||
- [ ] **Step 1: Create SigningKeyEntity**
|
||||
|
||||
JPA entity for the `signing_keys` table: id (UUID), publicKeyB64 (text), privateKeyB64 (text), active (boolean), createdAt (Instant).
|
||||
|
||||
- [ ] **Step 2: Create SigningKeyRepository**
|
||||
|
||||
JpaRepository with `Optional<SigningKeyEntity> findByActiveTrue()`.
|
||||
|
||||
- [ ] **Step 3: Create SigningKeyService**
|
||||
|
||||
Methods:
|
||||
- `getOrCreateActiveKey()` → returns the active key, generating a new Ed25519 keypair on first call
|
||||
- `getPublicKeyBase64()` → convenience for the active key's public key
|
||||
- `getPrivateKey()` → reconstructs `PrivateKey` from stored base64
|
||||
|
||||
Key generation:
|
||||
```java
|
||||
KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
|
||||
String pubB64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
|
||||
String privB64 = Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded());
|
||||
```
|
||||
|
||||
Private key reconstruction:
|
||||
```java
|
||||
byte[] keyBytes = Base64.getDecoder().decode(entity.getPrivateKeyB64());
|
||||
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
|
||||
return KeyFactory.getInstance("Ed25519").generatePrivate(spec);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```
|
||||
feat: add SigningKeyService for Ed25519 keypair management
|
||||
```
|
||||
|
||||
### Task 4: Rewrite LicenseService + LicenseEntity
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/license/LicenseEntity.java`
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/license/LicenseService.java`
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/license/dto/LicenseResponse.java`
|
||||
|
||||
- [ ] **Step 1: Update LicenseEntity**
|
||||
|
||||
- Remove `features` field + getter/setter
|
||||
- Add `label` (String) field + getter/setter
|
||||
- Add `gracePeriodDays` (int) field + getter/setter
|
||||
|
||||
- [ ] **Step 2: Rewrite LicenseService**
|
||||
|
||||
- Add `SigningKeyService` dependency
|
||||
- Rewrite `generateLicense(TenantEntity, Map<String,Integer> limits, Instant expiresAt, int gracePeriodDays, String label, UUID actorId)`:
|
||||
- Build `LicenseInfo(UUID.randomUUID(), tenant.getSlug(), label, limits, Instant.now(), expiresAt, gracePeriodDays)`
|
||||
- Call `LicenseMinter.mint(info, signingKeyService.getPrivateKey())`
|
||||
- Store signed token in entity
|
||||
- Add convenience overload `generateLicense(TenantEntity, Duration, UUID actorId)` that uses tier presets
|
||||
- Remove `verifyLicenseToken()` (server validates cryptographically)
|
||||
- Add `verifyToken(String token)` that uses `LicenseValidator`
|
||||
|
||||
- [ ] **Step 3: Update LicenseResponse DTO**
|
||||
|
||||
Replace `features` with `label` and `gracePeriodDays`. Add `publicKeyB64` for bundle distribution.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```
|
||||
feat: rewrite LicenseService to mint Ed25519-signed tokens
|
||||
```
|
||||
|
||||
### Task 5: Update controllers + portal service
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/license/LicenseController.java`
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java`
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java`
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java`
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java`
|
||||
|
||||
- [ ] **Step 1: Update VendorTenantController**
|
||||
|
||||
- `POST /{id}/license` now takes a request body with limits, expiresAt, gracePeriodDays, label
|
||||
- Add `GET /license-presets` endpoint returning tier presets
|
||||
- Add `POST /license/verify` endpoint
|
||||
- Add `GET /signing-key/public` endpoint
|
||||
|
||||
- [ ] **Step 2: Update VendorTenantService**
|
||||
|
||||
- `renewLicense()` updated to accept customizable parameters
|
||||
- Add `mintLicense()` method with full limit configuration
|
||||
- Add `verifyToken()` delegation
|
||||
|
||||
- [ ] **Step 3: Update VendorTenantController response types**
|
||||
|
||||
- `VendorTenantSummary` — fix `agentLimit` to use `max_agents` key
|
||||
- `VendorTenantDetail` — license field uses updated LicenseResponse
|
||||
|
||||
- [ ] **Step 4: Update TenantPortalService**
|
||||
|
||||
- `DashboardData` — drop features, keep limits
|
||||
- `LicenseData` — drop features, add label + gracePeriodDays
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
feat: update vendor/portal APIs for Ed25519 license minting
|
||||
```
|
||||
|
||||
### Task 6: Fix tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/test/java/net/siegeln/cameleer/saas/license/LicenseServiceTest.java`
|
||||
- Modify: `src/test/java/net/siegeln/cameleer/saas/license/LicenseControllerTest.java`
|
||||
- Modify: `src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantServiceTest.java`
|
||||
- Modify: `src/test/java/net/siegeln/cameleer/saas/tenant/TenantServiceTest.java`
|
||||
- Modify: `src/test/java/net/siegeln/cameleer/saas/portal/TenantPortalServiceTest.java`
|
||||
- Modify: `src/test/java/net/siegeln/cameleer/saas/portal/TenantPortalControllerTest.java`
|
||||
|
||||
- [ ] **Step 1: Update all Tier.LOW→STARTER, Tier.MID→TEAM, Tier.HIGH→BUSINESS, Tier.BUSINESS→ENTERPRISE**
|
||||
|
||||
- [ ] **Step 2: Update LicenseServiceTest**
|
||||
|
||||
- `generateLicense_producesUuidToken` → rename to `generateLicense_producesSignedToken`, assert token contains `.` separator
|
||||
- Remove feature-related assertions
|
||||
- Mock `SigningKeyService` to return a test keypair
|
||||
- Remove `verifyLicenseToken` tests
|
||||
|
||||
- [ ] **Step 3: Update LicenseControllerTest**
|
||||
|
||||
- Remove feature assertions (`features.correlation`)
|
||||
- Update tier values in assertions
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `mvn test -q`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```
|
||||
test: update tests for Ed25519 license minting and tier rename
|
||||
```
|
||||
|
||||
## Phase 2: Provisioning Integration
|
||||
|
||||
### Task 7: Push public key to tenant containers
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java`
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java`
|
||||
|
||||
- [ ] **Step 1: Inject SigningKeyService into DockerTenantProvisioner**
|
||||
|
||||
Add `SigningKeyService` as a constructor dependency.
|
||||
|
||||
- [ ] **Step 2: Add CAMELEER_SERVER_LICENSE_PUBLICKEY env var**
|
||||
|
||||
In `createServerContainer()`, after the existing env vars, add:
|
||||
```java
|
||||
"CAMELEER_SERVER_LICENSE_PUBLICKEY=" + signingKeyService.getPublicKeyBase64()
|
||||
```
|
||||
|
||||
`CAMELEER_SERVER_TENANT_ID` is already set to slug (line 218).
|
||||
`CAMELEER_SERVER_LICENSE_TOKEN` is already set (line 225).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
feat: push Ed25519 public key to tenant server containers
|
||||
```
|
||||
|
||||
## Phase 3: Vendor API — Configurable Minting
|
||||
|
||||
### Task 8: Vendor license endpoints
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java`
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java`
|
||||
- Create: `src/main/java/net/siegeln/cameleer/saas/license/dto/MintLicenseRequest.java`
|
||||
- Create: `src/main/java/net/siegeln/cameleer/saas/license/dto/VerifyLicenseRequest.java`
|
||||
- Create: `src/main/java/net/siegeln/cameleer/saas/license/dto/VerifyLicenseResponse.java`
|
||||
- Create: `src/main/java/net/siegeln/cameleer/saas/license/dto/LicensePreset.java`
|
||||
- Create: `src/main/java/net/siegeln/cameleer/saas/license/dto/LicenseBundleResponse.java`
|
||||
|
||||
- [ ] **Step 1: Create DTOs**
|
||||
|
||||
`MintLicenseRequest`: tier (optional String), limits (Map<String,Integer>), expiresAt (Instant), gracePeriodDays (Integer), label (String), pushToServer (boolean)
|
||||
|
||||
`VerifyLicenseRequest`: token (String)
|
||||
|
||||
`VerifyLicenseResponse`: valid (boolean), state (String), envelope fields (tenantId, label, limits, issuedAt, expiresAt, gracePeriodDays), error (String)
|
||||
|
||||
`LicensePreset`: tier (String), limits (Map<String,Integer>)
|
||||
|
||||
`LicenseBundleResponse`: extends LicenseResponse + adds publicKeyB64, tenantSlug (for the env-var bundle)
|
||||
|
||||
- [ ] **Step 2: Update VendorTenantService**
|
||||
|
||||
Add `mintLicense(UUID tenantId, MintLicenseRequest request, UUID actorId)`:
|
||||
- Resolves limits from request (or tier preset)
|
||||
- Calls `licenseService.generateLicense()` with full params
|
||||
- Optionally pushes to server
|
||||
- Returns the license + public key + slug for the bundle
|
||||
|
||||
Add `verifyToken(String token)`:
|
||||
- Uses LicenseValidator from server-core
|
||||
|
||||
- [ ] **Step 3: Update VendorTenantController**
|
||||
|
||||
- `POST /{id}/license` — takes MintLicenseRequest body, returns LicenseBundleResponse
|
||||
- `GET /license-presets` — returns list of LicensePreset
|
||||
- `POST /license/verify` — takes VerifyLicenseRequest, returns VerifyLicenseResponse
|
||||
- `GET /signing-key/public` — returns `{"publicKey": "<base64>"}`
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```
|
||||
feat: add vendor license minting, presets, and verify endpoints
|
||||
```
|
||||
|
||||
## Phase 4: Vendor UI — License Minting
|
||||
|
||||
### Task 9: Update frontend types + hooks
|
||||
|
||||
**Files:**
|
||||
- Modify: `ui/src/types/api.ts`
|
||||
- Modify: `ui/src/api/vendor-hooks.ts`
|
||||
|
||||
- [ ] **Step 1: Update types**
|
||||
|
||||
- `LicenseResponse` — remove `features`, add `label`, `gracePeriodDays`, `publicKeyB64`, `tenantSlug`
|
||||
- Add `MintLicenseRequest`, `VerifyLicenseRequest`, `VerifyLicenseResponse`, `LicensePreset`, `LicenseBundleResponse`
|
||||
- `DashboardData` — remove `features`
|
||||
- `TenantLicenseData` — remove `features`, add `label`, `gracePeriodDays`
|
||||
|
||||
- [ ] **Step 2: Update hooks**
|
||||
|
||||
- `useRenewLicense()` → replace with `useMintLicense(tenantId)` that takes MintLicenseRequest body
|
||||
- Add `useLicensePresets()`
|
||||
- Add `useVerifyLicense()`
|
||||
- Add `usePublicKey()`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
feat(ui): update types and hooks for Ed25519 license minting
|
||||
```
|
||||
|
||||
### Task 10: License minting form on TenantDetailPage
|
||||
|
||||
**Files:**
|
||||
- Modify: `ui/src/pages/vendor/TenantDetailPage.tsx`
|
||||
|
||||
- [ ] **Step 1: Replace License card**
|
||||
|
||||
Replace the simple "Renew License" button with a minting form:
|
||||
- Tier preset dropdown (STARTER/TEAM/BUSINESS/ENTERPRISE) that pre-fills limits
|
||||
- All 13 limits editable in a grid
|
||||
- Expiry date picker, grace period input, label input
|
||||
- "Custom" indicator when limits diverge from preset
|
||||
- Actions: "Mint & Push to Server" (default), "Mint & Copy Bundle", "Mint & Email Bundle"
|
||||
|
||||
- [ ] **Step 2: License bundle display**
|
||||
|
||||
After minting, show a dialog/card with the full env-var bundle:
|
||||
```
|
||||
CAMELEER_SERVER_TENANT_ID=<slug>
|
||||
CAMELEER_SERVER_LICENSE_PUBLICKEY=<public_key>
|
||||
CAMELEER_SERVER_LICENSE_TOKEN=<token>
|
||||
```
|
||||
With a "Copy Bundle" button.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
feat(ui): add license minting form with tier presets and bundle distribution
|
||||
```
|
||||
|
||||
### Task 11: License verify tool + public key viewer
|
||||
|
||||
**Files:**
|
||||
- Create: `ui/src/pages/vendor/LicenseVerifyPage.tsx`
|
||||
- Modify: `ui/src/router.tsx` (add route)
|
||||
- Modify: `ui/src/Layout.tsx` (add nav item)
|
||||
|
||||
- [ ] **Step 1: Create LicenseVerifyPage**
|
||||
|
||||
- Textarea to paste a token
|
||||
- "Verify" button
|
||||
- Results: valid/invalid badge, decoded envelope (tenantId, label, limits, expiry, grace period)
|
||||
- State badge (ACTIVE/GRACE/EXPIRED/INVALID)
|
||||
- Public key display section with copy button
|
||||
|
||||
- [ ] **Step 2: Add route and navigation**
|
||||
|
||||
Route: `/vendor/license-verify`
|
||||
Nav: "License Tools" section in vendor sidebar
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```
|
||||
feat(ui): add license verify tool and public key viewer
|
||||
```
|
||||
|
||||
### Task 12: Update tier color utility
|
||||
|
||||
**Files:**
|
||||
- Modify: `ui/src/utils/tier.ts`
|
||||
|
||||
- [ ] **Step 1: Update tierColor**
|
||||
|
||||
```typescript
|
||||
export function tierColor(tier: string): 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto' {
|
||||
switch (tier?.toUpperCase()) {
|
||||
case 'ENTERPRISE': return 'success';
|
||||
case 'BUSINESS': return 'primary';
|
||||
case 'TEAM': return 'running';
|
||||
case 'STARTER': return 'warning';
|
||||
default: return 'auto';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```
|
||||
fix(ui): update tier color mapping for renamed tiers
|
||||
```
|
||||
|
||||
## Phase 5: Tenant UI Updates
|
||||
|
||||
### Task 13: Update TenantLicensePage
|
||||
|
||||
**Files:**
|
||||
- Modify: `ui/src/pages/tenant/TenantLicensePage.tsx`
|
||||
|
||||
- [ ] **Step 1: Remove features card, update limits card**
|
||||
|
||||
- Drop the "Features" card entirely
|
||||
- Update "Limits & Usage" card to show all 13 limit keys with proper labels
|
||||
- Show grace period and label if present
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```
|
||||
feat(ui): update tenant license page for Ed25519 model
|
||||
```
|
||||
|
||||
### Task 14: Update TenantDashboardPage
|
||||
|
||||
**Files:**
|
||||
- Modify: `ui/src/pages/tenant/TenantDashboardPage.tsx`
|
||||
|
||||
- [ ] **Step 1: Remove features references**
|
||||
|
||||
Drop any `features` display. Keep limits display.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```
|
||||
fix(ui): remove features from tenant dashboard
|
||||
```
|
||||
|
||||
### Task 15: Update CreateTenantPage
|
||||
|
||||
**Files:**
|
||||
- Modify: `ui/src/pages/vendor/CreateTenantPage.tsx`
|
||||
|
||||
- [ ] **Step 1: Update tier options**
|
||||
|
||||
Change tier dropdown options from LOW/MID/HIGH/BUSINESS to STARTER/TEAM/BUSINESS/ENTERPRISE.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```
|
||||
fix(ui): update tier options in create tenant form
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
After all tasks:
|
||||
- [ ] `mvn test` passes
|
||||
- [ ] `cd ui && npm run build` succeeds
|
||||
- [ ] Docker compose boots (if available)
|
||||
- [ ] Verify a tenant can be created with STARTER tier
|
||||
- [ ] Verify license is minted with Ed25519 signature (token contains `.`)
|
||||
- [ ] Verify CAMELEER_SERVER_LICENSE_PUBLICKEY appears in container env
|
||||
2120
docs/superpowers/plans/2026-04-26-password-reset-mfa-plan.md
Normal file
2120
docs/superpowers/plans/2026-04-26-password-reset-mfa-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
1953
docs/superpowers/plans/2026-04-27-passkey-mfa.md
Normal file
1953
docs/superpowers/plans/2026-04-27-passkey-mfa.md
Normal file
File diff suppressed because it is too large
Load Diff
2368
docs/superpowers/plans/2026-04-27-vendor-admin-account-settings.md
Normal file
2368
docs/superpowers/plans/2026-04-27-vendor-admin-account-settings.md
Normal file
File diff suppressed because it is too large
Load Diff
404
docs/superpowers/plans/2026-04-29-logto-webhook-auth-events.md
Normal file
404
docs/superpowers/plans/2026-04-29-logto-webhook-auth-events.md
Normal file
@@ -0,0 +1,404 @@
|
||||
# Logto Webhook Auth Event Logging — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Capture Logto authentication events (sign-in, registration, password reset) via webhooks and write them to the audit_log table.
|
||||
|
||||
**Architecture:** Register a Logto webhook at startup that posts auth interaction events to an internal SaaS endpoint. The endpoint validates the HMAC-SHA256 signature, extracts user/IP/event data from the payload, and writes audit_log entries via AuditService. The webhook secret is configured via `CAMELEER_SAAS_IDENTITY_WEBHOOKSECRET`.
|
||||
|
||||
**Tech Stack:** Java 21, Spring Boot 3, Logto Management API webhooks, HMAC-SHA256
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `src/main/resources/application.yml` | Modify | Add `webhooksecret` property |
|
||||
| `src/main/java/io/cameleer/saas/identity/LogtoConfig.java` | Modify | Expose webhookSecret getter |
|
||||
| `src/main/java/io/cameleer/saas/identity/LogtoManagementClient.java` | Modify | Add `listWebhooks`, `createWebhook` methods |
|
||||
| `src/main/java/io/cameleer/saas/config/SecurityConfig.java` | Modify | Permit `/api/internal/**` |
|
||||
| `src/main/java/io/cameleer/saas/webhook/LogtoWebhookController.java` | Create | Receive webhook, validate HMAC, dispatch to AuditService |
|
||||
| `src/main/java/io/cameleer/saas/config/LogtoStartupConfig.java` | Modify | Auto-register webhook at startup |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add Webhook Secret Configuration
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/resources/application.yml`
|
||||
- Modify: `src/main/java/io/cameleer/saas/identity/LogtoConfig.java`
|
||||
|
||||
- [ ] **Step 1: Add webhooksecret to application.yml**
|
||||
|
||||
In the `cameleer.saas.identity` section, after `serverendpoint`, add:
|
||||
|
||||
```yaml
|
||||
webhooksecret: ${CAMELEER_SAAS_IDENTITY_WEBHOOKSECRET:}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add webhookSecret field to LogtoConfig**
|
||||
|
||||
Add a new `@Value` field and getter to `LogtoConfig.java`:
|
||||
|
||||
```java
|
||||
@Value("${cameleer.saas.identity.webhooksecret:}")
|
||||
private String webhookSecret;
|
||||
```
|
||||
|
||||
Add getter:
|
||||
|
||||
```java
|
||||
public String getWebhookSecret() { return webhookSecret; }
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build and commit**
|
||||
|
||||
Run: `./mvnw compile -q`
|
||||
|
||||
```bash
|
||||
git add src/main/resources/application.yml \
|
||||
src/main/java/io/cameleer/saas/identity/LogtoConfig.java
|
||||
git commit -m "feat(webhook): add webhooksecret identity config property"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add Webhook Methods to LogtoManagementClient
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/io/cameleer/saas/identity/LogtoManagementClient.java`
|
||||
|
||||
- [ ] **Step 1: Add listWebhooks method**
|
||||
|
||||
Add to LogtoManagementClient (follow existing pattern: `isAvailable()` guard, restClient call, `getAccessToken()`):
|
||||
|
||||
```java
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<Map<String, Object>> listWebhooks() {
|
||||
if (!isAvailable()) return List.of();
|
||||
|
||||
var response = restClient.get()
|
||||
.uri(config.getLogtoEndpoint() + "/api/hooks")
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.retrieve()
|
||||
.body(List.class);
|
||||
return response != null ? response : List.of();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add createWebhook method**
|
||||
|
||||
```java
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> createWebhook(String name, List<String> events, String url, String signingKey) {
|
||||
if (!isAvailable()) return null;
|
||||
|
||||
var body = Map.of(
|
||||
"name", name,
|
||||
"events", events,
|
||||
"config", Map.of("url", url),
|
||||
"signingKey", signingKey
|
||||
);
|
||||
|
||||
return restClient.post()
|
||||
.uri(config.getLogtoEndpoint() + "/api/hooks")
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(body)
|
||||
.retrieve()
|
||||
.body(Map.class);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build and commit**
|
||||
|
||||
Run: `./mvnw compile -q`
|
||||
|
||||
```bash
|
||||
git add src/main/java/io/cameleer/saas/identity/LogtoManagementClient.java
|
||||
git commit -m "feat(webhook): add listWebhooks and createWebhook to LogtoManagementClient"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Permit Internal API Path in SecurityConfig
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/io/cameleer/saas/config/SecurityConfig.java`
|
||||
|
||||
- [ ] **Step 1: Add /api/internal/** to permit list**
|
||||
|
||||
In the `authorizeHttpRequests` block, after the `/api/password-reset-notification` line, add:
|
||||
|
||||
```java
|
||||
.requestMatchers("/api/internal/**").permitAll()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build and commit**
|
||||
|
||||
Run: `./mvnw compile -q`
|
||||
|
||||
```bash
|
||||
git add src/main/java/io/cameleer/saas/config/SecurityConfig.java
|
||||
git commit -m "feat(webhook): permit /api/internal/** in SecurityConfig for webhook receivers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Create LogtoWebhookController
|
||||
|
||||
**Files:**
|
||||
- Create: `src/main/java/io/cameleer/saas/webhook/LogtoWebhookController.java`
|
||||
|
||||
- [ ] **Step 1: Create the webhook receiver controller**
|
||||
|
||||
```java
|
||||
package io.cameleer.saas.webhook;
|
||||
|
||||
import io.cameleer.saas.audit.AuditAction;
|
||||
import io.cameleer.saas.audit.AuditService;
|
||||
import io.cameleer.saas.identity.LogtoConfig;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/internal/webhooks")
|
||||
public class LogtoWebhookController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LogtoWebhookController.class);
|
||||
|
||||
private final AuditService auditService;
|
||||
private final LogtoConfig logtoConfig;
|
||||
|
||||
public LogtoWebhookController(AuditService auditService, LogtoConfig logtoConfig) {
|
||||
this.auditService = auditService;
|
||||
this.logtoConfig = logtoConfig;
|
||||
}
|
||||
|
||||
@PostMapping("/logto")
|
||||
public ResponseEntity<Void> handleWebhook(
|
||||
@RequestBody String rawBody,
|
||||
@RequestHeader(value = "logto-signature-sha-256", required = false) String signature) {
|
||||
|
||||
String secret = logtoConfig.getWebhookSecret();
|
||||
if (secret == null || secret.isBlank()) {
|
||||
log.warn("Webhook secret not configured — rejecting request");
|
||||
return ResponseEntity.status(403).build();
|
||||
}
|
||||
|
||||
if (!verifySignature(rawBody, signature, secret)) {
|
||||
log.warn("Invalid webhook signature — rejecting request");
|
||||
return ResponseEntity.status(401).build();
|
||||
}
|
||||
|
||||
try {
|
||||
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
var payload = mapper.readTree(rawBody);
|
||||
|
||||
String event = payload.path("event").asText("");
|
||||
var userNode = payload.path("user");
|
||||
String userId = userNode.path("id").asText(null);
|
||||
String email = userNode.path("primaryEmail").asText(null);
|
||||
String userIp = payload.path("userIp").asText(null);
|
||||
String userAgent = payload.path("userAgent").asText(null);
|
||||
String appName = payload.path("application").path("name").asText(null);
|
||||
|
||||
UUID actorId = resolveUUID(userId);
|
||||
|
||||
switch (event) {
|
||||
case "PostSignIn" -> {
|
||||
log.info("Auth event: sign-in by user {}", userId);
|
||||
auditService.log(actorId, email, null,
|
||||
AuditAction.AUTH_LOGIN, userId,
|
||||
null, userIp, "SUCCESS",
|
||||
buildMetadata(userAgent, appName, null));
|
||||
}
|
||||
case "PostRegister" -> {
|
||||
log.info("Auth event: registration by user {}", userId);
|
||||
auditService.log(actorId, email, null,
|
||||
AuditAction.AUTH_REGISTER, userId,
|
||||
null, userIp, "SUCCESS",
|
||||
buildMetadata(userAgent, appName, null));
|
||||
}
|
||||
case "PostResetPassword" -> {
|
||||
log.info("Auth event: password reset by user {}", userId);
|
||||
auditService.log(actorId, email, null,
|
||||
AuditAction.AUTH_LOGIN, userId,
|
||||
null, userIp, "SUCCESS",
|
||||
buildMetadata(userAgent, appName, "password_reset"));
|
||||
}
|
||||
default -> log.debug("Ignoring unhandled Logto webhook event: {}", event);
|
||||
}
|
||||
|
||||
return ResponseEntity.ok().build();
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to process Logto webhook: {}", e.getMessage());
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean verifySignature(String payload, String signatureHeader, String secret) {
|
||||
if (signatureHeader == null || signatureHeader.isBlank()) return false;
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||
byte[] hash = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
|
||||
String expected = HexFormat.of().formatHex(hash);
|
||||
return expected.equalsIgnoreCase(signatureHeader);
|
||||
} catch (Exception e) {
|
||||
log.error("HMAC verification failed: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private UUID resolveUUID(String id) {
|
||||
if (id == null) return null;
|
||||
try {
|
||||
return UUID.fromString(id);
|
||||
} catch (Exception e) {
|
||||
return UUID.nameUUIDFromBytes(id.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> buildMetadata(String userAgent, String appName, String via) {
|
||||
var meta = new java.util.LinkedHashMap<String, Object>();
|
||||
if (userAgent != null) meta.put("userAgent", userAgent);
|
||||
if (appName != null) meta.put("application", appName);
|
||||
if (via != null) meta.put("via", via);
|
||||
return meta.isEmpty() ? null : meta;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build and commit**
|
||||
|
||||
Run: `./mvnw compile -q`
|
||||
|
||||
```bash
|
||||
git add src/main/java/io/cameleer/saas/webhook/LogtoWebhookController.java
|
||||
git commit -m "feat(webhook): add LogtoWebhookController for auth event audit logging"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Auto-Register Webhook at Startup
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/io/cameleer/saas/config/LogtoStartupConfig.java`
|
||||
|
||||
- [ ] **Step 1: Inject LogtoConfig and register webhook on startup**
|
||||
|
||||
Add `LogtoConfig` to the constructor. After the existing MFA factor setup in `onStartup()`, add webhook registration:
|
||||
|
||||
```java
|
||||
package io.cameleer.saas.config;
|
||||
|
||||
import io.cameleer.saas.identity.LogtoConfig;
|
||||
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;
|
||||
|
||||
@Component
|
||||
public class LogtoStartupConfig {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LogtoStartupConfig.class);
|
||||
private static final String WEBHOOK_NAME = "cameleer-saas-auth-events";
|
||||
|
||||
private final LogtoManagementClient logtoClient;
|
||||
private final LogtoConfig logtoConfig;
|
||||
|
||||
public LogtoStartupConfig(LogtoManagementClient logtoClient, LogtoConfig logtoConfig) {
|
||||
this.logtoClient = logtoClient;
|
||||
this.logtoConfig = logtoConfig;
|
||||
}
|
||||
|
||||
@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());
|
||||
}
|
||||
|
||||
registerWebhook();
|
||||
}
|
||||
|
||||
private void registerWebhook() {
|
||||
String secret = logtoConfig.getWebhookSecret();
|
||||
if (secret == null || secret.isBlank()) {
|
||||
log.info("Webhook secret not configured — skipping auth event webhook registration");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var existing = logtoClient.listWebhooks();
|
||||
boolean alreadyRegistered = existing.stream()
|
||||
.anyMatch(h -> WEBHOOK_NAME.equals(h.get("name")));
|
||||
|
||||
if (alreadyRegistered) {
|
||||
log.info("Logto webhook '{}' already registered", WEBHOOK_NAME);
|
||||
return;
|
||||
}
|
||||
|
||||
String url = logtoConfig.getLogtoEndpoint().replace("cameleer-logto:3001", "cameleer-saas:8080")
|
||||
.replaceFirst("/api$", "")
|
||||
.replaceFirst(":\\d+.*$", "");
|
||||
url = "http://cameleer-saas:8080/platform/api/internal/webhooks/logto";
|
||||
|
||||
var events = List.of("PostSignIn", "PostRegister", "PostResetPassword");
|
||||
logtoClient.createWebhook(WEBHOOK_NAME, events, url, secret);
|
||||
log.info("Registered Logto webhook '{}' for events {}", WEBHOOK_NAME, events);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to register Logto webhook: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build and commit**
|
||||
|
||||
Run: `./mvnw compile -q`
|
||||
|
||||
```bash
|
||||
git add src/main/java/io/cameleer/saas/config/LogtoStartupConfig.java
|
||||
git commit -m "feat(webhook): auto-register Logto auth event webhook at startup"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Coverage Summary
|
||||
|
||||
| Spec Requirement | Task |
|
||||
|------------------|------|
|
||||
| Config property `cameleer.saas.identity.webhooksecret` | Task 1 |
|
||||
| LogtoManagementClient webhook methods | Task 2 |
|
||||
| SecurityConfig permit internal path | Task 3 |
|
||||
| Webhook receiver with HMAC validation | Task 4 |
|
||||
| PostSignIn → AUTH_LOGIN audit entry | Task 4 |
|
||||
| PostRegister → AUTH_REGISTER audit entry | Task 4 |
|
||||
| PostResetPassword → AUTH_LOGIN (via: password_reset) audit entry | Task 4 |
|
||||
| Idempotent startup registration | Task 5 |
|
||||
| Skip registration if secret not set | Task 5 |
|
||||
310
docs/superpowers/plans/2026-04-29-security-review-fixes.md
Normal file
310
docs/superpowers/plans/2026-04-29-security-review-fixes.md
Normal file
@@ -0,0 +1,310 @@
|
||||
# Security Review Fixes Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Fix 5 security vulnerabilities found in the full-codebase security review — hardcoded JWT secret, missing authorization on tenant portal admin endpoints, dead code password/settings endpoints, and unprotected tenant lookups.
|
||||
|
||||
**Architecture:** All fixes are surgical edits to existing files. No new files, no schema changes. The JWT secret fix adds one field to `ProvisioningProperties` and reads it in the provisioner. The authorization fixes add `@PreAuthorize` annotations. Dead code removal deletes unsafe endpoints and their unused frontend hooks.
|
||||
|
||||
**Tech Stack:** Spring Boot, Spring Security (`@PreAuthorize`), Spring Boot `@ConfigurationProperties`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Fix hardcoded JWT secret in DockerTenantProvisioner
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/io/cameleer/saas/provisioning/ProvisioningProperties.java`
|
||||
- Modify: `src/main/java/io/cameleer/saas/provisioning/DockerTenantProvisioner.java:223`
|
||||
|
||||
- [ ] **Step 1: Add `jwtSecret` field to ProvisioningProperties**
|
||||
|
||||
In `src/main/java/io/cameleer/saas/provisioning/ProvisioningProperties.java`, add `jwtSecret` as a new field after `corsOrigins`:
|
||||
|
||||
```java
|
||||
@ConfigurationProperties(prefix = "cameleer.saas.provisioning")
|
||||
public record ProvisioningProperties(
|
||||
String serverImage,
|
||||
String serverUiImage,
|
||||
String runtimeBaseImage,
|
||||
String networkName,
|
||||
String traefikNetwork,
|
||||
String publicHost,
|
||||
String publicProtocol,
|
||||
String datasourceUrl,
|
||||
String datasourceUsername,
|
||||
String datasourcePassword,
|
||||
String clickhouseUrl,
|
||||
String clickhouseUser,
|
||||
String clickhousePassword,
|
||||
String oidcIssuerUri,
|
||||
String oidcJwkSetUri,
|
||||
String corsOrigins,
|
||||
String jwtSecret
|
||||
) {}
|
||||
```
|
||||
|
||||
This binds from env var `CAMELEER_SAAS_PROVISIONING_JWTSECRET`. The installer already generates `CAMELEER_SERVER_SECURITY_JWTSECRET` — the compose template needs to also set `CAMELEER_SAAS_PROVISIONING_JWTSECRET` to the same value (or the deployer maps it manually). A missing value will be `null`, caught by the validation below.
|
||||
|
||||
- [ ] **Step 2: Replace hardcoded secret with property value**
|
||||
|
||||
In `src/main/java/io/cameleer/saas/provisioning/DockerTenantProvisioner.java`, replace line 223:
|
||||
|
||||
```java
|
||||
// OLD:
|
||||
"CAMELEER_SERVER_SECURITY_JWTSECRET=cameleer-dev-jwt-secret-change-in-production",
|
||||
|
||||
// NEW:
|
||||
"CAMELEER_SERVER_SECURITY_JWTSECRET=" + props.jwtSecret(),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add startup validation for jwtSecret**
|
||||
|
||||
In `DockerTenantProvisioner.java`, add a validation check at the end of the constructor (after line 36):
|
||||
|
||||
```java
|
||||
if (props.jwtSecret() == null || props.jwtSecret().isBlank()) {
|
||||
log.warn("CAMELEER_SAAS_PROVISIONING_JWTSECRET is not set — provisioned servers will fail to start");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/java/io/cameleer/saas/provisioning/ProvisioningProperties.java src/main/java/io/cameleer/saas/provisioning/DockerTenantProvisioner.java
|
||||
git commit -m "fix(security): replace hardcoded JWT secret with config property
|
||||
|
||||
Every provisioned tenant server was using the same hardcoded dev JWT
|
||||
secret ('cameleer-dev-jwt-secret-change-in-production'), visible in
|
||||
source code. An attacker could forge valid JWT tokens for any tenant
|
||||
server. Now reads from CAMELEER_SAAS_PROVISIONING_JWTSECRET."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add authorization to TenantPortalController admin endpoints
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/io/cameleer/saas/portal/TenantPortalController.java`
|
||||
|
||||
- [ ] **Step 1: Add `@PreAuthorize` to team management endpoints**
|
||||
|
||||
Add `@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")` before each of these method annotations:
|
||||
|
||||
Line 76 — `inviteTeamMember`:
|
||||
```java
|
||||
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
|
||||
@PostMapping("/team/invite")
|
||||
```
|
||||
|
||||
Line 82 — `removeTeamMember`:
|
||||
```java
|
||||
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
|
||||
@DeleteMapping("/team/{userId}")
|
||||
```
|
||||
|
||||
Line 88 — `changeTeamMemberRole`:
|
||||
```java
|
||||
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
|
||||
@PatchMapping("/team/{userId}/role")
|
||||
```
|
||||
|
||||
Line 114 — `resetTeamMemberPassword`:
|
||||
```java
|
||||
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
|
||||
@PostMapping("/team/{userId}/password")
|
||||
```
|
||||
|
||||
Line 176 — `resetTeamMemberMfa`:
|
||||
```java
|
||||
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
|
||||
@DeleteMapping("/users/{userId}/mfa")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `@PreAuthorize` to server management endpoints**
|
||||
|
||||
Line 95 — `resetServerAdminPassword`:
|
||||
```java
|
||||
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
|
||||
@PostMapping("/server/admin-password")
|
||||
```
|
||||
|
||||
Line 125 — `restartServer`:
|
||||
```java
|
||||
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
|
||||
@PostMapping("/server/restart")
|
||||
```
|
||||
|
||||
Line 131 — `upgradeServer`:
|
||||
```java
|
||||
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
|
||||
@PostMapping("/server/upgrade")
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `@PreAuthorize` to CA certificate management endpoints**
|
||||
|
||||
Line 289 — `stageCaCert`:
|
||||
```java
|
||||
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
|
||||
@PostMapping("/ca")
|
||||
```
|
||||
|
||||
Line 304 — `activateCaCert`:
|
||||
```java
|
||||
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
|
||||
@PostMapping("/ca/{id}/activate")
|
||||
```
|
||||
|
||||
Line 315 — `deleteCaCert`:
|
||||
```java
|
||||
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
|
||||
@DeleteMapping("/ca/{id}")
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/java/io/cameleer/saas/portal/TenantPortalController.java
|
||||
git commit -m "fix(security): add authorization to tenant portal admin endpoints
|
||||
|
||||
All admin-level endpoints (team invite/remove/role, password resets,
|
||||
server restart/upgrade, CA cert management) were accessible to any
|
||||
org member including viewers. Now require SCOPE_tenant:manage,
|
||||
matching the existing pattern on PATCH /auth-settings."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Remove dead code endpoints (settings duplicate + password without verification)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/io/cameleer/saas/portal/TenantPortalController.java`
|
||||
- Modify: `ui/src/api/tenant-hooks.ts`
|
||||
|
||||
- [ ] **Step 1: Remove `PATCH /settings` endpoint from controller**
|
||||
|
||||
Delete lines 238-242 from `TenantPortalController.java`:
|
||||
|
||||
```java
|
||||
// DELETE THIS ENTIRE BLOCK:
|
||||
@PatchMapping("/settings")
|
||||
public ResponseEntity<Void> updateSettings(@RequestBody Map<String, Object> updates) {
|
||||
portalService.updateTenantSettings(updates);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
```
|
||||
|
||||
This is a duplicate of `PATCH /auth-settings` (line 231-235) which has proper `@PreAuthorize`. The frontend uses `useUpdateTenantAuthSettings` which calls `/auth-settings`.
|
||||
|
||||
- [ ] **Step 2: Remove `POST /password` endpoint from controller**
|
||||
|
||||
Delete lines 107-112 from `TenantPortalController.java`:
|
||||
|
||||
```java
|
||||
// DELETE THIS ENTIRE BLOCK:
|
||||
@PostMapping("/password")
|
||||
public ResponseEntity<Void> changeOwnPassword(@AuthenticationPrincipal Jwt jwt,
|
||||
@RequestBody PasswordChangeRequest body) {
|
||||
portalService.changePassword(jwt.getSubject(), body.password());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
```
|
||||
|
||||
The frontend uses `POST /api/account/password` (via `AccountSettingsPage` + `AccountController`) which correctly requires current password verification.
|
||||
|
||||
- [ ] **Step 3: Remove unused hooks from tenant-hooks.ts**
|
||||
|
||||
Remove the `useChangeOwnPassword` hook (lines 124-128):
|
||||
|
||||
```typescript
|
||||
// DELETE THIS ENTIRE BLOCK:
|
||||
export function useChangeOwnPassword() {
|
||||
return useMutation<void, Error, string>({
|
||||
mutationFn: (password) => api.post('/tenant/password', { password }),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Remove the `useUpdateTenantSettings` mutation hook (lines 152-158):
|
||||
|
||||
```typescript
|
||||
// DELETE THIS ENTIRE BLOCK:
|
||||
export function useUpdateTenantSettings() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<void, Error, Record<string, unknown>>({
|
||||
mutationFn: (updates) => api.patch('/tenant/settings', updates),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'settings'] }),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Note: Keep `useTenantSettings` (the GET query hook at lines 145-150) — the `GET /settings` endpoint returns tenant info (name, slug, tier) and is a legitimate read-only endpoint.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/java/io/cameleer/saas/portal/TenantPortalController.java ui/src/api/tenant-hooks.ts
|
||||
git commit -m "fix(security): remove unsafe dead code endpoints
|
||||
|
||||
Remove PATCH /api/tenant/settings (duplicate of /auth-settings without
|
||||
authorization — any org member could disable MFA) and POST
|
||||
/api/tenant/password (allowed password change without current password
|
||||
verification). Both were dead code — frontend uses the secure
|
||||
alternatives. Also remove corresponding unused hooks."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add authorization to TenantController lookup endpoints
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/io/cameleer/saas/tenant/TenantController.java`
|
||||
|
||||
- [ ] **Step 1: Add `@PreAuthorize` to getById and getBySlug**
|
||||
|
||||
Line 58 — `getById`:
|
||||
```java
|
||||
@GetMapping("/{id}")
|
||||
@PreAuthorize("hasAuthority('SCOPE_platform:admin')")
|
||||
public ResponseEntity<TenantResponse> getById(@PathVariable UUID id) {
|
||||
```
|
||||
|
||||
Line 65 — `getBySlug`:
|
||||
```java
|
||||
@GetMapping("/by-slug/{slug}")
|
||||
@PreAuthorize("hasAuthority('SCOPE_platform:admin')")
|
||||
public ResponseEntity<TenantResponse> getBySlug(@PathVariable String slug) {
|
||||
```
|
||||
|
||||
This matches the existing `@PreAuthorize` on `listAll()` and `create()` in the same controller. These are vendor-only lookup endpoints — no tenant-scoped user should access arbitrary tenant records.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/java/io/cameleer/saas/tenant/TenantController.java
|
||||
git commit -m "fix(security): restrict tenant lookup endpoints to platform admins
|
||||
|
||||
GET /api/tenants/{id} and GET /api/tenants/by-slug/{slug} were
|
||||
accessible to any authenticated user, exposing serverEndpoint,
|
||||
adminEmail, and provisionError. Now require SCOPE_platform:admin,
|
||||
matching listAll() and create() in the same controller."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Verify and build
|
||||
|
||||
- [ ] **Step 1: Run the build to verify all changes compile**
|
||||
|
||||
```bash
|
||||
cd /c/Users/Hendrik/Documents/projects/cameleer-saas && ./mvnw compile -q
|
||||
```
|
||||
|
||||
Expected: BUILD SUCCESS with no compilation errors.
|
||||
|
||||
- [ ] **Step 2: Run frontend type check**
|
||||
|
||||
```bash
|
||||
cd ui && npm run typecheck
|
||||
```
|
||||
|
||||
Expected: No type errors from removed hooks (they were unused).
|
||||
1146
docs/superpowers/plans/2026-04-29-soc2-audit-logging.md
Normal file
1146
docs/superpowers/plans/2026-04-29-soc2-audit-logging.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
# Email Template Polish
|
||||
|
||||
Polish the 4 Logto SMTP connector email templates with branded visuals, playful desert/caravan copy, and extract them from inline Java strings to standalone HTML files.
|
||||
|
||||
## Current State
|
||||
|
||||
All 4 email templates are hardcoded as inline HTML strings in `EmailConnectorService.buildSmtpConfig()` (lines 164-189). They share a minimal structure: centered text "Cameleer" wordmark in `#C6820E`, a one-line message, a large verification code, and a small expiry note. No logo, no footer, no personality.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
- **Tone:** Playful desert/caravan voice matching the sign-in page personality
|
||||
- **Layout:** Structured card — amber header bar, white body with watermark, footer with separator
|
||||
- **Footer:** Help link ("Questions? Contact your administrator") + tagline ("Cameleer — Apache Camel observability")
|
||||
- **Header:** Text-only "Cameleer.io" centered on amber `#C6820E` bar, no logo image
|
||||
- **Watermark:** Cameleer logo at ~7% opacity, oversized, positioned top-right showing compass + cameleer + camel head. For production: pre-faded PNG hosted at a public URL to avoid CSS opacity issues in Outlook desktop.
|
||||
- **Storage:** External HTML template files, not inline Java strings
|
||||
|
||||
## Template Structure
|
||||
|
||||
All 4 templates share the same layout, differing only in subject, headline, body copy, and safety note.
|
||||
|
||||
```
|
||||
+------------------------------------------+
|
||||
| [amber #C6820E header bar] |
|
||||
| Cameleer.io (white) |
|
||||
+------------------------------------------+
|
||||
| |
|
||||
| Headline (bold, 16px) [watermark |
|
||||
| Body text (14px, 1.6lh) logo at |
|
||||
| 7% opacity|
|
||||
| +-------------------+ |
|
||||
| | VERIFICATION CODE | |
|
||||
| | cream pill, amber | |
|
||||
| | border, monospace | |
|
||||
| +-------------------+ |
|
||||
| |
|
||||
| Expiry/safety note (13px, muted) |
|
||||
| |
|
||||
+------------------------------------------+
|
||||
| Questions? Contact your administrator |
|
||||
| Cameleer — Apache Camel observability |
|
||||
+------------------------------------------+
|
||||
```
|
||||
|
||||
## Copy
|
||||
|
||||
| Type | Subject | Headline | Body | Safety note |
|
||||
|------|---------|----------|------|-------------|
|
||||
| Register | Your caravan pass is almost ready | Welcome to the caravan! | Enter this code to verify your email and claim your spot. The dunes wait for no one. | This code expires in 10 minutes. If you didn't request this, you can safely ignore this email — no camels were harmed. |
|
||||
| SignIn | Your Cameleer sign-in code | Back at the oasis already? | Here's your sign-in code. The caravan master is checking credentials. | This code expires in 10 minutes. |
|
||||
| ForgotPassword | Reset your Cameleer password | Lost in the dunes? | No worries — enter this code to reset your password and get back on the trail. | This code expires in 10 minutes. If you didn't request a password reset, you can safely ignore this email. |
|
||||
| Generic | Your Cameleer verification code | Quick checkpoint | Here's your verification code. Just making sure it's really you at the reins. | This code expires in 10 minutes. |
|
||||
|
||||
## Visual Specifications
|
||||
|
||||
- **Font stack:** `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif`
|
||||
- **Card max-width:** 480px, no fixed width (shrinks naturally on mobile)
|
||||
- **Card border:** `1px solid #e8e0d4`
|
||||
- **Header:** `background: #C6820E`, padding 20px 24px, text centered, 22px bold white
|
||||
- **Code pill:** `background: #FDF6EC`, `border: 2px solid #C6820E`, border-radius 8px, padding 16px 32px, 32px bold monospace with 8px letter-spacing in `#C6820E`
|
||||
- **Watermark:** Absolutely positioned `top:-30px; right:-50px`, 320x320px, 7% opacity, clipped by `overflow: hidden` on body container
|
||||
- **Footer separator:** `1px solid #e8e0d4`
|
||||
- **Footer text:** "Questions?" at 12px `#999`, tagline at 11px `#bbb`
|
||||
|
||||
## Responsiveness
|
||||
|
||||
No media queries needed. The single-column layout works from ~280px up:
|
||||
- Card uses `max-width: 480px` with no fixed width
|
||||
- Code pill uses `display: inline-block`, wraps within container
|
||||
- All sizes in px (email clients handle rem/em inconsistently)
|
||||
- Watermark clipped by `overflow: hidden`, never causes horizontal scroll
|
||||
|
||||
## File Structure
|
||||
|
||||
### Template files
|
||||
|
||||
Create 4 HTML template files at `src/main/resources/email-templates/`:
|
||||
|
||||
```
|
||||
src/main/resources/email-templates/
|
||||
register.html
|
||||
sign-in.html
|
||||
forgot-password.html
|
||||
generic.html
|
||||
```
|
||||
|
||||
Each file is a complete HTML email body (inline styles, self-contained). The verification code placeholder uses Logto's `{{code}}` syntax.
|
||||
|
||||
### Watermark image
|
||||
|
||||
Create a pre-faded PNG of the Cameleer logo at 7% opacity on a transparent background. Source the logo from `design-system/assets/cameleer-logo.png` and generate the faded version using ImageMagick or similar (one-time step, committed to the repo).
|
||||
|
||||
Place the image at `src/main/resources/static/platform/assets/email-watermark.png`. Spring Boot serves `/platform/assets/**` as static resources automatically. The template files use a placeholder `{{watermarkUrl}}` that `EmailConnectorService` replaces with `https://<PUBLIC_HOST>/platform/assets/email-watermark.png` at runtime.
|
||||
|
||||
### Java changes
|
||||
|
||||
`EmailConnectorService.buildSmtpConfig()`:
|
||||
- Read each template file from classpath (`src/main/resources/email-templates/*.html`) at startup or on first use
|
||||
- Replace `{{watermarkUrl}}` with the configured public host URL
|
||||
- Pass the HTML content as the `content` field in each Logto template config
|
||||
- Keep subjects in Java (they're short strings, no benefit from externalizing)
|
||||
|
||||
## Email Client Compatibility
|
||||
|
||||
- **Gmail, Apple Mail, Outlook.com:** Full support — opacity, absolute positioning, border-radius all work
|
||||
- **Outlook desktop (Word renderer):** CSS `opacity` is ignored. The pre-faded watermark PNG solves this — the transparency is baked into the image itself, not applied via CSS. Absolute positioning is supported via VML fallback that Outlook generates.
|
||||
- **No CSS classes:** Everything uses inline styles (email clients strip `<style>` blocks inconsistently)
|
||||
- **No external fonts:** System font stack only
|
||||
342
docs/superpowers/specs/2026-04-26-password-reset-mfa-design.md
Normal file
342
docs/superpowers/specs/2026-04-26-password-reset-mfa-design.md
Normal file
@@ -0,0 +1,342 @@
|
||||
# Password Reset & Multi-Factor Authentication Design
|
||||
|
||||
**Date**: 2026-04-26
|
||||
**Status**: Approved
|
||||
**Scope**: Self-service password reset, TOTP MFA with backup codes, per-tenant MFA enforcement
|
||||
|
||||
## Overview
|
||||
|
||||
Add two missing auth capabilities to the Cameleer SaaS platform:
|
||||
|
||||
1. **Self-service password reset** — "Forgot password?" flow in the custom sign-in UI, using Logto's Experience API and the already-configured `ForgotPassword` email template.
|
||||
2. **Multi-factor authentication** — TOTP (authenticator app) as primary factor, backup codes for recovery. Per-tenant enforcement (tenant admins control whether MFA is required for their org) plus optional opt-in for any user.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| MFA methods | TOTP + backup codes | Universal, no extra infra. WebAuthn can be added later. |
|
||||
| MFA policy at Logto level | `UserControlled` | Per-tenant enforcement is application-layer; Logto stays permissive. |
|
||||
| Enforcement mechanism | JWT claim (`mfa_enrolled`) | Stateless, works for both SaaS and cameleer-server, extends existing custom JWT script. |
|
||||
| MFA during password reset | Not required | Email verification code is sufficient proof of identity. Requiring TOTP risks deadlock if user lost both. |
|
||||
| Enrollment location (SaaS) | Tenant portal Settings page | Natural home next to existing password change. |
|
||||
| Enrollment location (server) | Cameleer-server UI (handoff doc) | Regular org members interact with server UI day-to-day. |
|
||||
| MFA requirement storage | `settings` JSONB on `TenantEntity` | No migration needed. |
|
||||
|
||||
## 1. Password Reset Flow
|
||||
|
||||
### UI Changes (`ui/sign-in/src/SignInPage.tsx`)
|
||||
|
||||
Add a "Forgot password?" link below the password field on the sign-in form. New mode `forgotPassword` with two sub-steps:
|
||||
|
||||
1. **Email entry** — user enters their email, clicks "Send code"
|
||||
2. **Code + new password** — 6-digit verification code (reuse existing OTP input pattern from registration) + new password + confirm password
|
||||
|
||||
The "Forgot password?" link is hidden when no email connector is configured — check the same sign-in experience config already fetched at page load.
|
||||
|
||||
### Experience API Flow (`ui/sign-in/src/experience-api.ts`)
|
||||
|
||||
```
|
||||
PUT /api/experience
|
||||
{ interactionEvent: 'ForgotPassword' }
|
||||
|
||||
POST /api/experience/verification/verification-code
|
||||
{ identifier: { type: 'email', value: email }, interactionEvent: 'ForgotPassword' }
|
||||
|
||||
POST /api/experience/verification/verification-code/verify
|
||||
{ identifier: { type: 'email', value: email }, verificationId, code }
|
||||
|
||||
POST /api/experience/identification
|
||||
{ verificationId }
|
||||
|
||||
POST /api/experience/profile
|
||||
{ type: 'password', value: newPassword }
|
||||
|
||||
POST /api/experience/submit
|
||||
-> redirect to sign-in page
|
||||
```
|
||||
|
||||
### Security Notification Email
|
||||
|
||||
After a successful password reset, Logto sends a confirmation redirect — but no security awareness email. The SaaS backend sends a separate **security notification email** to the user's address with:
|
||||
|
||||
- Subject: "Your Cameleer password was reset"
|
||||
- Body: confirms the password was changed, states that **MFA was not required for this change**, and recommends enabling MFA if not already enrolled
|
||||
- Includes a timestamp and "If this wasn't you, contact your administrator immediately"
|
||||
|
||||
This is triggered by the custom sign-in UI after the Experience API `submit` succeeds — a `POST /api/password-reset-notification` call to the SaaS backend with the user's email. The backend sends the email via the configured SMTP connector. The endpoint is unauthenticated (the user hasn't signed in yet) but rate-limited and accepts only email addresses that exist in Logto.
|
||||
|
||||
### Backend Changes
|
||||
|
||||
Minimal. The password reset flow itself is entirely between the custom sign-in UI and Logto's Experience API. The `ForgotPassword` email template is already configured in `EmailConnectorService`. The only new backend work is the security notification endpoint above.
|
||||
|
||||
## 2. MFA Configuration (Logto Bootstrap)
|
||||
|
||||
### Bootstrap Changes (`docker/logto-bootstrap.sh`)
|
||||
|
||||
Add MFA configuration to the existing Phase 8c sign-in experience patch:
|
||||
|
||||
```json
|
||||
{
|
||||
"mfa": {
|
||||
"factors": ["Totp", "BackupCode"],
|
||||
"policy": "UserControlled"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is one additional field in the existing `PATCH /api/sign-in-exp` call — not a separate bootstrap phase.
|
||||
|
||||
### Custom JWT Script Extension (Phase 7b)
|
||||
|
||||
Extend the existing `getCustomJwtClaims` script to include an `mfa_enrolled` claim. Logto's custom JWT context provides `context.user.mfaVerificationFactors` — an array of the user's enrolled MFA factors.
|
||||
|
||||
```javascript
|
||||
const getCustomJwtClaims = async ({ token, context, environmentVariables }) => {
|
||||
const roleMap = { owner: "server:admin", operator: "server:operator", viewer: "server:viewer" };
|
||||
const roles = new Set();
|
||||
if (context?.user?.organizationRoles) {
|
||||
for (const orgRole of context.user.organizationRoles) {
|
||||
const mapped = roleMap[orgRole.roleName];
|
||||
if (mapped) roles.add(mapped);
|
||||
}
|
||||
}
|
||||
if (context?.user?.roles) {
|
||||
for (const role of context.user.roles) {
|
||||
if (role.name === "saas-vendor") roles.add("server:admin");
|
||||
}
|
||||
}
|
||||
|
||||
// MFA enrollment status
|
||||
const mfaFactors = context?.user?.mfaVerificationFactors || [];
|
||||
const mfaEnrolled = mfaFactors.some(f => f.type === 'Totp');
|
||||
|
||||
const claims = {};
|
||||
if (roles.size > 0) claims.roles = [...roles];
|
||||
claims.mfa_enrolled = mfaEnrolled;
|
||||
return claims;
|
||||
};
|
||||
```
|
||||
|
||||
Every JWT now carries `mfa_enrolled: true/false`, readable by both SaaS backend and cameleer-server.
|
||||
|
||||
## 3. MFA Verification at Sign-in
|
||||
|
||||
### How Logto Signals MFA Is Needed
|
||||
|
||||
After password verification + identification, `POST /api/experience/submit` returns an error with code `mfa_factor_not_satisfied` instead of a redirect URL. The custom sign-in UI intercepts this and prompts for TOTP.
|
||||
|
||||
### UI Changes (`ui/sign-in/src/SignInPage.tsx`)
|
||||
|
||||
New mode `mfaVerify` — shown when submit returns `mfa_factor_not_satisfied`:
|
||||
|
||||
- 6-digit TOTP code input (reuse existing OTP input pattern)
|
||||
- **Prominent backup code fallback** — not a subtle link. Below the TOTP input, a clearly visible secondary action: "Lost your device? Use a backup code" styled as a distinct button or card (not a footnote-sized link). Users in a panic skip small text. The backup code view shows a single text input with clear instructions: "Enter one of your 10 backup codes"
|
||||
|
||||
### Experience API Flow
|
||||
|
||||
**TOTP verification:**
|
||||
|
||||
```
|
||||
POST /api/experience/verification/totp/verify
|
||||
{ code: '123456' }
|
||||
-> returns { verificationId }
|
||||
|
||||
POST /api/experience/identification
|
||||
{ verificationId }
|
||||
|
||||
POST /api/experience/submit
|
||||
-> redirectTo
|
||||
```
|
||||
|
||||
**Backup code verification:**
|
||||
|
||||
```
|
||||
POST /api/experience/verification/backup-code/verify
|
||||
{ code: 'abc123def456' }
|
||||
-> returns { verificationId }
|
||||
|
||||
POST /api/experience/identification
|
||||
{ verificationId }
|
||||
|
||||
POST /api/experience/submit
|
||||
-> redirectTo
|
||||
```
|
||||
|
||||
### Modified Sign-in Flow
|
||||
|
||||
The existing `signIn()` function (`init -> verifyPassword -> identify -> submit`) becomes:
|
||||
|
||||
1. `init -> verifyPassword -> identify -> submit`
|
||||
2. If submit returns `mfa_factor_not_satisfied` -> UI shows TOTP input
|
||||
3. User enters code -> `verify TOTP -> identify -> submit -> redirect`
|
||||
|
||||
### Error Handling
|
||||
|
||||
| Error | Message |
|
||||
|-------|---------|
|
||||
| Invalid TOTP code | "Invalid code, please try again" |
|
||||
| Backup code already used | "This backup code has already been used" |
|
||||
| All backup codes exhausted | "No backup codes remaining. Contact your administrator." |
|
||||
|
||||
## 4. MFA Enrollment (Tenant Portal)
|
||||
|
||||
### Settings Page UI (`ui/src/pages/SettingsPage.tsx`)
|
||||
|
||||
Add an "MFA" section to the existing Settings page (next to password change).
|
||||
|
||||
**Not enrolled state:**
|
||||
|
||||
- Description: "Protect your account with two-factor authentication"
|
||||
- "Set up authenticator app" button
|
||||
- Enrollment flow:
|
||||
1. Backend generates TOTP secret -> returns secret + QR code URI
|
||||
2. UI renders QR code (lightweight library, e.g., `qrcode.react`)
|
||||
3. User scans with authenticator app, enters 6-digit verification code to confirm
|
||||
4. On success -> backend generates 10 backup codes, displays them once
|
||||
5. User must copy/download before dismissing (checkbox: "I've saved these codes")
|
||||
6. Force token refresh so `mfa_enrolled` JWT claim updates immediately
|
||||
|
||||
**Already enrolled state:**
|
||||
|
||||
- "Authenticator app configured" with green status indicator
|
||||
- "Regenerate backup codes" button (new set of 10, invalidates old)
|
||||
- "Remove MFA" button with confirmation dialog + password re-entry
|
||||
|
||||
### Backend Endpoints (new, in `TenantPortalController`)
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/tenant/mfa/status` | Check current user's MFA enrollment status |
|
||||
| `POST` | `/api/tenant/mfa/totp/setup` | Generate TOTP secret via Logto Management API -> return secret + QR code |
|
||||
| `POST` | `/api/tenant/mfa/totp/verify` | Verify TOTP code + bind to user via Management API |
|
||||
| `POST` | `/api/tenant/mfa/backup-codes` | Generate backup codes via Management API |
|
||||
| `DELETE` | `/api/tenant/mfa/totp` | Remove TOTP factor (requires password confirmation) |
|
||||
|
||||
All proxy to Logto's Management API via `LogtoManagementClient`:
|
||||
|
||||
- `POST /api/users/{userId}/mfa-verifications` — create TOTP or backup codes
|
||||
- `GET /api/users/{userId}/mfa-verifications` — list enrolled factors
|
||||
- `DELETE /api/users/{userId}/mfa-verifications/{verificationId}` — remove a factor
|
||||
|
||||
### Team Management (`ui/src/pages/TeamPage.tsx`)
|
||||
|
||||
Add a "Reset MFA" action for team members — allows tenant owners/operators to remove MFA enrollment for a locked-out user. Calls `DELETE /api/users/{userId}/mfa-verifications/{verificationId}` via a new backend endpoint:
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `DELETE` | `/api/tenant/users/{userId}/mfa` | Remove all MFA factors for a team member |
|
||||
|
||||
## 5. Per-Tenant MFA Enforcement
|
||||
|
||||
### Tenant Setting
|
||||
|
||||
Stored in the existing `settings` JSONB column on `TenantEntity`:
|
||||
|
||||
```json
|
||||
{ "mfaRequired": true }
|
||||
```
|
||||
|
||||
Default is absent/`false` — MFA is optional, users can still opt in.
|
||||
|
||||
### Tenant Admin Toggle (`ui/src/pages/SettingsPage.tsx`)
|
||||
|
||||
Visible only to tenant admins (owner/operator role):
|
||||
|
||||
- Toggle: "Require MFA for all organization members"
|
||||
- Enabling shows confirmation: "Members without MFA will be prompted to enroll on their next sign-in. Are you sure?"
|
||||
- Calls `PATCH /api/tenant/settings` with `{ "mfaRequired": true/false }`
|
||||
|
||||
### Backend Enforcement
|
||||
|
||||
A Spring Security filter that runs after JWT validation:
|
||||
|
||||
1. Extract `mfa_enrolled` claim from JWT
|
||||
2. Look up the user's tenant -> check `settings.mfaRequired`
|
||||
3. If tenant requires MFA and `mfa_enrolled` is `false`:
|
||||
- Return `403` with a **distinct application error code** to avoid collision with generic Spring Security 403s (which get caught by global error handlers):
|
||||
```json
|
||||
{
|
||||
"error": "APP_MFA_REQUIRED",
|
||||
"code": "mfa_enrollment_required",
|
||||
"message": "Your organization requires multi-factor authentication"
|
||||
}
|
||||
```
|
||||
- The response includes a custom header `X-Cameleer-Error: APP_MFA_REQUIRED` as a belt-and-suspenders signal — frontends can check either the body or the header
|
||||
- **Exempt paths**: `/api/tenant/mfa/*` (enrollment endpoints), `/api/config`, `/api/me`
|
||||
4. Frontend intercepts 403 responses and checks for `APP_MFA_REQUIRED` specifically (not all 403s) -> redirects to Settings page with MFA section highlighted and an inline banner explaining the requirement
|
||||
|
||||
### Cameleer-Server Enforcement (via handoff doc)
|
||||
|
||||
- Read `mfa_enrolled` from JWT (same token, same parsing as `roles` claim)
|
||||
- Query tenant MFA policy: SaaS exposes `GET /api/tenant/{slug}/mfa-policy` returning `{ "mfaRequired": true/false }` — server caches with 5-minute TTL
|
||||
- Same enforcement logic: if required and not enrolled -> 403 with `APP_MFA_REQUIRED` error code (same format as SaaS backend)
|
||||
|
||||
### Token Refresh After Enrollment
|
||||
|
||||
When a user completes MFA enrollment in the tenant portal, the frontend calls `getAccessToken()` from the Logto SDK with a forced refresh. This gets a new JWT with `mfa_enrolled: true`, and subsequent requests pass enforcement.
|
||||
|
||||
### Edge Case: Admin Enables Enforcement While Users Are Active
|
||||
|
||||
Existing sessions have `mfa_enrolled: false`. On next API call, backend returns 403. Frontend redirects to enrollment. After enrolling + token refresh, they're unblocked. No forced logout needed.
|
||||
|
||||
## 6. Backup Codes
|
||||
|
||||
### Generation
|
||||
|
||||
10 codes generated by Logto's Management API (`POST /api/users/{userId}/mfa-verifications` with `{ type: "BackupCode" }`). Logto generates the codes.
|
||||
|
||||
### Display
|
||||
|
||||
Shown exactly once, immediately after TOTP enrollment succeeds:
|
||||
|
||||
- Grid of 10 codes in monospace font
|
||||
- "Copy all" button
|
||||
- "Download as .txt" button
|
||||
- Dialog cannot be dismissed until user checks "I've saved my backup codes"
|
||||
|
||||
### Regeneration
|
||||
|
||||
Available in Settings page for enrolled users. "Regenerate backup codes" creates a new set of 10, invalidates all previous codes. Same display/acknowledgment flow.
|
||||
|
||||
### Low-Code Warning
|
||||
|
||||
When a user signs in with a backup code and fewer than 3 remain, the sign-in UI shows a warning after successful auth: "You have N backup codes remaining."
|
||||
|
||||
### Exhaustion Recovery
|
||||
|
||||
If all backup codes are used and the user can't access their authenticator app, a tenant admin removes their MFA via the "Reset MFA" action on the Team page (`DELETE /api/tenant/users/{userId}/mfa`).
|
||||
|
||||
## 7. Server Handoff Document
|
||||
|
||||
A separate spec file to be delivered alongside this design, covering:
|
||||
|
||||
1. **API contract** — Logto Management API endpoints for MFA enrollment/unenrollment (exact URLs, request/response payloads, M2M token scope requirements)
|
||||
2. **UX requirements** — QR code display, backup code download, verification step, enrollment/removal flows
|
||||
3. **Enforcement model** — reading `mfa_enrolled` from JWT, querying `GET /api/tenant/{slug}/mfa-policy` for tenant requirement, 403 response format
|
||||
4. **Error states** — already enrolled, invalid TOTP, backup code exhaustion, removal while enforcement is active
|
||||
|
||||
## Summary of Changes by Component
|
||||
|
||||
| Component | Changes |
|
||||
|-----------|---------|
|
||||
| `ui/sign-in/src/SignInPage.tsx` | New modes: `forgotPassword`, `mfaVerify` (with prominent backup code fallback) |
|
||||
| `ui/sign-in/src/experience-api.ts` | New functions: `forgotPassword()`, `verifyTotp()`, `verifyBackupCode()`, `notifyPasswordReset()` |
|
||||
| `ui/src/pages/SettingsPage.tsx` | MFA enrollment section, MFA requirement toggle, `APP_MFA_REQUIRED` 403 interceptor |
|
||||
| `ui/src/pages/TeamPage.tsx` | "Reset MFA" action for team members |
|
||||
| `docker/logto-bootstrap.sh` | MFA factors in Phase 8c, `mfa_enrolled` claim in Phase 7b |
|
||||
| `TenantPortalController.java` | MFA endpoints (setup, verify, backup codes, status, remove) |
|
||||
| `TenantPortalService.java` | MFA business logic proxying to `LogtoManagementClient` |
|
||||
| `LogtoManagementClient.java` | New methods for MFA Management API calls |
|
||||
| `SecurityConfig.java` or new filter | MFA enforcement filter (`APP_MFA_REQUIRED` error code + `X-Cameleer-Error` header) |
|
||||
| New: password reset notification | `POST /api/password-reset-notification` — security email after reset (unauthenticated, rate-limited) |
|
||||
| New: server handoff doc | MFA API contract + UX + enforcement spec for cameleer-server team |
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- WebAuthn / passkey support (future addition)
|
||||
- SMS-based MFA
|
||||
- Password policies beyond Logto defaults (complexity, history, expiry)
|
||||
- Passwordless authentication
|
||||
- Social login / OAuth providers
|
||||
- Account lockout configuration
|
||||
152
docs/superpowers/specs/2026-04-26-server-mfa-handoff.md
Normal file
152
docs/superpowers/specs/2026-04-26-server-mfa-handoff.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# Cameleer-Server MFA Handoff Document
|
||||
|
||||
**Date:** 2026-04-26
|
||||
**For:** cameleer-server team
|
||||
**Context:** The SaaS platform now supports TOTP MFA with backup codes. This document specifies what the server team needs to implement for MFA enrollment in the server UI.
|
||||
|
||||
## 1. JWT Claim: `mfa_enrolled`
|
||||
|
||||
Every access token now includes an `mfa_enrolled: boolean` claim, set by the Logto Custom JWT script. The server already parses JWT claims for the `roles` field — `mfa_enrolled` works identically.
|
||||
|
||||
**Example decoded JWT payload:**
|
||||
|
||||
```json
|
||||
{
|
||||
"sub": "user-id-123",
|
||||
"roles": ["server:admin"],
|
||||
"mfa_enrolled": true,
|
||||
"aud": "https://api.cameleer.local",
|
||||
"scope": "tenant:manage tenant:view"
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Enforcement
|
||||
|
||||
### When to enforce
|
||||
|
||||
Check whether the tenant requires MFA:
|
||||
|
||||
- **Endpoint:** `GET /platform/api/tenant/{slug}/mfa-policy`
|
||||
- **Auth:** M2M token (same as existing server -> SaaS API calls)
|
||||
- **Response:** `{ "mfaRequired": true/false }`
|
||||
- **Cache:** 5-minute TTL recommended
|
||||
|
||||
### How to enforce
|
||||
|
||||
On authenticated requests, if `mfaRequired` is `true` and the JWT `mfa_enrolled` claim is `false`:
|
||||
|
||||
**Response:**
|
||||
|
||||
```
|
||||
HTTP 403
|
||||
X-Cameleer-Error: APP_MFA_REQUIRED
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"error": "APP_MFA_REQUIRED",
|
||||
"code": "mfa_enrollment_required",
|
||||
"message": "Your organization requires multi-factor authentication"
|
||||
}
|
||||
```
|
||||
|
||||
**Exempt paths:** MFA enrollment endpoints (below), health checks, public assets.
|
||||
|
||||
The server UI should intercept 403 responses with `X-Cameleer-Error: APP_MFA_REQUIRED` and redirect to the MFA enrollment page.
|
||||
|
||||
## 3. MFA Enrollment API
|
||||
|
||||
The server needs to call Logto's Management API to manage MFA for users. Use the existing M2M token for authentication.
|
||||
|
||||
### Get MFA status
|
||||
|
||||
```
|
||||
GET https://{logto-endpoint}/api/users/{userId}/mfa-verifications
|
||||
Authorization: Bearer {m2m_token}
|
||||
|
||||
Response: [
|
||||
{ "id": "ver-123", "type": "Totp", "createdAt": "..." },
|
||||
{ "id": "ver-456", "type": "BackupCode", "createdAt": "..." }
|
||||
]
|
||||
```
|
||||
|
||||
### Generate TOTP secret
|
||||
|
||||
Generate a 20-byte random secret, Base32-encode it, and create a QR code URI:
|
||||
|
||||
```
|
||||
otpauth://totp/Cameleer:{userEmail}?secret={base32Secret}&issuer=Cameleer
|
||||
```
|
||||
|
||||
Show the QR code to the user. After they scan and provide a 6-digit code, verify it server-side using the TOTP algorithm (RFC 6238, HMAC-SHA1, 30-second window, +/-1 step drift).
|
||||
|
||||
### Bind TOTP to user
|
||||
|
||||
After successful verification:
|
||||
|
||||
```
|
||||
POST https://{logto-endpoint}/api/users/{userId}/mfa-verifications
|
||||
Authorization: Bearer {m2m_token}
|
||||
Content-Type: application/json
|
||||
|
||||
{ "type": "Totp", "secret": "{base32Secret}" }
|
||||
|
||||
Response: { "type": "Totp", "secret": "...", "secretQrCode": "..." }
|
||||
```
|
||||
|
||||
### Generate backup codes
|
||||
|
||||
After TOTP is bound:
|
||||
|
||||
```
|
||||
POST https://{logto-endpoint}/api/users/{userId}/mfa-verifications
|
||||
Authorization: Bearer {m2m_token}
|
||||
Content-Type: application/json
|
||||
|
||||
{ "type": "BackupCode" }
|
||||
|
||||
Response: { "type": "BackupCode", "codes": ["abc123", "def456", ...] }
|
||||
```
|
||||
|
||||
Display the 10 codes once. User must acknowledge saving them before dismissing.
|
||||
|
||||
### Remove MFA (admin action)
|
||||
|
||||
```
|
||||
DELETE https://{logto-endpoint}/api/users/{userId}/mfa-verifications/{verificationId}
|
||||
Authorization: Bearer {m2m_token}
|
||||
```
|
||||
|
||||
Remove all verifications (TOTP + BackupCode) to fully reset MFA for a user.
|
||||
|
||||
## 4. UX Requirements
|
||||
|
||||
### Enrollment flow
|
||||
|
||||
1. User clicks "Set up MFA" in settings
|
||||
2. Show QR code (200x200px) with the TOTP secret URI
|
||||
3. User scans with authenticator app
|
||||
4. User enters 6-digit verification code
|
||||
5. On success -> show 10 backup codes in a 2-column monospace grid
|
||||
6. "Copy all" and "Download .txt" buttons
|
||||
7. Checkbox: "I've saved my backup codes" — must be checked before dismissing
|
||||
8. After dismissal, force token refresh to get `mfa_enrolled: true` in JWT
|
||||
|
||||
### Enrolled state
|
||||
|
||||
- Show "Authenticator app configured" with green status badge
|
||||
- "Regenerate backup codes" button
|
||||
- "Remove MFA" button with confirmation dialog
|
||||
|
||||
### Backup code fallback (sign-in)
|
||||
|
||||
This is handled by the SaaS custom sign-in UI, not the server. No server changes needed for the sign-in flow.
|
||||
|
||||
## 5. Error States
|
||||
|
||||
| Scenario | Response |
|
||||
|----------|----------|
|
||||
| User already has TOTP enrolled | 422 — "TOTP already configured" |
|
||||
| Invalid TOTP code during setup | Show error, let user retry |
|
||||
| Backup code already used (sign-in) | Handled by SaaS sign-in UI |
|
||||
| All backup codes exhausted | Admin removes MFA via team page |
|
||||
| Remove MFA while enforcement active | User will be prompted to re-enroll on next request |
|
||||
381
docs/superpowers/specs/2026-04-27-passkey-mfa-design.md
Normal file
381
docs/superpowers/specs/2026-04-27-passkey-mfa-design.md
Normal file
@@ -0,0 +1,381 @@
|
||||
# Passkey MFA Design
|
||||
|
||||
**Date:** 2026-04-27
|
||||
**Status:** Implemented
|
||||
**Goal:** Add passkeys (WebAuthn) as an MFA factor alongside existing TOTP, with a long-term path toward passwordless sign-in.
|
||||
|
||||
## Motivation
|
||||
|
||||
Passkeys provide a phishing-resistant, convenient alternative to TOTP codes. Short-term, they serve as an additional MFA option (fingerprint/Face ID instead of typing a 6-digit code). Long-term, they enable passwordless sign-in once adoption is sufficient.
|
||||
|
||||
## Approach
|
||||
|
||||
**Logto-native WebAuthn (Approach A).** All WebAuthn ceremony handling, credential storage, and factor management stays in Logto via the Experience API and Management API. The SaaS backend adds hierarchical policy enforcement and exposes Logto's credential data through its own API. No custom WebAuthn libraries, no credential mirroring — we work within what Logto provides.
|
||||
|
||||
## 1. Policy Model
|
||||
|
||||
### Two Independent Policy Domains
|
||||
|
||||
Vendor and tenant policies are **independent scopes**, not a hierarchy. No inheritance, no floor.
|
||||
|
||||
| Policy | Scope | Who it affects | Who sets it |
|
||||
|--------|-------|---------------|-------------|
|
||||
| **Vendor auth policy** | SaaS platform logins (`/platform/*`) | Tenant admins accessing the management plane | Platform admin (vendor) |
|
||||
| **Tenant auth policy** | Tenant server/dashboard logins | Org users accessing the tenant's cameleer-server | Tenant admin |
|
||||
|
||||
**Example scenario:** Vendor requires passkey for tenant admin platform logins. A tenant admin sets `mfa_mode: off` for their org. Result: that tenant admin must use a passkey to access the SaaS platform, but their end users sign into the cameleer dashboard with just username/password.
|
||||
|
||||
### Policy Settings
|
||||
|
||||
Each policy domain has three settings:
|
||||
|
||||
| Setting | Values | Default | Meaning |
|
||||
|---------|--------|---------|---------|
|
||||
| `mfa_mode` | `off`, `optional`, `required` | `off` | Whether MFA is required for sign-in |
|
||||
| `passkey_enabled` | `true`, `false` | `false` | Whether passkeys are available as a factor |
|
||||
| `passkey_mode` | `optional`, `preferred`, `required` | `optional` | Passkey enforcement level (only relevant when `passkey_enabled: true`) |
|
||||
|
||||
- `passkey_mode: optional` — User can choose passkey or TOTP
|
||||
- `passkey_mode: preferred` — Passkey prompted first, TOTP available as fallback
|
||||
- `passkey_mode: required` — Passkey is the only accepted MFA factor
|
||||
|
||||
### Storage
|
||||
|
||||
- **Vendor policy:** `vendor_auth_policy` single-row table with a management endpoint for runtime updates. Changes survive restarts without redeployment.
|
||||
- **Tenant policy:** Existing `settings` JSONB column on `tenants` table, extending the current `mfaRequired` key. New keys: `mfaMode`, `passkeyEnabled`, `passkeyMode`. The existing `mfaRequired: true` maps to `mfaMode: "required"` (backward-compatible).
|
||||
|
||||
### Enforcement
|
||||
|
||||
- **`MfaEnforcementFilter`** expands to cover two route groups:
|
||||
- `/api/vendor/**`, `/api/portal/**` — Checked against vendor auth policy
|
||||
- `/api/tenant/**` — Checked against tenant auth policy (from tenant `settings`)
|
||||
- Filter reads JWT claims `mfa_enrolled` and `passkey_enrolled` to determine user's factor status
|
||||
- Error codes returned:
|
||||
- `MFA_REQUIRED` — User must enroll in some MFA factor
|
||||
- `PASSKEY_REQUIRED` — Passkey specifically required by policy
|
||||
- Frontend uses these error codes to route users to the correct enrollment flow
|
||||
|
||||
## 2. Passkey Flows
|
||||
|
||||
### 2.1 Registration (Three Entry Points)
|
||||
|
||||
All three entry points use the same underlying WebAuthn registration ceremony via Logto Experience API.
|
||||
|
||||
**Settings page (deliberate enrollment):**
|
||||
1. User navigates to MFA settings in platform UI
|
||||
2. Clicks "Add passkey"
|
||||
3. Frontend calls SaaS backend `POST /api/tenant/mfa/webauthn/register/start`
|
||||
4. Backend calls Logto Management API to create a WebAuthn verification for the user, returns WebAuthn registration options (challenge, RP info, user info)
|
||||
5. Browser executes `navigator.credentials.create()` with the options via `@simplewebauthn/browser`
|
||||
6. Frontend sends the credential attestation to `POST /api/tenant/mfa/webauthn/register/complete`
|
||||
7. Backend forwards attestation to Logto Management API to complete registration, passkey appears in device list
|
||||
|
||||
**Post-sign-in nudge (organic adoption):**
|
||||
1. User signs in with password + TOTP (or password only if MFA optional)
|
||||
2. If passkey is enabled for the policy domain and user has no passkeys enrolled, show a dismissible banner: "Sign in faster with a passkey"
|
||||
3. User clicks "Set up" → same registration ceremony as settings page
|
||||
4. User clicks "Not now" → dismiss for 30 days (stored in `localStorage`)
|
||||
|
||||
**Onboarding wizard (new users):**
|
||||
1. New step after account creation, before tenant provisioning: "Secure your account with a passkey"
|
||||
2. Same registration ceremony
|
||||
3. "Skip" button available — passkey is not forced during onboarding regardless of policy (user hasn't joined an org yet, so no policy applies)
|
||||
|
||||
### 2.2 Authentication (Sign-In Flow)
|
||||
|
||||
1. User enters email + password → Logto validates credentials
|
||||
2. If MFA required (per effective policy), check enrolled factors:
|
||||
- **Passkey + TOTP enrolled:** Show last-used method by default. "Use [other method] instead" link below.
|
||||
- **Passkey only:** WebAuthn assertion prompt
|
||||
- **TOTP only:** Existing TOTP code input (unchanged)
|
||||
- **Neither enrolled, MFA required:** Redirect to enrollment flow
|
||||
3. Smart default: read `mfa_method_preference` from JWT custom data claim. Updated on each successful verification. Sign-in UI reads this to decide which prompt to show first.
|
||||
4. On successful factor verification → Logto completes session, issues token with `mfa_enrolled: true` and `passkey_enrolled: true`
|
||||
|
||||
### 2.3 WebAuthn Ceremony Details
|
||||
|
||||
**Registration via settings page (Management API — user is already signed in):**
|
||||
```
|
||||
1. Frontend → SaaS backend: POST /api/tenant/mfa/webauthn/register/start
|
||||
2. SaaS backend → Logto Management API: POST /api/users/{userId}/mfa-verifications
|
||||
(type: "WebAuthn") → returns registration options (challenge, RP, user info)
|
||||
3. SaaS backend → Frontend: registration options
|
||||
4. Browser: navigator.credentials.create(options) → attestation response
|
||||
5. Frontend → SaaS backend: POST /api/tenant/mfa/webauthn/register/complete
|
||||
6. SaaS backend → Logto Management API: complete verification with attestation
|
||||
```
|
||||
|
||||
**Registration during sign-in (Experience API — user is mid-authentication):**
|
||||
```
|
||||
1. POST /api/experience/verification/web-authn/registration → get creation options
|
||||
2. Browser: navigator.credentials.create(options)
|
||||
3. POST /api/experience/verification/web-authn/registration/verify → send attestation
|
||||
4. POST /api/experience/identification → identify user
|
||||
5. POST /api/experience/submit → complete
|
||||
```
|
||||
|
||||
**Assertion during sign-in (Experience API — MFA step after password):**
|
||||
```
|
||||
1. POST /api/experience/verification/web-authn/authentication → get request options
|
||||
2. Browser: navigator.credentials.get(options)
|
||||
3. POST /api/experience/verification/web-authn/authentication/verify → send assertion
|
||||
Returns: verificationId
|
||||
4. POST /api/experience/identification → identify user
|
||||
5. POST /api/experience/submit → complete, returns redirectTo
|
||||
```
|
||||
|
||||
### 2.4 Device Management (Settings UI)
|
||||
|
||||
Users can manage their registered passkeys from the MFA settings page:
|
||||
|
||||
- **List:** Show all registered WebAuthn credentials — name (user-settable), user-agent from registration, creation date
|
||||
- **Rename:** Update credential name via Logto Management API
|
||||
- **Delete:** Remove credential via Management API. If it's the last passkey and passkey is required, block deletion.
|
||||
|
||||
**Logto metadata available per credential:**
|
||||
- `id` — credential identifier
|
||||
- `type` — `"WebAuthn"`
|
||||
- `name` — user-settable display name (nullable)
|
||||
- `agent` — user-agent string captured at registration time
|
||||
- `createdAt` — timestamp
|
||||
|
||||
**Not available from Logto:** `lastUsedAt` — Logto does not track last-used date. The UI will not show this field.
|
||||
|
||||
## 3. Backend Changes
|
||||
|
||||
### 3.1 Database Migration
|
||||
|
||||
**New table: `vendor_auth_policy`** (single-row config)
|
||||
|
||||
| Column | Type | Default | Purpose |
|
||||
|--------|------|---------|---------|
|
||||
| `id` | INTEGER | 1 | Single-row constraint |
|
||||
| `mfa_mode` | VARCHAR(10) | `'off'` | `off`, `optional`, `required` |
|
||||
| `passkey_enabled` | BOOLEAN | `false` | Whether passkeys available |
|
||||
| `passkey_mode` | VARCHAR(10) | `'optional'` | `optional`, `preferred`, `required` |
|
||||
| `updated_at` | TIMESTAMP | now() | Last update |
|
||||
|
||||
**Tenant settings JSONB extension:** Add new keys to the whitelist in `TenantPortalService.updateTenantSettings()`:
|
||||
|
||||
| Key | Type | Default | Purpose |
|
||||
|-----|------|---------|---------|
|
||||
| `mfaMode` | string | `"off"` | Replaces/supersedes `mfaRequired` |
|
||||
| `passkeyEnabled` | boolean | `false` | Whether passkeys available for tenant users |
|
||||
| `passkeyMode` | string | `"optional"` | Passkey enforcement level |
|
||||
|
||||
**Backward compatibility:** `mfaRequired: true` treated as `mfaMode: "required"`. The filter checks both: if `mfaMode` is present, use it; otherwise fall back to `mfaRequired`.
|
||||
|
||||
### 3.2 MfaEnforcementFilter Changes
|
||||
|
||||
Current behavior: only intercepts `/api/tenant/**`, checks `mfa_enrolled` claim against tenant `settings.mfaRequired`.
|
||||
|
||||
New behavior:
|
||||
- **Route matching:** Intercept `/api/vendor/**` and `/api/portal/**` in addition to `/api/tenant/**`
|
||||
- **Policy lookup:**
|
||||
- Vendor/portal routes → read `vendor_auth_policy` table
|
||||
- Tenant routes → read tenant `settings` (existing behavior, extended)
|
||||
- **Claim checks:**
|
||||
- `mfa_mode: required` → require `mfa_enrolled == true`
|
||||
- `passkey_mode: required` → require `passkey_enrolled == true`
|
||||
- `passkey_mode: preferred` → same as optional for enforcement (preference handled in sign-in UI)
|
||||
- **Error codes:**
|
||||
- `APP_MFA_REQUIRED` (existing) — MFA enrollment needed
|
||||
- `APP_PASSKEY_REQUIRED` (new) — Passkey specifically required
|
||||
- **Exempt routes:** Add `/api/vendor/auth-policy` and `/api/portal/auth-settings` to exempt list so admins can read/update policy without triggering enforcement
|
||||
|
||||
### 3.3 LogtoManagementClient Additions
|
||||
|
||||
New methods for WebAuthn credential management:
|
||||
|
||||
| Method | Logto Endpoint | Purpose |
|
||||
|--------|---------------|---------|
|
||||
| `listWebAuthnCredentials(userId)` | `GET /api/users/{userId}/mfa-verifications` | List credentials, filter by `type: "WebAuthn"` |
|
||||
| `deleteWebAuthnCredential(userId, verificationId)` | `DELETE /api/users/{userId}/mfa-verifications/{verificationId}` | Remove a passkey |
|
||||
| `renameWebAuthnCredential(userId, verificationId, name)` | `PATCH /api/users/{userId}/mfa-verifications/{verificationId}` | Update display name |
|
||||
| `updateUserCustomData(userId, data)` | `PATCH /api/users/{userId}/custom-data` | Set `mfa_method_preference` |
|
||||
|
||||
### 3.4 Custom JWT Script Update
|
||||
|
||||
Extend the existing `getCustomJwtClaims` function in `docker/logto-bootstrap.sh`:
|
||||
|
||||
```javascript
|
||||
const factors = context.user?.mfaVerificationFactors ?? [];
|
||||
return {
|
||||
roles: /* ... existing role mapping ... */,
|
||||
mfa_enrolled: factors.includes('Totp') || factors.includes('WebAuthn'),
|
||||
passkey_enrolled: factors.includes('WebAuthn'),
|
||||
mfa_method_preference: context.user?.customData?.mfa_method_preference ?? null,
|
||||
};
|
||||
```
|
||||
|
||||
**Changes from current script:**
|
||||
- `mfa_enrolled` now returns `true` for either TOTP or WebAuthn (was TOTP-only)
|
||||
- New `passkey_enrolled` boolean claim
|
||||
- New `mfa_method_preference` claim from user custom data
|
||||
|
||||
### 3.5 API Endpoints
|
||||
|
||||
**Vendor auth policy (platform:admin only):**
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/vendor/auth-policy` | Read current vendor auth policy |
|
||||
| `PUT` | `/api/vendor/auth-policy` | Update vendor auth policy |
|
||||
|
||||
**Tenant auth settings (tenant:manage scope):**
|
||||
|
||||
Extend existing `TenantPortalController`:
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/tenant/auth-settings` | Read tenant auth policy |
|
||||
| `PUT` | `/api/tenant/auth-settings` | Update tenant auth policy |
|
||||
|
||||
**Passkey management (authenticated users):**
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/tenant/mfa/webauthn` | List user's passkey credentials |
|
||||
| `POST` | `/api/tenant/mfa/webauthn/register/start` | Initiate WebAuthn registration |
|
||||
| `POST` | `/api/tenant/mfa/webauthn/register/complete` | Complete WebAuthn registration |
|
||||
| `PATCH` | `/api/tenant/mfa/webauthn/{id}/name` | Rename a passkey |
|
||||
| `DELETE` | `/api/tenant/mfa/webauthn/{id}` | Delete a passkey |
|
||||
|
||||
**Public config extension:**
|
||||
|
||||
`GET /api/config` response adds:
|
||||
```json
|
||||
{
|
||||
"vendorAuthPolicy": { "mfaMode": "...", "passkeyEnabled": true, "passkeyMode": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
`GET /{slug}/mfa-policy` response extends to:
|
||||
```json
|
||||
{
|
||||
"mfaMode": "required",
|
||||
"passkeyEnabled": true,
|
||||
"passkeyMode": "preferred"
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Sign-In UI Changes
|
||||
|
||||
### 4.1 New Modes
|
||||
|
||||
Add to the `Mode` type in `SignInPage.tsx`:
|
||||
|
||||
| Mode | When shown | UI |
|
||||
|------|------------|-----|
|
||||
| `mfaWebauthn` | Passkey verification during sign-in | "Verifying your identity..." with browser passkey prompt |
|
||||
| `mfaMethodPicker` | User has both TOTP and passkey, choosing which to use | Two buttons: "Use passkey" / "Use authenticator code" |
|
||||
|
||||
### 4.2 Experience API Client Additions
|
||||
|
||||
New functions in `experience-api.ts`:
|
||||
|
||||
```typescript
|
||||
// Initiate WebAuthn authentication, returns challenge options
|
||||
async function startWebAuthnAuth(): Promise<WebAuthnOptions>
|
||||
|
||||
// Verify WebAuthn assertion response, returns verificationId
|
||||
async function verifyWebAuthnAuth(credential: PublicKeyCredential): Promise<string>
|
||||
|
||||
// Initiate WebAuthn registration, returns creation options
|
||||
async function startWebAuthnRegistration(): Promise<WebAuthnCreationOptions>
|
||||
|
||||
// Verify WebAuthn registration attestation
|
||||
async function verifyWebAuthnRegistration(credential: PublicKeyCredential): Promise<string>
|
||||
```
|
||||
|
||||
### 4.3 Sign-In Flow Changes
|
||||
|
||||
After password verification, when Logto returns an MFA challenge:
|
||||
|
||||
1. Read effective auth policy from `/api/config` or `/{slug}/mfa-policy`
|
||||
2. Read `mfa_method_preference` from the MFA challenge context (or default to passkey if `passkey_mode: preferred`)
|
||||
3. Route to:
|
||||
- `mfaWebauthn` mode if preference is passkey
|
||||
- `mfaVerify` mode (existing) if preference is TOTP
|
||||
- `mfaMethodPicker` if no preference set and both are enrolled
|
||||
4. Each mode shows a link to switch to the other method
|
||||
|
||||
### 4.4 Post-Sign-In Nudge
|
||||
|
||||
After successful sign-in, if:
|
||||
- Passkey is enabled in the effective policy
|
||||
- User has no passkeys enrolled
|
||||
- Nudge hasn't been dismissed in the last 30 days
|
||||
|
||||
Show a banner at the top of the platform UI (not in the sign-in UI — this happens after redirect back to the SPA).
|
||||
|
||||
### 4.5 WebAuthn Browser API
|
||||
|
||||
Use `@simplewebauthn/browser` for the client-side WebAuthn ceremony:
|
||||
- `startRegistration(options)` — wraps `navigator.credentials.create()`
|
||||
- `startAuthentication(options)` — wraps `navigator.credentials.get()`
|
||||
|
||||
This handles Base64URL encoding, browser compatibility, and platform authenticator detection.
|
||||
|
||||
## 5. Platform UI Changes
|
||||
|
||||
### 5.1 MFA Settings Page Extension
|
||||
|
||||
Add a "Passkeys" section below the existing TOTP section in `SettingsPage.tsx`:
|
||||
|
||||
**When no passkeys enrolled:**
|
||||
- "Add a passkey" button
|
||||
- Brief explanation: "Use your fingerprint, face, or security key to sign in"
|
||||
|
||||
**When passkeys exist:**
|
||||
- Table/list of registered passkeys showing:
|
||||
- Name (editable inline or via edit button)
|
||||
- Device info (parsed from user-agent string — "Chrome on Windows", "Safari on iPhone", etc.)
|
||||
- Created date
|
||||
- Delete button (with confirmation dialog)
|
||||
- "Add another passkey" button
|
||||
|
||||
### 5.2 Auth Policy Management
|
||||
|
||||
**Vendor settings (new page or section):**
|
||||
- Under vendor admin area, "Authentication Policy" section
|
||||
- Three controls matching the policy settings (mfa_mode dropdown, passkey_enabled toggle, passkey_mode dropdown)
|
||||
- Passkey_mode control disabled when passkey_enabled is false
|
||||
|
||||
**Tenant settings (extend existing):**
|
||||
- Current MFA toggle (`mfaRequired`) replaced with richer controls
|
||||
- Same three controls as vendor, scoped to tenant users
|
||||
- Backward-compatible: existing `mfaRequired: true` shown as `mfaMode: required`
|
||||
|
||||
### 5.3 Onboarding Wizard
|
||||
|
||||
Add optional passkey registration step to `OnboardingPage.tsx`:
|
||||
|
||||
- After org creation, before redirect
|
||||
- "Secure your account with a passkey" with setup and skip buttons
|
||||
- Only shown if passkeys are enabled in vendor policy (read from `/api/config`)
|
||||
|
||||
## 6. Passwordless Future Path
|
||||
|
||||
This design enables passwordless sign-in without additional architecture changes:
|
||||
|
||||
1. **`passkey_mode: required`** already means passkey is the only accepted factor
|
||||
2. When Logto supports passkey-as-primary-factor (passwordless sign-in), the sign-in UI adds a "Sign in with passkey" button on the initial screen (before email/password)
|
||||
3. The policy model already supports this: a future `auth_mode: passwordless` setting could skip the password step entirely
|
||||
4. No backend changes needed — the JWT claims and enforcement filter already handle passkey-only scenarios
|
||||
|
||||
This is not part of the current implementation scope but validates that the design doesn't paint us into a corner.
|
||||
|
||||
## 7. Out of Scope
|
||||
|
||||
- Passwordless sign-in (passkey as primary factor, no password) — future work
|
||||
- Conditional UI / autofill-assisted passkey discovery — depends on Logto Experience API support
|
||||
- Cross-device passkey sync management — handled by OS/browser, not our concern
|
||||
- FIDO2 attestation policy (which authenticators to trust) — Logto defaults are sufficient
|
||||
- Rate limiting on WebAuthn ceremonies — handled by Logto
|
||||
|
||||
## 8. Dependencies
|
||||
|
||||
- Logto v1.11.0+ (WebAuthn MFA support) — current `ghcr.io/logto-io/logto:latest` satisfies this
|
||||
- `@simplewebauthn/browser` npm package for sign-in UI and platform UI
|
||||
- No Java WebAuthn libraries needed (all ceremonies handled by Logto)
|
||||
@@ -0,0 +1,299 @@
|
||||
# Vendor Admin Management & Account Settings
|
||||
|
||||
**Date:** 2026-04-27
|
||||
**Status:** Approved
|
||||
|
||||
## Problem
|
||||
|
||||
1. The vendor console supports only a single platform admin (created during bootstrap via `SAAS_ADMIN_USER`). There is no way to add additional administrators.
|
||||
2. There is no page where any authenticated user (vendor or tenant) can manage their own account — display name, password, MFA enrollment.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
- **Flat admin model** — all vendor admins are equal (`platform:admin`), no tiers
|
||||
- **Invite or create** — invite via email when connector is configured, create with temporary credentials when it's not
|
||||
- **Shared account settings** — single `/settings/account` route for any authenticated user (vendor or tenant), reached via user menu in header
|
||||
- **Password change requires current password** — verified via ROPC token exchange against Logto
|
||||
- **Confirmation email** on any successful password change (uses existing `PasswordResetNotificationService`)
|
||||
- **Forgot password link** on sign-in page (flow already implemented, just needs visible link)
|
||||
- **MFA self-service** — TOTP setup/removal, backup codes, passkey list/rename/delete. Passkey registration remains in sign-in flow (Logto Experience API).
|
||||
|
||||
---
|
||||
|
||||
## Section 1: Backend — New `account/` Package
|
||||
|
||||
### AccountService
|
||||
|
||||
New service at `src/main/java/net/siegeln/cameleer/saas/account/AccountService.java`.
|
||||
|
||||
Extracts user-level identity operations from `TenantPortalService`:
|
||||
|
||||
| Method | Extracted from | Logto API |
|
||||
|--------|---------------|-----------|
|
||||
| `getProfile(userId)` | New | `GET /api/users/{userId}` |
|
||||
| `updateDisplayName(userId, name)` | `OnboardingService` | `PATCH /api/users/{userId}` |
|
||||
| `changePassword(userId, currentPassword, newPassword)` | `TenantPortalService.changePassword()` | ROPC verify + `PATCH /api/users/{userId}/password` |
|
||||
| `validatePassword(password)` | Duplicated in 3+ places | Local validation (min 8 chars) |
|
||||
| `getMfaStatus(userId)` | `TenantPortalService.getMfaStatus()` | `GET /api/users/{userId}/mfa-verifications` |
|
||||
| `setupTotp(userId)` | `TenantPortalService.setupTotp()` | `POST /api/users/{userId}/mfa-verifications` |
|
||||
| `verifyTotpCode(secret, code)` | `TenantPortalService.verifyTotpCode()` | Local HMAC-SHA1 computation |
|
||||
| `generateBackupCodes(userId)` | `TenantPortalService.generateBackupCodes()` | `POST /api/users/{userId}/mfa-verifications` |
|
||||
| `removeMfa(userId)` | `TenantPortalService.removeTotp()` | Batch `DELETE /api/users/{userId}/mfa-verifications/{id}` |
|
||||
| `listPasskeys(userId)` | `TenantPortalService.listPasskeys()` | `GET /api/users/{userId}/mfa-verifications` (filtered) |
|
||||
| `renamePasskey(userId, id, name)` | `TenantPortalService.renamePasskey()` | `PATCH /api/users/{userId}/mfa-verifications/{id}` |
|
||||
| `deletePasskey(userId, id)` | `TenantPortalService.deletePasskey()` | `DELETE /api/users/{userId}/mfa-verifications/{id}` |
|
||||
| `setMfaMethodPreference(userId, pref)` | `TenantPortalService.updateMfaMethodPreference()` | `PATCH /api/users/{userId}/custom-data` |
|
||||
|
||||
TOTP helper methods (`computeTotp`, base32 encoding, HMAC-SHA1) move with the service.
|
||||
|
||||
### AccountController
|
||||
|
||||
New controller at `src/main/java/net/siegeln/cameleer/saas/account/AccountController.java`.
|
||||
|
||||
All endpoints require `authenticated()` (any logged-in user, no scope check). User ID extracted from JWT `sub` claim.
|
||||
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `GET /api/account/profile` | GET | Own display name + email |
|
||||
| `PATCH /api/account/profile` | PATCH | Update display name |
|
||||
| `POST /api/account/password` | POST | Change password (current + new) |
|
||||
| `GET /api/account/mfa/status` | GET | MFA enrollment status |
|
||||
| `POST /api/account/mfa/totp/setup` | POST | Start TOTP enrollment |
|
||||
| `POST /api/account/mfa/totp/verify` | POST | Verify TOTP code |
|
||||
| `POST /api/account/mfa/backup-codes` | POST | Generate backup codes |
|
||||
| `DELETE /api/account/mfa/totp` | DELETE | Remove TOTP |
|
||||
| `GET /api/account/mfa/webauthn` | GET | List passkeys |
|
||||
| `PATCH /api/account/mfa/webauthn/{id}/name` | PATCH | Rename passkey |
|
||||
| `DELETE /api/account/mfa/webauthn/{id}` | DELETE | Delete passkey |
|
||||
| `POST /api/account/mfa/method-preference` | POST | Set MFA preference |
|
||||
|
||||
### SecurityConfig Changes
|
||||
|
||||
Add to the security filter chain:
|
||||
- `/api/account/**` → `authenticated()` (any logged-in user)
|
||||
- `/api/account/mfa/**` exempt from `MfaEnforcementFilter` (same as current `/api/tenant/mfa/` exemption)
|
||||
|
||||
### TenantPortalService Consolidation
|
||||
|
||||
After extraction, `TenantPortalService` delegates to `AccountService` for user-level operations:
|
||||
- `changePassword()` → `accountService.changePassword()`
|
||||
- `getMfaStatus()` → `accountService.getMfaStatus()`
|
||||
- `setupTotp()` → `accountService.setupTotp()`
|
||||
- `verifyTotpCode()` → `accountService.verifyTotpCode()`
|
||||
- `generateBackupCodes()` → `accountService.generateBackupCodes()`
|
||||
- `removeTotp()` → `accountService.removeMfa()`
|
||||
- `listPasskeys()` → `accountService.listPasskeys()`
|
||||
- `renamePasskey()` → `accountService.renamePasskey()`
|
||||
- `deletePasskey()` → `accountService.deletePasskey()`
|
||||
- `updateMfaMethodPreference()` → `accountService.setMfaMethodPreference()`
|
||||
|
||||
Tenant-admin operations stay in `TenantPortalService`: `resetTeamMemberMfa`, `resetTeamMemberPassword`, `updateTenantSettings`, `getAuthSettings`, `resetServerAdminPassword`.
|
||||
|
||||
Old `/api/tenant/mfa/*` and `/api/tenant/password` endpoints remain as thin delegates to preserve backward compatibility during migration, then deprecated.
|
||||
|
||||
### Password Change Flow
|
||||
|
||||
1. Client sends `POST /api/account/password` with `{ currentPassword, newPassword }`
|
||||
2. `AccountService.changePassword()`:
|
||||
a. `validatePassword(newPassword)` — min 8 chars
|
||||
b. Fetch user email via `logtoClient.getUser(userId)`
|
||||
c. Attempt ROPC token exchange: `POST /oidc/token` with `grant_type=password`, user's email + `currentPassword` against the SaaS OIDC app
|
||||
d. If token exchange fails → 400 "Current password is incorrect"
|
||||
e. `logtoClient.updateUserPassword(userId, newPassword)`
|
||||
f. Fire `passwordResetNotificationService.sendNotification(email)` asynchronously
|
||||
|
||||
### LogtoManagementClient — ROPC Addition
|
||||
|
||||
New method: `verifyPasswordViaRopc(String email, String password)` — attempts password grant against Logto's token endpoint using the SaaS app's client ID. Returns boolean (success/failure). Does not store the returned token.
|
||||
|
||||
**Prerequisite:** The SaaS OIDC application in Logto must have the Resource Owner Password Credentials (ROPC) grant type enabled. This is configured in `logto-bootstrap.sh` when creating the application (add `password` to the `grantTypes` array if not already present).
|
||||
|
||||
---
|
||||
|
||||
## Section 2: Vendor Admin Management
|
||||
|
||||
### VendorAdminService
|
||||
|
||||
New service at `src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminService.java`.
|
||||
|
||||
| Method | Purpose | Logto API |
|
||||
|--------|---------|-----------|
|
||||
| `listAdmins()` | List all users with `saas-vendor` role | `GET /api/roles/{roleId}/users` |
|
||||
| `createAdmin(email, tempPassword?)` | Create + assign role (invite or credentials) | `POST /api/users` + `POST /api/users/{id}/roles` |
|
||||
| `removeAdmin(userId, requesterId)` | Revoke role (blocks self-removal) | `DELETE /api/users/{id}/roles/{roleId}` |
|
||||
| `resetAdminPassword(userId, newPassword)` | Reset password + send notification | `PATCH /api/users/{id}/password` |
|
||||
| `resetAdminMfa(userId)` | Delete all MFA verifications | Batch `DELETE /api/users/{id}/mfa-verifications/{vid}` |
|
||||
|
||||
**Create admin logic:**
|
||||
- Check `emailConnectorService.isEmailConnectorConfigured()`
|
||||
- If configured and no temp password provided: `logtoClient.createAndInviteUser(email)` + assign `saas-vendor` role → returns `{ invited: true }`
|
||||
- If not configured or temp password provided: `logtoClient.createUserWithPassword(email, tempPassword)` + assign `saas-vendor` role → returns `{ invited: false, tempPassword }`
|
||||
|
||||
**Self-removal prevention:** `removeAdmin` compares `userId` with `requesterId` (from JWT `sub`). Throws 400 if equal.
|
||||
|
||||
### VendorAdminController
|
||||
|
||||
New controller at `src/main/java/net/siegeln/cameleer/saas/vendor/VendorAdminController.java`. All endpoints require `platform:admin`.
|
||||
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `GET /api/vendor/admins` | GET | List all vendor admins |
|
||||
| `POST /api/vendor/admins` | POST | Create/invite new admin |
|
||||
| `DELETE /api/vendor/admins/{userId}` | DELETE | Remove admin |
|
||||
| `POST /api/vendor/admins/{userId}/reset-password` | POST | Reset another admin's password |
|
||||
| `DELETE /api/vendor/admins/{userId}/mfa` | DELETE | Reset another admin's MFA |
|
||||
|
||||
### LogtoManagementClient Additions
|
||||
|
||||
| Method | Logto API |
|
||||
|--------|-----------|
|
||||
| `listRoleUsers(roleId)` | `GET /api/roles/{roleId}/users` |
|
||||
| `assignGlobalRole(userId, roleId)` | `POST /api/users/{userId}/roles` |
|
||||
| `revokeGlobalRole(userId, roleId)` | `DELETE /api/users/{userId}/roles/{roleId}` |
|
||||
| `getRoleByName(roleName)` | `GET /api/roles?search={name}` |
|
||||
|
||||
---
|
||||
|
||||
## Section 3: Frontend — Shared Account Settings Page
|
||||
|
||||
### New Route
|
||||
|
||||
`/settings/account` — standalone page with app header and back navigation. Accessible to any authenticated user.
|
||||
|
||||
### Extracted Components
|
||||
|
||||
Components extracted from `SettingsPage.tsx` into `ui/src/components/account/`:
|
||||
|
||||
| Component | Source | File |
|
||||
|-----------|--------|------|
|
||||
| `ProfileSection` | New | `ProfileSection.tsx` |
|
||||
| `PasswordChangeSection` | `SettingsPage.tsx` lines 631-664 | `PasswordChangeSection.tsx` |
|
||||
| `MfaSection` | `SettingsPage.tsx` lines 34-270 | `MfaSection.tsx` |
|
||||
| `PasskeySection` | `SettingsPage.tsx` lines 368-462 | `PasskeySection.tsx` |
|
||||
|
||||
The `PasswordChangeSection` gains a "current password" field (the existing tenant version only has new password + confirm).
|
||||
|
||||
### New Account Settings Page
|
||||
|
||||
`ui/src/pages/AccountSettingsPage.tsx` — composes all four sections in order: Profile, Password, MFA, Passkeys.
|
||||
|
||||
### Tenant SettingsPage Consolidation
|
||||
|
||||
`SettingsPage.tsx` imports the shared components from `ui/src/components/account/` instead of defining them inline. Keeps its tenant-specific sections: `AuthPolicySection`, `MfaEnforcementToggle`, server admin password reset.
|
||||
|
||||
### New API Hooks
|
||||
|
||||
`ui/src/hooks/account-hooks.ts`:
|
||||
|
||||
| Hook | Endpoint |
|
||||
|------|----------|
|
||||
| `useAccountProfile()` | `GET /api/account/profile` |
|
||||
| `useUpdateDisplayName()` | `PATCH /api/account/profile` |
|
||||
| `useChangePassword()` | `POST /api/account/password` |
|
||||
| `useAccountMfaStatus()` | `GET /api/account/mfa/status` |
|
||||
| `useAccountMfaSetup()` | `POST /api/account/mfa/totp/setup` |
|
||||
| `useAccountMfaVerify()` | `POST /api/account/mfa/totp/verify` |
|
||||
| `useAccountBackupCodes()` | `POST /api/account/mfa/backup-codes` |
|
||||
| `useAccountMfaRemove()` | `DELETE /api/account/mfa/totp` |
|
||||
| `useAccountPasskeyList()` | `GET /api/account/mfa/webauthn` |
|
||||
| `useAccountRenamePasskey()` | `PATCH /api/account/mfa/webauthn/{id}/name` |
|
||||
| `useAccountDeletePasskey()` | `DELETE /api/account/mfa/webauthn/{id}` |
|
||||
| `useAccountMfaPreference()` | `POST /api/account/mfa/method-preference` |
|
||||
|
||||
Old hooks in `tenant-hooks.ts` (`useMfaStatus`, `useMfaSetup`, etc.) are replaced with re-exports from `account-hooks.ts` to avoid breaking any remaining references during migration.
|
||||
|
||||
### User Menu in Header
|
||||
|
||||
Dropdown on the user name/avatar in the app header:
|
||||
- "Account Settings" → `/settings/account`
|
||||
- "Sign Out" → existing sign-out flow
|
||||
|
||||
---
|
||||
|
||||
## Section 4: Vendor Admin List Page
|
||||
|
||||
### New Route
|
||||
|
||||
`/vendor/admins` — added to vendor sidebar navigation.
|
||||
|
||||
### Page Layout
|
||||
|
||||
- Header: "Platform Administrators" + "Add Administrator" button
|
||||
- Table columns: Display Name, Email, Status ("You" badge on self-row), actions
|
||||
- Row actions (kebab menu): Reset Password, Reset MFA, Remove
|
||||
- Self-row: remove action disabled
|
||||
|
||||
### Add Administrator Dialog
|
||||
|
||||
- Checks email connector status via existing `GET /api/vendor/email/status`
|
||||
- Email connector configured: single email field → invite sent
|
||||
- Email connector not configured: email field + temporary password field → credentials shown with copy action on success
|
||||
|
||||
### Confirmation Dialogs
|
||||
|
||||
- Remove: "Remove {name} as platform administrator? They will lose access to the vendor console."
|
||||
- Reset Password: form with new temporary password field
|
||||
- Reset MFA: "Reset all MFA enrollments for {name}? They will need to re-enroll."
|
||||
|
||||
### New API Hooks
|
||||
|
||||
`ui/src/hooks/vendor-admin-hooks.ts`:
|
||||
|
||||
| Hook | Endpoint |
|
||||
|------|----------|
|
||||
| `useVendorAdminList()` | `GET /api/vendor/admins` |
|
||||
| `useCreateVendorAdmin()` | `POST /api/vendor/admins` |
|
||||
| `useRemoveVendorAdmin()` | `DELETE /api/vendor/admins/{userId}` |
|
||||
| `useResetVendorAdminPassword()` | `POST /api/vendor/admins/{userId}/reset-password` |
|
||||
| `useResetVendorAdminMfa()` | `DELETE /api/vendor/admins/{userId}/mfa` |
|
||||
|
||||
---
|
||||
|
||||
## Section 5: Sign-In Page Changes
|
||||
|
||||
### Forgot Password Link
|
||||
|
||||
Add a "Forgot password?" link below the password field in `SignInPage.tsx`. This triggers the existing `forgotPassword` mode (already implemented at lines 187-238). The flow:
|
||||
1. User enters email
|
||||
2. Logto Experience API sends reset code
|
||||
3. User enters code + new password
|
||||
4. On success, fires `POST /api/password-reset-notification` (already wired)
|
||||
|
||||
No backend changes needed — the flow exists, just needs a visible trigger in the UI.
|
||||
|
||||
---
|
||||
|
||||
## Files Changed Summary
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/.../account/AccountService.java` | User-level identity operations |
|
||||
| `src/.../account/AccountController.java` | `/api/account/*` endpoints |
|
||||
| `src/.../vendor/VendorAdminService.java` | Vendor admin CRUD |
|
||||
| `src/.../vendor/VendorAdminController.java` | `/api/vendor/admins/*` endpoints |
|
||||
| `ui/src/components/account/ProfileSection.tsx` | Display name + email |
|
||||
| `ui/src/components/account/PasswordChangeSection.tsx` | Password change form |
|
||||
| `ui/src/components/account/MfaSection.tsx` | TOTP management |
|
||||
| `ui/src/components/account/PasskeySection.tsx` | Passkey list/rename/delete |
|
||||
| `ui/src/pages/AccountSettingsPage.tsx` | Shared account settings page |
|
||||
| `ui/src/pages/vendor/VendorAdminsPage.tsx` | Vendor admin list page |
|
||||
| `ui/src/hooks/account-hooks.ts` | Shared account API hooks |
|
||||
| `ui/src/hooks/vendor-admin-hooks.ts` | Vendor admin API hooks |
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `LogtoManagementClient.java` | Add `verifyPasswordViaRopc`, `listRoleUsers`, `assignGlobalRole`, `revokeGlobalRole`, `getRoleByName` |
|
||||
| `SecurityConfig.java` | Add `/api/account/**` as `authenticated()` |
|
||||
| `MfaEnforcementFilter.java` | Exempt `/api/account/mfa/` paths |
|
||||
| `TenantPortalService.java` | Delegate MFA/password/passkey methods to `AccountService` |
|
||||
| `TenantPortalController.java` | Optionally deprecate old `/api/tenant/mfa/*` endpoints |
|
||||
| `OnboardingService.java` | Use `AccountService.updateDisplayName()` instead of direct Logto call |
|
||||
| `SettingsPage.tsx` (tenant) | Import shared components from `components/account/` |
|
||||
| `tenant-hooks.ts` | Replace MFA/password hooks with re-exports from `account-hooks.ts` |
|
||||
| `router.tsx` | Add `/settings/account` and `/vendor/admins` routes |
|
||||
| `SignInPage.tsx` | Add "Forgot password?" link |
|
||||
| App header component | Add user dropdown menu |
|
||||
@@ -0,0 +1,72 @@
|
||||
# Logto Webhook Auth Event Logging — Design Spec
|
||||
|
||||
## Problem
|
||||
|
||||
Authentication events (login, registration, password reset) happen inside Logto, not the SaaS backend. The audit_log table has `AUTH_LOGIN`, `AUTH_REGISTER`, `AUTH_LOGIN_FAILED` action types but they're never written — the backend never sees these events. SOC 2 CC7.2 requires logging authentication events.
|
||||
|
||||
## Solution
|
||||
|
||||
Register a Logto webhook that sends auth interaction events to the SaaS backend. A new internal endpoint receives these events, validates the HMAC signature, and writes them to `audit_log`.
|
||||
|
||||
## Event Mapping
|
||||
|
||||
| Logto Hook Event | AuditAction | Metadata |
|
||||
|------------------|-------------|----------|
|
||||
| `PostSignIn` | `AUTH_LOGIN` | userAgent, application |
|
||||
| `PostRegister` | `AUTH_REGISTER` | userAgent, email |
|
||||
| `PostResetPassword` | `AUTH_LOGIN` | via: "password_reset" |
|
||||
|
||||
Logto only fires hooks on **successful** interactions. `AUTH_LOGIN_FAILED` cannot be captured via webhooks — it would require polling Logto's audit log API (out of scope).
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Webhook Receiver — `LogtoWebhookController`
|
||||
|
||||
- **Path:** `POST /api/internal/webhooks/logto`
|
||||
- **Auth:** Not JWT-protected. Validated via `logto-signature-sha-256` HMAC header.
|
||||
- **Signature validation:** `HMAC-SHA256(webhookSecret, rawRequestBody)` compared against header value.
|
||||
- **Behavior:** Parse event type and user data from body, map to AuditAction, call `auditService.log()` with userId, IP (`userIp` from payload), and metadata.
|
||||
- **Response:** 200 OK (Logto retries on 4xx/5xx with exponential backoff).
|
||||
|
||||
### 2. Webhook Registration — `LogtoStartupConfig`
|
||||
|
||||
- At startup, after existing Logto configuration, idempotently register the webhook:
|
||||
- `GET /api/webhooks` — check if a webhook with our URL already exists
|
||||
- If not: `POST /api/webhooks` with name `"cameleer-saas-auth-events"`, events `["PostSignIn", "PostRegister", "PostResetPassword"]`, URL pointing to the internal SaaS endpoint, and the shared secret.
|
||||
- The webhook URL uses the Docker-internal endpoint (`http://cameleer-saas:8080/api/internal/webhooks/logto`) since Logto and SaaS are on the same Docker network.
|
||||
|
||||
### 3. LogtoManagementClient — Webhook Methods
|
||||
|
||||
Add to existing client:
|
||||
- `listWebhooks()` — `GET /api/webhooks`
|
||||
- `createWebhook(name, events, url, signingKey)` — `POST /api/webhooks`
|
||||
|
||||
No update/delete needed — registration is idempotent (check-then-create).
|
||||
|
||||
### 4. SecurityConfig — Permit Internal Path
|
||||
|
||||
Add `/api/internal/**` to the permit list in `SecurityConfig`. This path prefix is reserved for machine-to-machine calls validated by their own mechanisms (HMAC, API keys), not JWT.
|
||||
|
||||
### 5. Configuration
|
||||
|
||||
- Property: `cameleer.saas.identity.webhooksecret`
|
||||
- Env var: `CAMELEER_SAAS_IDENTITY_WEBHOOKSECRET`
|
||||
- Added to existing `LogtoConfig` record (or a new sibling, depending on where identity props are bound).
|
||||
- **Installer responsibility:** Generate a random value (e.g., `openssl rand -hex 32`) and set it in the compose env. Same pattern as `CAMELEER_SAAS_IDENTITY_M2MCLIENTSECRET`.
|
||||
- If the property is blank/missing, webhook registration is skipped and a warning is logged.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Action | What |
|
||||
|------|--------|------|
|
||||
| `LogtoWebhookController.java` | Create | Receiver + HMAC validation |
|
||||
| `LogtoManagementClient.java` | Modify | Add `listWebhooks`, `createWebhook` |
|
||||
| `LogtoStartupConfig.java` | Modify | Auto-register webhook at startup |
|
||||
| `SecurityConfig.java` | Modify | Permit `/api/internal/**` |
|
||||
| `LogtoConfig.java` or `application.yml` | Modify | Add `webhooksecret` property |
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Failed login tracking (requires Logto audit log polling)
|
||||
- Logout events (Logto does not fire webhook on logout)
|
||||
- Data change events (already captured at service layer)
|
||||
@@ -26,13 +26,12 @@ Both audiences share the same UI and workflows. The self-hosted setup section at
|
||||
|
||||
### Logging In
|
||||
|
||||
Cameleer SaaS uses Logto for single sign-on (SSO). To log in:
|
||||
Cameleer SaaS uses Logto for single sign-on (SSO). Email is the primary user identity — all users must have an email address. To log in:
|
||||
|
||||
1. Navigate to the Cameleer SaaS URL in your browser.
|
||||
2. You will see the login screen with the title "Cameleer SaaS" and a subtitle "Managed Apache Camel Runtime."
|
||||
3. Click **Sign in with Logto**.
|
||||
4. Authenticate with your Logto credentials (username/password or any configured social login).
|
||||
5. After successful authentication, you are redirected back to the dashboard.
|
||||
2. You will be redirected to the Cameleer sign-in page.
|
||||
3. Enter your email and password.
|
||||
4. After successful authentication, you are redirected to the dashboard.
|
||||
|
||||

|
||||
|
||||
@@ -62,16 +61,28 @@ The dashboard provides an at-a-glance overview of your tenant:
|
||||
|
||||
The sidebar provides access to all major sections:
|
||||
|
||||
**Vendor console** (visible only to platform admins):
|
||||
|
||||
| Section | Description |
|
||||
|---------|-------------|
|
||||
| **Dashboard** | Tenant overview and KPI metrics |
|
||||
| **Environments** | Expandable tree showing all environments and their apps |
|
||||
| **License** | License tier, features, limits, and token |
|
||||
| **Platform** | Platform-wide tenant management (visible only to platform admins) |
|
||||
| **View Dashboard** | Opens the observability dashboard (cameleer-server) in a new tab |
|
||||
| **Account** | Log out of the current session |
|
||||
| **Tenants** | List, create, and manage tenants |
|
||||
| **Audit Log** | Platform-wide audit trail |
|
||||
| **Certificates** | TLS certificate lifecycle (stage, activate, restore) |
|
||||
| **Metrics** | Tenant usage metrics |
|
||||
| **Infrastructure** | PostgreSQL and ClickHouse health |
|
||||
| **Email Connector** | Configure SMTP for email verification and self-service registration |
|
||||
| **Logto Console** | Opens the Logto admin console (external link) |
|
||||
|
||||
The Environments section in the sidebar renders as a collapsible tree: environments at the top level, with their applications nested underneath. Clicking any item navigates directly to its detail page.
|
||||
**Tenant portal** (visible to tenant admins):
|
||||
|
||||
| Section | Description |
|
||||
|---------|-------------|
|
||||
| **Dashboard** | Tenant overview, server health, usage, and quick links |
|
||||
| **License** | License tier, features, limits, and token |
|
||||
| **Security** | SSO connector configuration |
|
||||
| **Team** | Manage team members and reset passwords |
|
||||
| **Audit Log** | Tenant-scoped audit trail |
|
||||
| **Settings** | Change own password, reset server admin password |
|
||||
|
||||
---
|
||||
|
||||
@@ -442,7 +453,7 @@ Copy `.env.example` to `.env` and configure as needed:
|
||||
| `CAMELEER_SAAS_IDENTITY_SPACLIENTID` | SPA client ID for the frontend | _(empty)_ |
|
||||
| `PUBLIC_HOST` | Public hostname for Traefik, Logto, and SaaS routing | `localhost` |
|
||||
| `PUBLIC_PROTOCOL` | Public protocol (`http` or `https`) | `https` |
|
||||
| `SAAS_ADMIN_USER` | Platform admin username | `admin` |
|
||||
| `SAAS_ADMIN_USER` | Platform admin login (must be an email in SaaS mode) | `admin` |
|
||||
| `SAAS_ADMIN_PASS` | Platform admin password | `admin` |
|
||||
| `TENANT_ADMIN_USER` | Tenant admin username | `camel` |
|
||||
| `TENANT_ADMIN_PASS` | Tenant admin password | `camel` |
|
||||
@@ -518,7 +529,7 @@ The Cameleer SaaS application itself does not need any changes -- all identity c
|
||||
|
||||
### Login Fails or Redirect Loop
|
||||
|
||||
**Symptoms:** Clicking "Sign in with Logto" redirects you in a loop, or you see an error page.
|
||||
**Symptoms:** The sign-in page redirects in a loop, or you see an error page.
|
||||
|
||||
**Possible causes:**
|
||||
|
||||
@@ -562,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
|
||||
|
||||
Submodule installer updated: 1ef0016965...64b4005ac7
25
pom.xml
25
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>
|
||||
@@ -100,6 +100,19 @@
|
||||
<version>3.4.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- License Minter (Ed25519 signing) -->
|
||||
<dependency>
|
||||
<groupId>io.cameleer</groupId>
|
||||
<artifactId>cameleer-license-minter</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Mail (for password-reset security notification) -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Test -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
@@ -123,6 +136,16 @@
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>gitea-cameleer</id>
|
||||
<url>https://gitea.siegeln.net/api/packages/cameleer/maven</url>
|
||||
<snapshots>
|
||||
<enabled>true</enabled>
|
||||
</snapshots>
|
||||
</repository>
|
||||
</repositories>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.siegeln.cameleer.saas;
|
||||
package io.cameleer.saas;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
114
src/main/java/io/cameleer/saas/account/AccountController.java
Normal file
114
src/main/java/io/cameleer/saas/account/AccountController.java
Normal file
@@ -0,0 +1,114 @@
|
||||
package io.cameleer.saas.account;
|
||||
|
||||
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;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/account")
|
||||
public class AccountController {
|
||||
|
||||
private final AccountService accountService;
|
||||
|
||||
public AccountController(AccountService accountService) {
|
||||
this.accountService = accountService;
|
||||
}
|
||||
|
||||
// --- Profile ---
|
||||
|
||||
@GetMapping("/profile")
|
||||
public ProfileData getProfile(@AuthenticationPrincipal Jwt jwt) {
|
||||
return accountService.getProfile(jwt.getSubject());
|
||||
}
|
||||
|
||||
@PatchMapping("/profile")
|
||||
public ResponseEntity<Void> updateProfile(@AuthenticationPrincipal Jwt jwt,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String name = body.get("name");
|
||||
accountService.updateDisplayName(jwt.getSubject(), name);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// --- Password ---
|
||||
|
||||
record PasswordChangeRequest(String currentPassword, String newPassword) {}
|
||||
|
||||
@PostMapping("/password")
|
||||
public ResponseEntity<Void> changePassword(@AuthenticationPrincipal Jwt jwt,
|
||||
@RequestBody PasswordChangeRequest request) {
|
||||
accountService.changePassword(jwt.getSubject(), request.currentPassword(), request.newPassword());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// --- MFA ---
|
||||
|
||||
@GetMapping("/mfa/status")
|
||||
public MfaStatusData getMfaStatus(@AuthenticationPrincipal Jwt jwt) {
|
||||
return accountService.getMfaStatus(jwt.getSubject());
|
||||
}
|
||||
|
||||
@PostMapping("/mfa/totp/setup")
|
||||
public MfaSetupData setupTotp(@AuthenticationPrincipal Jwt jwt) {
|
||||
return accountService.setupTotp(jwt.getSubject());
|
||||
}
|
||||
|
||||
record TotpVerifyRequest(String secret, String code) {}
|
||||
|
||||
@PostMapping("/mfa/totp/verify")
|
||||
public Map<String, Boolean> verifyTotp(@AuthenticationPrincipal Jwt jwt,
|
||||
@RequestBody TotpVerifyRequest request) {
|
||||
boolean ok = accountService.verifyAndEnableTotp(jwt.getSubject(), request.secret(), request.code());
|
||||
return Map.of("verified", ok);
|
||||
}
|
||||
|
||||
@PostMapping("/mfa/backup-codes")
|
||||
public BackupCodesData generateBackupCodes(@AuthenticationPrincipal Jwt jwt) {
|
||||
return accountService.generateBackupCodes(jwt.getSubject());
|
||||
}
|
||||
|
||||
@DeleteMapping("/mfa/totp")
|
||||
public ResponseEntity<Void> removeTotp(@AuthenticationPrincipal Jwt jwt) {
|
||||
accountService.removeMfa(jwt.getSubject());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// --- Passkeys ---
|
||||
|
||||
@GetMapping("/mfa/webauthn")
|
||||
public List<PasskeyCredential> listPasskeys(@AuthenticationPrincipal Jwt jwt) {
|
||||
return accountService.listPasskeys(jwt.getSubject());
|
||||
}
|
||||
|
||||
@PatchMapping("/mfa/webauthn/{id}/name")
|
||||
public ResponseEntity<Void> renamePasskey(@AuthenticationPrincipal Jwt jwt,
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String name = body.get("name");
|
||||
if (name == null || name.isBlank()) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
accountService.renamePasskey(jwt.getSubject(), id, name.trim());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@DeleteMapping("/mfa/webauthn/{id}")
|
||||
public ResponseEntity<Void> deletePasskey(@AuthenticationPrincipal Jwt jwt,
|
||||
@PathVariable String id) {
|
||||
accountService.deletePasskey(jwt.getSubject(), id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// --- MFA Preference ---
|
||||
|
||||
@PostMapping("/mfa/method-preference")
|
||||
public ResponseEntity<Void> setMfaPreference(@AuthenticationPrincipal Jwt jwt,
|
||||
@RequestBody Map<String, String> body) {
|
||||
accountService.setMfaMethodPreference(jwt.getSubject(), body.get("preference"));
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
301
src/main/java/io/cameleer/saas/account/AccountService.java
Normal file
301
src/main/java/io/cameleer/saas/account/AccountService.java
Normal file
@@ -0,0 +1,301 @@
|
||||
package io.cameleer.saas.account;
|
||||
|
||||
import io.cameleer.saas.audit.AuditAction;
|
||||
import io.cameleer.saas.audit.AuditService;
|
||||
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;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class AccountService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AccountService.class);
|
||||
private static final String BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
|
||||
private final LogtoManagementClient logtoClient;
|
||||
private final PasswordResetNotificationService passwordNotificationService;
|
||||
private final AuditService auditService;
|
||||
|
||||
public AccountService(LogtoManagementClient logtoClient,
|
||||
PasswordResetNotificationService passwordNotificationService,
|
||||
AuditService auditService) {
|
||||
this.logtoClient = logtoClient;
|
||||
this.passwordNotificationService = passwordNotificationService;
|
||||
this.auditService = auditService;
|
||||
}
|
||||
|
||||
private UUID resolveUUID(String id) {
|
||||
try {
|
||||
return UUID.fromString(id);
|
||||
} catch (Exception e) {
|
||||
return UUID.nameUUIDFromBytes(id.getBytes());
|
||||
}
|
||||
}
|
||||
|
||||
// --- Records ---
|
||||
|
||||
public record ProfileData(String userId, String name, String email) {}
|
||||
public record MfaStatusData(boolean enrolled, boolean hasBackupCodes, boolean passkeyEnrolled, int passkeyCount) {}
|
||||
public record MfaSetupData(String secret, String secretQrCode) {}
|
||||
public record BackupCodesData(List<String> codes) {}
|
||||
public record PasskeyCredential(String id, String name, String agent, String createdAt) {}
|
||||
|
||||
// --- Profile ---
|
||||
|
||||
public ProfileData getProfile(String userId) {
|
||||
var user = logtoClient.getUser(userId);
|
||||
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,
|
||||
nameVal != null ? String.valueOf(nameVal) : "",
|
||||
emailVal != null ? String.valueOf(emailVal) : ""
|
||||
);
|
||||
}
|
||||
|
||||
public void updateDisplayName(String userId, String name) {
|
||||
if (name == null || name.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Display name must not be blank");
|
||||
}
|
||||
logtoClient.updateUserProfile(userId, Map.of("name", name.trim()));
|
||||
log.info("Updated display name for user {}", userId);
|
||||
auditService.log(resolveUUID(userId), null, null, AuditAction.PROFILE_UPDATED,
|
||||
userId, null, null, "SUCCESS", Map.of("name", name.trim()));
|
||||
}
|
||||
|
||||
// --- Password ---
|
||||
|
||||
public void validatePassword(String password) {
|
||||
if (password == null || password.length() < 8) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Password must be at least 8 characters");
|
||||
}
|
||||
}
|
||||
|
||||
public void changePassword(String userId, String currentPassword, String newPassword) {
|
||||
validatePassword(newPassword);
|
||||
if (!logtoClient.verifyUserPassword(userId, currentPassword)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Current password is incorrect");
|
||||
}
|
||||
logtoClient.updateUserPassword(userId, newPassword);
|
||||
log.info("Password changed for user {}", userId);
|
||||
auditService.log(resolveUUID(userId), null, null, AuditAction.PASSWORD_CHANGED,
|
||||
userId, null, null, "SUCCESS", null);
|
||||
|
||||
// Send confirmation email asynchronously
|
||||
try {
|
||||
var user = logtoClient.getUser(userId);
|
||||
if (user != null) {
|
||||
String email = String.valueOf(user.getOrDefault("primaryEmail", ""));
|
||||
if (!email.isBlank()) {
|
||||
passwordNotificationService.sendNotification(email);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to send password change notification for user {}: {}", userId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// --- MFA ---
|
||||
|
||||
public MfaStatusData getMfaStatus(String userId) {
|
||||
var verifications = logtoClient.getUserMfaVerifications(userId);
|
||||
boolean enrolled = verifications.stream()
|
||||
.anyMatch(v -> "Totp".equals(String.valueOf(v.get("type"))));
|
||||
boolean hasBackupCodes = verifications.stream()
|
||||
.anyMatch(v -> "BackupCode".equals(String.valueOf(v.get("type"))));
|
||||
long passkeyCount = verifications.stream()
|
||||
.filter(v -> "WebAuthn".equals(String.valueOf(v.get("type"))))
|
||||
.count();
|
||||
return new MfaStatusData(enrolled, hasBackupCodes, passkeyCount > 0, (int) passkeyCount);
|
||||
}
|
||||
|
||||
public MfaSetupData setupTotp(String userId) {
|
||||
byte[] secretBytes = new byte[20];
|
||||
new SecureRandom().nextBytes(secretBytes);
|
||||
String secret = base32Encode(secretBytes);
|
||||
|
||||
// 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);
|
||||
log.info("TOTP MFA enabled for user {}", userId);
|
||||
auditService.log(resolveUUID(userId), null, null, AuditAction.MFA_TOTP_ENABLED,
|
||||
userId, null, null, "SUCCESS", null);
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean verifyTotpCode(String secret, String code) {
|
||||
if (code == null || code.length() != 6) return false;
|
||||
long currentStep = Instant.now().getEpochSecond() / 30;
|
||||
for (int drift = -1; drift <= 1; drift++) {
|
||||
String computed = computeTotp(secret, currentStep + drift);
|
||||
if (code.equals(computed)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public BackupCodesData generateBackupCodes(String userId) {
|
||||
var result = logtoClient.createBackupCodes(userId);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> codes = (List<String>) result.get("codes");
|
||||
log.info("Backup codes generated for user {}", userId);
|
||||
auditService.log(resolveUUID(userId), null, null, AuditAction.MFA_BACKUP_CODES_GENERATED,
|
||||
userId, null, null, "SUCCESS", null);
|
||||
return new BackupCodesData(codes != null ? codes : List.of());
|
||||
}
|
||||
|
||||
public void removeMfa(String userId) {
|
||||
var verifications = logtoClient.getUserMfaVerifications(userId);
|
||||
for (var v : verifications) {
|
||||
logtoClient.deleteMfaVerification(userId, String.valueOf(v.get("id")));
|
||||
}
|
||||
log.info("MFA removed for user {}", userId);
|
||||
auditService.log(resolveUUID(userId), null, null, AuditAction.MFA_TOTP_REMOVED,
|
||||
userId, null, null, "SUCCESS", null);
|
||||
}
|
||||
|
||||
// --- Passkeys ---
|
||||
|
||||
public List<PasskeyCredential> listPasskeys(String userId) {
|
||||
var credentials = logtoClient.getWebAuthnCredentials(userId);
|
||||
return credentials.stream()
|
||||
.map(c -> new PasskeyCredential(
|
||||
String.valueOf(c.get("id")),
|
||||
c.get("name") != null ? String.valueOf(c.get("name")) : null,
|
||||
c.get("agent") != null ? String.valueOf(c.get("agent")) : null,
|
||||
c.get("createdAt") != null ? String.valueOf(c.get("createdAt")) : null
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
public void renamePasskey(String userId, String credentialId, String name) {
|
||||
var credentials = logtoClient.getWebAuthnCredentials(userId);
|
||||
boolean owns = credentials.stream()
|
||||
.anyMatch(c -> credentialId.equals(String.valueOf(c.get("id"))));
|
||||
if (!owns) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Passkey not found");
|
||||
}
|
||||
logtoClient.renameMfaVerification(userId, credentialId, name);
|
||||
log.info("Passkey {} renamed for user {}", credentialId, userId);
|
||||
auditService.log(resolveUUID(userId), null, null, AuditAction.PASSKEY_RENAMED,
|
||||
credentialId, null, null, "SUCCESS", Map.of("name", name));
|
||||
}
|
||||
|
||||
public void deletePasskey(String userId, String credentialId) {
|
||||
var credentials = logtoClient.getWebAuthnCredentials(userId);
|
||||
boolean owns = credentials.stream()
|
||||
.anyMatch(c -> credentialId.equals(String.valueOf(c.get("id"))));
|
||||
if (!owns) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Passkey not found");
|
||||
}
|
||||
logtoClient.deleteMfaVerification(userId, credentialId);
|
||||
log.info("Passkey {} deleted for user {}", credentialId, userId);
|
||||
auditService.log(resolveUUID(userId), null, null, AuditAction.PASSKEY_DELETED,
|
||||
credentialId, null, null, "SUCCESS", null);
|
||||
}
|
||||
|
||||
// --- MFA Preference ---
|
||||
|
||||
public void setMfaMethodPreference(String userId, String preference) {
|
||||
if (!"totp".equals(preference) && !"webauthn".equals(preference)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid MFA preference: must be 'totp' or 'webauthn'");
|
||||
}
|
||||
logtoClient.updateUserCustomData(userId, Map.of("mfa_method_preference", preference));
|
||||
log.info("MFA preference set to '{}' for user {}", preference, userId);
|
||||
auditService.log(resolveUUID(userId), null, null, AuditAction.MFA_PREFERENCE_CHANGED,
|
||||
userId, null, null, "SUCCESS", Map.of("preference", preference));
|
||||
}
|
||||
|
||||
// --- TOTP helpers (moved from TenantPortalService) ---
|
||||
|
||||
private String computeTotp(String base32Secret, long timeStep) {
|
||||
try {
|
||||
byte[] key = base32Decode(base32Secret);
|
||||
byte[] data = ByteBuffer.allocate(8).putLong(timeStep).array();
|
||||
Mac mac = Mac.getInstance("HmacSHA1");
|
||||
mac.init(new SecretKeySpec(key, "HmacSHA1"));
|
||||
byte[] hash = mac.doFinal(data);
|
||||
int offset = hash[hash.length - 1] & 0x0F;
|
||||
int code = ((hash[offset] & 0x7F) << 24)
|
||||
| ((hash[offset + 1] & 0xFF) << 16)
|
||||
| ((hash[offset + 2] & 0xFF) << 8)
|
||||
| (hash[offset + 3] & 0xFF);
|
||||
return String.format("%06d", code % 1_000_000);
|
||||
} catch (Exception e) {
|
||||
log.error("TOTP computation failed", e);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
String base32Encode(byte[] data) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
int buffer = 0, bitsLeft = 0;
|
||||
for (byte b : data) {
|
||||
buffer = (buffer << 8) | (b & 0xFF);
|
||||
bitsLeft += 8;
|
||||
while (bitsLeft >= 5) {
|
||||
sb.append(BASE32_ALPHABET.charAt((buffer >> (bitsLeft - 5)) & 0x1F));
|
||||
bitsLeft -= 5;
|
||||
}
|
||||
}
|
||||
if (bitsLeft > 0) {
|
||||
sb.append(BASE32_ALPHABET.charAt((buffer << (5 - bitsLeft)) & 0x1F));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
byte[] base32Decode(String encoded) {
|
||||
String clean = encoded.replaceAll("[=\\s]", "").toUpperCase();
|
||||
int byteCount = clean.length() * 5 / 8;
|
||||
byte[] result = new byte[byteCount];
|
||||
int buffer = 0, bitsLeft = 0, index = 0;
|
||||
for (char c : clean.toCharArray()) {
|
||||
int val = BASE32_ALPHABET.indexOf(c);
|
||||
if (val < 0) continue;
|
||||
buffer = (buffer << 5) | val;
|
||||
bitsLeft += 5;
|
||||
if (bitsLeft >= 8) {
|
||||
result[index++] = (byte) (buffer >> (bitsLeft - 8));
|
||||
bitsLeft -= 8;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
55
src/main/java/io/cameleer/saas/audit/AuditAction.java
Normal file
55
src/main/java/io/cameleer/saas/audit/AuditAction.java
Normal file
@@ -0,0 +1,55 @@
|
||||
package io.cameleer.saas.audit;
|
||||
|
||||
public enum AuditAction {
|
||||
// Authentication
|
||||
AUTH_REGISTER, AUTH_LOGIN, AUTH_LOGIN_FAILED, AUTH_LOGOUT,
|
||||
|
||||
// Tenant lifecycle
|
||||
TENANT_CREATE, TENANT_UPDATE, TENANT_SUSPEND, TENANT_REACTIVATE, TENANT_DELETE,
|
||||
TENANT_AUTH_SETTINGS_UPDATED,
|
||||
|
||||
// Environments & apps
|
||||
ENVIRONMENT_CREATE, ENVIRONMENT_UPDATE, ENVIRONMENT_DELETE,
|
||||
APP_CREATE, APP_DEPLOY, APP_PROMOTE, APP_ROLLBACK, APP_SCALE, APP_STOP, APP_DELETE,
|
||||
|
||||
// Secrets
|
||||
SECRET_CREATE, SECRET_READ, SECRET_UPDATE, SECRET_DELETE, SECRET_ROTATE,
|
||||
|
||||
// Config
|
||||
CONFIG_UPDATE,
|
||||
|
||||
// Team management
|
||||
TEAM_INVITE, TEAM_REMOVE, TEAM_ROLE_CHANGE,
|
||||
TEAM_MEMBER_PASSWORD_RESET, TEAM_MEMBER_MFA_RESET,
|
||||
|
||||
// License
|
||||
LICENSE_GENERATE, LICENSE_REVOKE,
|
||||
|
||||
// Vendor admin lifecycle
|
||||
ADMIN_CREATED, ADMIN_REMOVED, ADMIN_PASSWORD_RESET, ADMIN_MFA_RESET,
|
||||
|
||||
// Platform auth policy
|
||||
PLATFORM_AUTH_POLICY_UPDATED,
|
||||
|
||||
// Email connector
|
||||
EMAIL_CONNECTOR_SAVED, EMAIL_CONNECTOR_DELETED, REGISTRATION_TOGGLED,
|
||||
|
||||
// Platform certificate management
|
||||
CERTIFICATE_STAGED, CERTIFICATE_ACTIVATED, CERTIFICATE_RESTORED, CERTIFICATE_DISCARDED,
|
||||
|
||||
// Tenant CA certificate management
|
||||
TENANT_CA_CERT_STAGED, TENANT_CA_CERT_ACTIVATED, TENANT_CA_CERT_DELETED,
|
||||
|
||||
// Account security
|
||||
PROFILE_UPDATED, PASSWORD_CHANGED,
|
||||
MFA_TOTP_ENABLED, MFA_TOTP_REMOVED,
|
||||
MFA_BACKUP_CODES_GENERATED,
|
||||
PASSKEY_RENAMED, PASSKEY_DELETED,
|
||||
MFA_PREFERENCE_CHANGED,
|
||||
|
||||
// SSO connectors
|
||||
SSO_CONNECTOR_CREATED, SSO_CONNECTOR_UPDATED, SSO_CONNECTOR_DELETED,
|
||||
|
||||
// Server operations
|
||||
SERVER_RESTARTED, SERVER_UPGRADED, SERVER_ADMIN_PASSWORD_RESET
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.siegeln.cameleer.saas.audit;
|
||||
package io.cameleer.saas.audit;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
@@ -10,6 +10,7 @@ public final class AuditDto {
|
||||
|
||||
public record AuditLogEntry(
|
||||
UUID id,
|
||||
UUID actorId,
|
||||
String actorEmail,
|
||||
UUID tenantId,
|
||||
String action,
|
||||
@@ -21,7 +22,7 @@ public final class AuditDto {
|
||||
) {
|
||||
public static AuditLogEntry from(AuditEntity e) {
|
||||
return new AuditLogEntry(
|
||||
e.getId(), e.getActorEmail(), e.getTenantId(),
|
||||
e.getId(), e.getActorId(), e.getActorEmail(), e.getTenantId(),
|
||||
e.getAction(), e.getResource(), e.getEnvironment(),
|
||||
e.getResult(), e.getSourceIp(), e.getCreatedAt()
|
||||
);
|
||||
@@ -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;
|
||||
@@ -75,6 +75,7 @@ public class AuditRepositoryImpl implements AuditRepositoryCustom {
|
||||
Timestamp ts = rs.getTimestamp("created_at");
|
||||
return new AuditDto.AuditLogEntry(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getObject("actor_id", UUID.class),
|
||||
rs.getString("actor_email"),
|
||||
rs.getObject("tenant_id", UUID.class),
|
||||
rs.getString("action"),
|
||||
@@ -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;
|
||||
@@ -53,21 +53,46 @@ public class AuditService {
|
||||
}
|
||||
|
||||
private String resolveActorName(String userId) {
|
||||
return userNameCache.computeIfAbsent(userId, id -> {
|
||||
try {
|
||||
var user = logtoClient.getUser(id);
|
||||
if (user == null) return id;
|
||||
var username = user.get("username");
|
||||
if (username != null && !username.toString().isBlank()) return username.toString();
|
||||
var name = user.get("name");
|
||||
if (name != null && !name.toString().isBlank()) return name.toString();
|
||||
var email = user.get("primaryEmail");
|
||||
if (email != null && !email.toString().isBlank()) return email.toString();
|
||||
return id;
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to resolve actor name for {}: {}", id, e.getMessage());
|
||||
return id;
|
||||
String cached = userNameCache.get(userId);
|
||||
if (cached != null) return cached;
|
||||
|
||||
// Try direct Logto lookup (works when userId is a real Logto ID)
|
||||
try {
|
||||
var user = logtoClient.getUser(userId);
|
||||
if (user != null) {
|
||||
String display = extractDisplayName(user);
|
||||
userNameCache.put(userId, display);
|
||||
return display;
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
log.debug("Direct user lookup failed for {}: {}", userId, e.getMessage());
|
||||
}
|
||||
|
||||
// userId is likely a synthetic UUID — build reverse map from all Logto users
|
||||
try {
|
||||
var allUsers = logtoClient.listUsers();
|
||||
for (var user : allUsers) {
|
||||
String logtoId = String.valueOf(user.get("id"));
|
||||
String display = extractDisplayName(user);
|
||||
String syntheticUuid = java.util.UUID.nameUUIDFromBytes(
|
||||
logtoId.getBytes(java.nio.charset.StandardCharsets.UTF_8)).toString();
|
||||
userNameCache.put(logtoId, display);
|
||||
userNameCache.put(syntheticUuid, display);
|
||||
}
|
||||
return userNameCache.getOrDefault(userId, userId);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to resolve actor name for {}: {}", userId, e.getMessage());
|
||||
return userId;
|
||||
}
|
||||
}
|
||||
|
||||
private static String extractDisplayName(java.util.Map<String, Object> user) {
|
||||
var email = user.get("primaryEmail");
|
||||
if (email != null && !email.toString().isBlank()) return email.toString();
|
||||
var username = user.get("username");
|
||||
if (username != null && !username.toString().isBlank()) return username.toString();
|
||||
var name = user.get("name");
|
||||
if (name != null && !name.toString().isBlank()) return name.toString();
|
||||
return String.valueOf(user.get("id"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -102,15 +102,15 @@ public class CertificateController {
|
||||
}
|
||||
|
||||
@PostMapping("/activate")
|
||||
public ResponseEntity<Void> activate() {
|
||||
certificateService.activate();
|
||||
public ResponseEntity<Void> activate(@AuthenticationPrincipal Jwt jwt) {
|
||||
certificateService.activate(resolveActorId(jwt));
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/restore")
|
||||
public ResponseEntity<Void> restore() {
|
||||
public ResponseEntity<Void> restore(@AuthenticationPrincipal Jwt jwt) {
|
||||
try {
|
||||
certificateService.restore();
|
||||
certificateService.restore(resolveActorId(jwt));
|
||||
return ResponseEntity.noContent().build();
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.badRequest().body(null);
|
||||
@@ -118,8 +118,8 @@ public class CertificateController {
|
||||
}
|
||||
|
||||
@DeleteMapping("/staged")
|
||||
public ResponseEntity<Void> discardStaged() {
|
||||
certificateService.discardStaged();
|
||||
public ResponseEntity<Void> discardStaged(@AuthenticationPrincipal Jwt jwt) {
|
||||
certificateService.discardStaged(resolveActorId(jwt));
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@@ -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,8 @@
|
||||
package net.siegeln.cameleer.saas.certificate;
|
||||
package io.cameleer.saas.certificate;
|
||||
|
||||
import net.siegeln.cameleer.saas.tenant.TenantRepository;
|
||||
import io.cameleer.saas.audit.AuditAction;
|
||||
import io.cameleer.saas.audit.AuditService;
|
||||
import io.cameleer.saas.tenant.TenantRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -8,6 +10,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@@ -18,13 +21,16 @@ public class CertificateService {
|
||||
private final CertificateManager certManager;
|
||||
private final CertificateRepository certRepository;
|
||||
private final TenantRepository tenantRepository;
|
||||
private final AuditService auditService;
|
||||
|
||||
public CertificateService(CertificateManager certManager,
|
||||
CertificateRepository certRepository,
|
||||
TenantRepository tenantRepository) {
|
||||
TenantRepository tenantRepository,
|
||||
AuditService auditService) {
|
||||
this.certManager = certManager;
|
||||
this.certRepository = certRepository;
|
||||
this.tenantRepository = tenantRepository;
|
||||
this.auditService = auditService;
|
||||
}
|
||||
|
||||
public record CertificateOverview(
|
||||
@@ -61,12 +67,17 @@ public class CertificateService {
|
||||
entity.setUploadedBy(actorId);
|
||||
certRepository.save(entity);
|
||||
|
||||
auditService.log(actorId, null, null, AuditAction.CERTIFICATE_STAGED, entity.getFingerprint(),
|
||||
null, null, "SUCCESS",
|
||||
Map.of("subject", result.info().subject(), "issuer", result.info().issuer(),
|
||||
"hasCa", result.info().hasCaBundle()));
|
||||
|
||||
log.info("Certificate staged by actor {}: subject={}", actorId, result.info().subject());
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void activate() {
|
||||
public void activate(UUID actorId) {
|
||||
var staged = certRepository.findByStatus(CertificateEntity.Status.STAGED)
|
||||
.orElseThrow(() -> new IllegalStateException("No staged certificate to activate"));
|
||||
|
||||
@@ -86,11 +97,14 @@ public class CertificateService {
|
||||
staged.setActivatedAt(Instant.now());
|
||||
certRepository.save(staged);
|
||||
|
||||
auditService.log(actorId, null, null, AuditAction.CERTIFICATE_ACTIVATED, staged.getFingerprint(),
|
||||
null, null, "SUCCESS", Map.of("subject", staged.getSubject()));
|
||||
|
||||
log.info("Certificate activated: subject={}", staged.getSubject());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void restore() {
|
||||
public void restore(UUID actorId) {
|
||||
var archived = certRepository.findByStatus(CertificateEntity.Status.ARCHIVED)
|
||||
.orElseThrow(() -> new IllegalStateException("No archived certificate to restore"));
|
||||
|
||||
@@ -115,13 +129,18 @@ public class CertificateService {
|
||||
certRepository.save(active);
|
||||
}
|
||||
|
||||
auditService.log(actorId, null, null, AuditAction.CERTIFICATE_RESTORED, archived.getFingerprint(),
|
||||
null, null, "SUCCESS", Map.of("subject", archived.getSubject()));
|
||||
|
||||
log.info("Certificate restored from archive: subject={}", archived.getSubject());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void discardStaged() {
|
||||
public void discardStaged(UUID actorId) {
|
||||
certManager.discardStaged();
|
||||
certRepository.findByStatus(CertificateEntity.Status.STAGED).ifPresent(certRepository::delete);
|
||||
auditService.log(actorId, null, null, AuditAction.CERTIFICATE_DISCARDED, "staged",
|
||||
null, null, "SUCCESS", null);
|
||||
log.info("Staged certificate discarded");
|
||||
}
|
||||
|
||||
@@ -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,8 @@
|
||||
package net.siegeln.cameleer.saas.certificate;
|
||||
package io.cameleer.saas.certificate;
|
||||
|
||||
import net.siegeln.cameleer.saas.provisioning.DockerCertificateManager;
|
||||
import io.cameleer.saas.audit.AuditAction;
|
||||
import io.cameleer.saas.audit.AuditService;
|
||||
import io.cameleer.saas.provisioning.DockerCertificateManager;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -13,6 +15,7 @@ import java.security.cert.X509Certificate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@@ -22,10 +25,13 @@ public class TenantCaCertService {
|
||||
|
||||
private final TenantCaCertRepository caCertRepository;
|
||||
private final CertificateManager certManager;
|
||||
private final AuditService auditService;
|
||||
|
||||
public TenantCaCertService(TenantCaCertRepository caCertRepository, CertificateManager certManager) {
|
||||
public TenantCaCertService(TenantCaCertRepository caCertRepository, CertificateManager certManager,
|
||||
AuditService auditService) {
|
||||
this.caCertRepository = caCertRepository;
|
||||
this.certManager = certManager;
|
||||
this.auditService = auditService;
|
||||
}
|
||||
|
||||
public List<TenantCaCertEntity> listForTenant(UUID tenantId) {
|
||||
@@ -33,7 +39,7 @@ public class TenantCaCertService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TenantCaCertEntity stage(UUID tenantId, String label, byte[] certPem) {
|
||||
public TenantCaCertEntity stage(UUID tenantId, String label, byte[] certPem, UUID actorId) {
|
||||
// Parse and validate
|
||||
X509Certificate cert;
|
||||
try {
|
||||
@@ -64,11 +70,14 @@ public class TenantCaCertService {
|
||||
|
||||
var saved = caCertRepository.save(entity);
|
||||
log.info("Staged tenant CA cert for tenant {}: subject={}", tenantId, entity.getSubject());
|
||||
auditService.log(actorId, null, tenantId, AuditAction.TENANT_CA_CERT_STAGED,
|
||||
saved.getId().toString(), null, null, "SUCCESS",
|
||||
Map.of("subject", saved.getSubject(), "fingerprint", saved.getFingerprint()));
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TenantCaCertEntity activate(UUID tenantId, UUID certId) {
|
||||
public TenantCaCertEntity activate(UUID tenantId, UUID certId, UUID actorId) {
|
||||
var entity = caCertRepository.findById(certId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("CA certificate not found"));
|
||||
if (!entity.getTenantId().equals(tenantId)) {
|
||||
@@ -83,11 +92,14 @@ public class TenantCaCertService {
|
||||
|
||||
rebuildCaBundle();
|
||||
log.info("Activated tenant CA cert {} for tenant {}", certId, tenantId);
|
||||
auditService.log(actorId, null, tenantId, AuditAction.TENANT_CA_CERT_ACTIVATED,
|
||||
certId.toString(), null, null, "SUCCESS",
|
||||
Map.of("subject", entity.getSubject()));
|
||||
return entity;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(UUID tenantId, UUID certId) {
|
||||
public void delete(UUID tenantId, UUID certId, UUID actorId) {
|
||||
var entity = caCertRepository.findById(certId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("CA certificate not found"));
|
||||
if (!entity.getTenantId().equals(tenantId)) {
|
||||
@@ -101,6 +113,9 @@ public class TenantCaCertService {
|
||||
rebuildCaBundle();
|
||||
}
|
||||
log.info("Deleted tenant CA cert {} for tenant {}", certId, tenantId);
|
||||
auditService.log(actorId, null, tenantId, AuditAction.TENANT_CA_CERT_DELETED,
|
||||
certId.toString(), null, null, "SUCCESS",
|
||||
Map.of("wasActive", wasActive));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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,5 +1,9 @@
|
||||
# Auth & Security Config
|
||||
|
||||
## User identity
|
||||
|
||||
**Email is the primary user identity** in SaaS mode. All users must have an email address — Logto enforces this via `signUp.identifiers: ["email"]` when registration is enabled. `SAAS_ADMIN_USER` IS the email address (no separate `SAAS_ADMIN_EMAIL`). The bootstrap creates the admin user with `SAAS_ADMIN_USER` as both username and `primaryEmail`. The installer enforces email format in SaaS mode. Self-service registration requires email verification via a configured email connector (vendor UI at `/vendor/email`).
|
||||
|
||||
## Auth enforcement
|
||||
|
||||
- All API endpoints enforce OAuth2 scopes via `@PreAuthorize("hasAuthority('SCOPE_xxx')")` annotations
|
||||
@@ -9,7 +13,24 @@
|
||||
- Org roles: `owner` -> `server:admin` + `tenant:manage`, `operator` -> `server:operator`, `viewer` -> `server:viewer`
|
||||
- `saas-vendor` global role created by bootstrap Phase 12 and always assigned to the admin user — has `platform:admin` + all tenant scopes
|
||||
- Custom `JwtDecoder` in `SecurityConfig.java` — ES384 algorithm, `at+jwt` token type, split issuer-uri (string validation) / jwk-set-uri (Docker-internal fetch), audience validation (`https://api.cameleer.local`)
|
||||
- Logto Custom JWT (Phase 7b in bootstrap) injects a `roles` claim into access tokens based on org roles and global roles — this makes role data available to the server without Logto-specific code
|
||||
- Logto Custom JWT (Phase 7b in bootstrap) injects claims into access tokens: `roles` (org role mapping), `mfa_enrolled` (true if TOTP or WebAuthn factor), `passkey_enrolled` (true if WebAuthn factor), `mfa_method_preference` (from user custom data)
|
||||
|
||||
## MFA & Passkey enforcement
|
||||
|
||||
Two independent policy domains — **no inheritance**, vendor and tenant policies are separate scopes:
|
||||
|
||||
| Policy | Scope | Who it affects | Stored in |
|
||||
|--------|-------|---------------|-----------|
|
||||
| **Vendor auth policy** | Platform logins (`/api/vendor/**`, `/api/portal/**`) | Tenant admins | `vendor_auth_policy` table (single-row) |
|
||||
| **Tenant auth policy** | Org user logins (`/api/tenant/**`) | Org members | `tenants.settings` JSONB (`mfaMode`, `passkeyEnabled`, `passkeyMode`) |
|
||||
|
||||
- `MfaEnforcementFilter` (after `BearerTokenAuthenticationFilter`) checks JWT claims `mfa_enrolled` and `passkey_enrolled` against effective policy
|
||||
- Error codes: `APP_MFA_REQUIRED`, `APP_PASSKEY_REQUIRED` (returned via `X-Cameleer-Error` header)
|
||||
- Exempt routes: `/api/tenant/mfa/`, `/api/config`, `/api/me`, `/api/onboarding`, `/api/vendor/auth-policy`, `/api/tenant/auth-settings`
|
||||
- Backward-compatible: legacy `mfaRequired: true` in settings treated as `mfaMode: "required"`
|
||||
- Policy settings: `mfa_mode` (off/optional/required), `passkey_enabled` (bool), `passkey_mode` (optional/preferred/required)
|
||||
- Passkey registration only via Logto Experience API during sign-in (Approach A: Logto-native WebAuthn)
|
||||
- Passkey management (list/rename/delete) via Logto Management API through SaaS backend endpoints
|
||||
|
||||
## Auth routing by persona
|
||||
|
||||
@@ -37,6 +58,21 @@ The server's OIDC config (`OidcConfig`) includes `audience` (RFC 8707 resource i
|
||||
|
||||
**CRITICAL:** `additionalScopes` MUST include `urn:logto:scope:organizations` and `urn:logto:scope:organization_roles` — without these, Logto doesn't populate `context.user.organizationRoles` in the Custom JWT script, so the `roles` claim is empty and all users get `defaultRoles` (VIEWER). The server's `OidcAuthController.applyClaimMappings()` uses OIDC token roles (from Custom JWT) as fallback when no DB claim mapping rules exist: claim mapping rules > OIDC token roles > defaultRoles.
|
||||
|
||||
## Logto webhook integration
|
||||
|
||||
Logto auth events (sign-in, registration, password reset) are captured via a webhook registered at startup:
|
||||
- `LogtoStartupConfig` idempotently registers a webhook named `cameleer-saas-auth-events` with Logto on app startup
|
||||
- Webhook URL: `http://cameleer-saas:8080/platform/api/internal/webhooks/logto` (Docker-internal)
|
||||
- `LogtoWebhookController` at `/api/internal/webhooks/logto` receives events, validates HMAC-SHA256 signature, writes to `audit_log`
|
||||
- Events: `PostSignIn` → `AUTH_LOGIN`, `PostRegister` → `AUTH_REGISTER`, `PostResetPassword` → `AUTH_LOGIN` (with `via: password_reset` metadata)
|
||||
- `/api/internal/**` is permitted without JWT in SecurityConfig — validated by HMAC instead
|
||||
- Logto auto-generates a signing key per webhook; `LogtoStartupConfig` captures it from the create/list response and stores it in `LogtoConfig.webhookSigningKey` for HMAC verification at runtime
|
||||
- Logto only fires webhooks on successful interactions — failed logins are not captured
|
||||
|
||||
## SOC 2 audit logging
|
||||
|
||||
All security-relevant operations are logged to the `audit_log` table via `AuditService.log()`. The audit_log table is protected by Flyway V006 triggers that prevent UPDATE and DELETE (immutability). 37 operations are covered across: vendor admin lifecycle, platform auth policy, email connector, certificates, tenant CA certs, account security (password, MFA, passkeys), team management, server operations, SSO connectors, and tenant settings. Auth events are captured via the Logto webhook above.
|
||||
|
||||
## SaaS app identity configuration
|
||||
|
||||
**Identity** (`cameleer.saas.identity.*` / `CAMELEER_SAAS_IDENTITY_*`):
|
||||
@@ -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,76 @@
|
||||
package io.cameleer.saas.config;
|
||||
|
||||
import io.cameleer.saas.identity.LogtoConfig;
|
||||
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;
|
||||
|
||||
@Component
|
||||
public class LogtoStartupConfig {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LogtoStartupConfig.class);
|
||||
private static final String WEBHOOK_NAME = "cameleer-saas-auth-events";
|
||||
|
||||
private final LogtoManagementClient logtoClient;
|
||||
private final LogtoConfig logtoConfig;
|
||||
|
||||
public LogtoStartupConfig(LogtoManagementClient logtoClient, LogtoConfig logtoConfig) {
|
||||
this.logtoClient = logtoClient;
|
||||
this.logtoConfig = logtoConfig;
|
||||
}
|
||||
|
||||
@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());
|
||||
}
|
||||
|
||||
registerWebhook();
|
||||
}
|
||||
|
||||
private void registerWebhook() {
|
||||
try {
|
||||
var existing = logtoClient.listWebhooks();
|
||||
var found = existing.stream()
|
||||
.filter(h -> WEBHOOK_NAME.equals(h.get("name")))
|
||||
.findFirst();
|
||||
|
||||
if (found.isPresent()) {
|
||||
log.info("Logto webhook '{}' already registered", WEBHOOK_NAME);
|
||||
storeSigningKey(found.get());
|
||||
return;
|
||||
}
|
||||
|
||||
String url = "http://cameleer-saas:8080/platform/api/internal/webhooks/logto";
|
||||
|
||||
var events = List.of("PostSignIn", "PostRegister", "PostResetPassword");
|
||||
var created = logtoClient.createWebhook(WEBHOOK_NAME, events, url);
|
||||
storeSigningKey(created);
|
||||
log.info("Registered Logto webhook '{}' for events {}", WEBHOOK_NAME, events);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to register Logto webhook: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void storeSigningKey(Map<String, Object> hook) {
|
||||
if (hook == null) return;
|
||||
Object key = hook.get("signingKey");
|
||||
if (key instanceof String s && !s.isBlank()) {
|
||||
logtoConfig.setWebhookSigningKey(s);
|
||||
log.info("Stored Logto webhook signing key for HMAC verification");
|
||||
} else {
|
||||
log.warn("Logto webhook response did not contain a signing key — HMAC verification will fail");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
163
src/main/java/io/cameleer/saas/config/MfaEnforcementFilter.java
Normal file
163
src/main/java/io/cameleer/saas/config/MfaEnforcementFilter.java
Normal file
@@ -0,0 +1,163 @@
|
||||
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 io.cameleer.saas.tenant.TenantService;
|
||||
import io.cameleer.saas.vendor.VendorAuthPolicyRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Component
|
||||
public class MfaEnforcementFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MfaEnforcementFilter.class);
|
||||
private static final Set<String> EXEMPT_PREFIXES = Set.of(
|
||||
"/api/tenant/mfa/",
|
||||
"/api/account/mfa/",
|
||||
"/api/account/profile",
|
||||
"/api/account/password",
|
||||
"/api/config",
|
||||
"/api/me",
|
||||
"/api/onboarding",
|
||||
"/api/vendor/auth-policy",
|
||||
"/api/tenant/auth-settings"
|
||||
);
|
||||
|
||||
private final TenantService tenantService;
|
||||
private final VendorAuthPolicyRepository vendorPolicyRepo;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public MfaEnforcementFilter(TenantService tenantService,
|
||||
VendorAuthPolicyRepository vendorPolicyRepo,
|
||||
ObjectMapper objectMapper) {
|
||||
this.tenantService = tenantService;
|
||||
this.vendorPolicyRepo = vendorPolicyRepo;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
String path = request.getServletPath();
|
||||
boolean isProtected = path.startsWith("/api/tenant/")
|
||||
|| path.startsWith("/api/vendor/")
|
||||
|| path.startsWith("/api/portal/");
|
||||
if (!isProtected) return true;
|
||||
return EXEMPT_PREFIXES.stream().anyMatch(path::startsWith);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
var auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (!(auth instanceof JwtAuthenticationToken jwtAuth)) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
Jwt jwt = jwtAuth.getToken();
|
||||
String path = request.getServletPath();
|
||||
|
||||
if (path.startsWith("/api/vendor/") || path.startsWith("/api/portal/")) {
|
||||
enforceVendorPolicy(jwt, request, response, filterChain);
|
||||
} else if (path.startsWith("/api/tenant/")) {
|
||||
enforceTenantPolicy(jwt, request, response, filterChain);
|
||||
} else {
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
|
||||
private void enforceVendorPolicy(Jwt jwt, HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
var policy = vendorPolicyRepo.getPolicy();
|
||||
Boolean mfaEnrolled = jwt.getClaim("mfa_enrolled");
|
||||
Boolean passkeyEnrolled = jwt.getClaim("passkey_enrolled");
|
||||
|
||||
if ("required".equals(policy.getMfaMode()) && !Boolean.TRUE.equals(mfaEnrolled)) {
|
||||
log.info("MFA enforcement (vendor): blocking user {} — vendor policy requires MFA", jwt.getSubject());
|
||||
writeError(response, "APP_MFA_REQUIRED", "mfa_enrollment_required",
|
||||
"Platform authentication policy requires multi-factor authentication");
|
||||
return;
|
||||
}
|
||||
|
||||
if (policy.isPasskeyEnabled() && "required".equals(policy.getPasskeyMode())
|
||||
&& !Boolean.TRUE.equals(passkeyEnrolled)) {
|
||||
log.info("Passkey enforcement (vendor): blocking user {} — vendor policy requires passkey", jwt.getSubject());
|
||||
writeError(response, "APP_PASSKEY_REQUIRED", "passkey_enrollment_required",
|
||||
"Platform authentication policy requires a passkey");
|
||||
return;
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private void enforceTenantPolicy(Jwt jwt, HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
Boolean mfaEnrolled = jwt.getClaim("mfa_enrolled");
|
||||
Boolean passkeyEnrolled = jwt.getClaim("passkey_enrolled");
|
||||
|
||||
String orgId = jwt.getClaimAsString("organization_id");
|
||||
if (orgId == null) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
var tenant = tenantService.getByLogtoOrgId(orgId).orElse(null);
|
||||
if (tenant == null) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, Object> settings = tenant.getSettings() != null ? tenant.getSettings() : Map.of();
|
||||
|
||||
String mfaMode = settings.containsKey("mfaMode")
|
||||
? String.valueOf(settings.get("mfaMode"))
|
||||
: (Boolean.TRUE.equals(settings.get("mfaRequired")) ? "required" : "off");
|
||||
|
||||
if ("required".equals(mfaMode) && !Boolean.TRUE.equals(mfaEnrolled)) {
|
||||
log.info("MFA enforcement: blocking user {} — tenant {} requires MFA", jwt.getSubject(), tenant.getSlug());
|
||||
writeError(response, "APP_MFA_REQUIRED", "mfa_enrollment_required",
|
||||
"Your organization requires multi-factor authentication");
|
||||
return;
|
||||
}
|
||||
|
||||
boolean passkeyEnabled = Boolean.TRUE.equals(settings.get("passkeyEnabled"));
|
||||
String passkeyMode = settings.containsKey("passkeyMode")
|
||||
? String.valueOf(settings.get("passkeyMode"))
|
||||
: "optional";
|
||||
|
||||
if (passkeyEnabled && "required".equals(passkeyMode) && !Boolean.TRUE.equals(passkeyEnrolled)) {
|
||||
log.info("Passkey enforcement: blocking user {} — tenant {} requires passkey", jwt.getSubject(), tenant.getSlug());
|
||||
writeError(response, "APP_PASSKEY_REQUIRED", "passkey_enrollment_required",
|
||||
"Your organization requires a passkey");
|
||||
return;
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private void writeError(HttpServletResponse response, String errorCode, String code, String message)
|
||||
throws IOException {
|
||||
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setHeader("X-Cameleer-Error", errorCode);
|
||||
objectMapper.writeValue(response.getOutputStream(), Map.of(
|
||||
"error", errorCode,
|
||||
"code", code,
|
||||
"message", message
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +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 io.cameleer.saas.vendor.VendorAuthPolicyRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -25,6 +26,11 @@ public class PublicConfigController {
|
||||
private String spaClientId;
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final VendorAuthPolicyRepository vendorPolicyRepo;
|
||||
|
||||
public PublicConfigController(VendorAuthPolicyRepository vendorPolicyRepo) {
|
||||
this.vendorPolicyRepo = vendorPolicyRepo;
|
||||
}
|
||||
|
||||
private static final List<String> SCOPES = List.of(
|
||||
"platform:admin",
|
||||
@@ -61,11 +67,19 @@ public class PublicConfigController {
|
||||
endpoint = "http://localhost:3001";
|
||||
}
|
||||
|
||||
var policy = vendorPolicyRepo.getPolicy();
|
||||
var vendorAuthPolicy = Map.of(
|
||||
"mfaMode", policy.getMfaMode(),
|
||||
"passkeyEnabled", policy.isPasskeyEnabled(),
|
||||
"passkeyMode", policy.getPasskeyMode()
|
||||
);
|
||||
|
||||
return Map.of(
|
||||
"logtoEndpoint", endpoint,
|
||||
"logtoClientId", clientId != null ? clientId : "",
|
||||
"logtoResource", apiResource,
|
||||
"scopes", SCOPES
|
||||
"scopes", SCOPES,
|
||||
"vendorAuthPolicy", vendorAuthPolicy
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -24,6 +24,7 @@ import org.springframework.security.oauth2.jwt.JwtIssuerValidator;
|
||||
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
|
||||
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationFilter;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
import java.net.URL;
|
||||
@@ -36,7 +37,7 @@ import java.util.List;
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
public SecurityFilterChain filterChain(HttpSecurity http, MfaEnforcementFilter mfaEnforcementFilter) throws Exception {
|
||||
http
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
@@ -44,16 +45,20 @@ public class SecurityConfig {
|
||||
.requestMatchers("/actuator/health").permitAll()
|
||||
.requestMatchers("/api/config").permitAll()
|
||||
.requestMatchers("/", "/index.html", "/login", "/register", "/callback",
|
||||
"/vendor/**", "/tenant/**", "/onboarding",
|
||||
"/vendor/**", "/tenant/**", "/onboarding", "/settings/**",
|
||||
"/environments/**", "/license", "/admin/**").permitAll()
|
||||
.requestMatchers("/_app/**", "/favicon.ico", "/favicon.svg", "/logo.svg", "/logo-dark.svg").permitAll()
|
||||
.requestMatchers("/_app/**", "/assets/**", "/favicon.ico", "/favicon.svg", "/logo.svg", "/logo-dark.svg").permitAll()
|
||||
.requestMatchers("/api/password-reset-notification").permitAll()
|
||||
.requestMatchers("/api/internal/**").permitAll()
|
||||
.requestMatchers("/api/account/**").authenticated()
|
||||
.requestMatchers("/api/onboarding/**").authenticated()
|
||||
.requestMatchers("/api/vendor/**").hasAuthority("SCOPE_platform:admin")
|
||||
.requestMatchers("/api/tenant/**").authenticated()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt ->
|
||||
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())));
|
||||
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())))
|
||||
.addFilterAfter(mfaEnforcementFilter, BearerTokenAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -28,6 +28,9 @@ public class LogtoConfig {
|
||||
@Value("${cameleer.saas.identity.serverendpoint:http://cameleer-server:8081}")
|
||||
private String serverEndpoint;
|
||||
|
||||
/** Logto-generated signing key, populated at startup after webhook registration/discovery. */
|
||||
private volatile String webhookSigningKey;
|
||||
|
||||
private String tradAppId;
|
||||
private String tradAppSecret;
|
||||
|
||||
@@ -63,6 +66,8 @@ public class LogtoConfig {
|
||||
public String getM2mClientId() { return m2mClientId; }
|
||||
public String getM2mClientSecret() { return m2mClientSecret; }
|
||||
public String getServerEndpoint() { return serverEndpoint; }
|
||||
public String getWebhookSigningKey() { return webhookSigningKey; }
|
||||
public void setWebhookSigningKey(String key) { this.webhookSigningKey = key; }
|
||||
public String getTradAppId() { return tradAppId; }
|
||||
public String getTradAppSecret() { return tradAppSecret; }
|
||||
|
||||
@@ -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,22 +209,72 @@ 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"));
|
||||
addUserToOrganization(orgId, userId);
|
||||
if (roleId != null) {
|
||||
assignOrganizationRole(orgId, userId, roleId);
|
||||
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, "username", email.split("@")[0], "name", email.split("@")[0]))
|
||||
.retrieve()
|
||||
.body(Map.class);
|
||||
userId = String.valueOf(userResp.get("id"));
|
||||
}
|
||||
if (orgId != null) {
|
||||
addUserToOrganization(orgId, userId);
|
||||
if (roleId != null) {
|
||||
assignOrganizationRole(orgId, userId, roleId);
|
||||
}
|
||||
}
|
||||
return userId;
|
||||
} catch (Exception e) {
|
||||
@@ -233,7 +283,7 @@ public class LogtoManagementClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a user with username/password and add to org with role. */
|
||||
/** Create a user with username/password and optionally add to org with role. */
|
||||
@SuppressWarnings("unchecked")
|
||||
public String createUserWithPassword(String username, String password, String orgId, String roleId) {
|
||||
if (!isAvailable()) return null;
|
||||
@@ -246,9 +296,11 @@ public class LogtoManagementClient {
|
||||
.retrieve()
|
||||
.body(Map.class);
|
||||
String userId = String.valueOf(userResp.get("id"));
|
||||
addUserToOrganization(orgId, userId);
|
||||
if (roleId != null) {
|
||||
assignOrganizationRole(orgId, userId, roleId);
|
||||
if (orgId != null) {
|
||||
addUserToOrganization(orgId, userId);
|
||||
if (roleId != null) {
|
||||
assignOrganizationRole(orgId, userId, roleId);
|
||||
}
|
||||
}
|
||||
log.info("Created user '{}' and added to org {} with role {}", username, orgId, roleId);
|
||||
return userId;
|
||||
@@ -526,6 +578,157 @@ public class LogtoManagementClient {
|
||||
.toBodilessEntity();
|
||||
}
|
||||
|
||||
/** Verify a user's current password. Returns true if correct, false if wrong. */
|
||||
public boolean verifyUserPassword(String userId, String password) {
|
||||
try {
|
||||
var token = getAccessToken();
|
||||
restClient.post()
|
||||
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/password/verify")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(Map.of("password", password))
|
||||
.retrieve()
|
||||
.toBodilessEntity();
|
||||
return true;
|
||||
} catch (org.springframework.web.client.HttpClientErrorException e) {
|
||||
if (e.getStatusCode().value() == 422 || e.getStatusCode().value() == 400) {
|
||||
return false;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// --- MFA Verification Management ---
|
||||
|
||||
/** List all MFA verifications for a user. Returns a list of MFA factor objects. */
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<Map<String, Object>> getUserMfaVerifications(String userId) {
|
||||
if (!isAvailable()) return List.of();
|
||||
try {
|
||||
var resp = restClient.get()
|
||||
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/mfa-verifications")
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.retrieve()
|
||||
.body(List.class);
|
||||
return resp != null ? resp : List.of();
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to get MFA verifications for user {}: {}", userId, e.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a TOTP MFA verification for a user. Returns the secret and QR code. */
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> createTotpVerification(String userId, String secret) {
|
||||
if (!isAvailable()) return Map.of();
|
||||
try {
|
||||
return (Map<String, Object>) restClient.post()
|
||||
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/mfa-verifications")
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(Map.of("type", "Totp", "secret", secret))
|
||||
.retrieve()
|
||||
.body(Map.class);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to create TOTP verification for user {}: {}", userId, e.getMessage());
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate backup codes for a user. Returns the list of codes. */
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> createBackupCodes(String userId) {
|
||||
if (!isAvailable()) return Map.of();
|
||||
try {
|
||||
return (Map<String, Object>) restClient.post()
|
||||
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/mfa-verifications")
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(Map.of("type", "BackupCode"))
|
||||
.retrieve()
|
||||
.body(Map.class);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to create backup codes for user {}: {}", userId, e.getMessage());
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete a specific MFA verification for a user. */
|
||||
public void deleteMfaVerification(String userId, String verificationId) {
|
||||
if (!isAvailable()) return;
|
||||
try {
|
||||
restClient.delete()
|
||||
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/mfa-verifications/" + verificationId)
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.retrieve()
|
||||
.toBodilessEntity();
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to delete MFA verification {} for user {}: {}", verificationId, userId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete all MFA verifications for a user (used for admin MFA reset). */
|
||||
public void deleteAllMfaVerifications(String userId) {
|
||||
List<Map<String, Object>> verifications = getUserMfaVerifications(userId);
|
||||
for (Map<String, Object> v : verifications) {
|
||||
String id = String.valueOf(v.get("id"));
|
||||
deleteMfaVerification(userId, id);
|
||||
}
|
||||
}
|
||||
|
||||
/** List WebAuthn credentials for a user (filtered from all MFA verifications). */
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<Map<String, Object>> getWebAuthnCredentials(String userId) {
|
||||
var all = getUserMfaVerifications(userId);
|
||||
return all.stream()
|
||||
.filter(v -> "WebAuthn".equals(String.valueOf(v.get("type"))))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** Rename a WebAuthn credential. Uses PATCH on the MFA verification. */
|
||||
public void renameMfaVerification(String userId, String verificationId, String name) {
|
||||
if (!isAvailable()) return;
|
||||
try {
|
||||
restClient.patch()
|
||||
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/mfa-verifications/" + verificationId)
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(Map.of("name", name))
|
||||
.retrieve()
|
||||
.toBodilessEntity();
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to rename MFA verification {} for user {}: {}", verificationId, userId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Update user custom data (partial merge). Used for mfa_method_preference. */
|
||||
public void updateUserCustomData(String userId, Map<String, Object> customData) {
|
||||
if (!isAvailable()) return;
|
||||
try {
|
||||
restClient.patch()
|
||||
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/custom-data")
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(customData)
|
||||
.retrieve()
|
||||
.toBodilessEntity();
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to update custom data for user {}: {}", userId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Update a user's profile fields (e.g. name). */
|
||||
public void updateUserProfile(String userId, Map<String, Object> profile) {
|
||||
if (!isAvailable()) throw new IllegalStateException("Logto not configured");
|
||||
restClient.patch()
|
||||
.uri(config.getLogtoEndpoint() + "/api/users/" + userId)
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(profile)
|
||||
.retrieve()
|
||||
.toBodilessEntity();
|
||||
}
|
||||
|
||||
/** Get a user by ID. Returns username, primaryEmail, name. */
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> getUser(String userId) {
|
||||
@@ -542,6 +745,107 @@ public class LogtoManagementClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** List all users (first 200). Used to build reverse lookup caches. */
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<Map<String, Object>> listUsers() {
|
||||
if (!isAvailable()) return List.of();
|
||||
try {
|
||||
var resp = restClient.get()
|
||||
.uri(config.getLogtoEndpoint() + "/api/users?page=1&page_size=200")
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.retrieve()
|
||||
.body(List.class);
|
||||
return resp != null ? resp : List.of();
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to list users: {}", e.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Webhook Management ---
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<Map<String, Object>> listWebhooks() {
|
||||
if (!isAvailable()) return List.of();
|
||||
var response = restClient.get()
|
||||
.uri(config.getLogtoEndpoint() + "/api/hooks")
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.retrieve()
|
||||
.body(List.class);
|
||||
return response != null ? response : List.of();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> createWebhook(String name, List<String> events, String url) {
|
||||
if (!isAvailable()) return null;
|
||||
var body = Map.of(
|
||||
"name", name,
|
||||
"events", events,
|
||||
"config", Map.of("url", url)
|
||||
);
|
||||
return restClient.post()
|
||||
.uri(config.getLogtoEndpoint() + "/api/hooks")
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(body)
|
||||
.retrieve()
|
||||
.body(Map.class);
|
||||
}
|
||||
|
||||
// --- Global Role Management ---
|
||||
|
||||
/** List all users assigned to a global role. */
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<Map<String, Object>> listRoleUsers(String roleId) {
|
||||
var token = getAccessToken();
|
||||
var response = restClient.get()
|
||||
.uri(config.getLogtoEndpoint() + "/api/roles/" + roleId + "/users")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.retrieve()
|
||||
.body(List.class);
|
||||
return response != null ? response : List.of();
|
||||
}
|
||||
|
||||
/** Find a global role by exact name. Returns null if not found. */
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> getRoleByName(String roleName) {
|
||||
var token = getAccessToken();
|
||||
var response = restClient.get()
|
||||
.uri(config.getLogtoEndpoint() + "/api/roles?search=" +
|
||||
java.net.URLEncoder.encode(roleName, java.nio.charset.StandardCharsets.UTF_8) +
|
||||
"&page_size=20")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.retrieve()
|
||||
.body(List.class);
|
||||
if (response == null) return null;
|
||||
return ((List<Map<String, Object>>) response).stream()
|
||||
.filter(r -> roleName.equals(r.get("name")))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/** Assign a global role to a user. */
|
||||
public void assignGlobalRole(String userId, String roleId) {
|
||||
var token = getAccessToken();
|
||||
restClient.post()
|
||||
.uri(config.getLogtoEndpoint() + "/api/roles/" + roleId + "/users")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(Map.of("userIds", List.of(userId)))
|
||||
.retrieve()
|
||||
.toBodilessEntity();
|
||||
}
|
||||
|
||||
/** Revoke a global role from a user. */
|
||||
public void revokeGlobalRole(String userId, String roleId) {
|
||||
var token = getAccessToken();
|
||||
restClient.delete()
|
||||
.uri(config.getLogtoEndpoint() + "/api/roles/" + roleId + "/users/" + userId)
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.retrieve()
|
||||
.toBodilessEntity();
|
||||
}
|
||||
|
||||
private static final String MGMT_API_RESOURCE = "https://default.logto.app/api";
|
||||
|
||||
private synchronized String getAccessToken() {
|
||||
@@ -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,35 +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;
|
||||
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;
|
||||
@@ -55,15 +55,6 @@ public class LicenseController {
|
||||
}
|
||||
|
||||
private LicenseResponse toResponse(LicenseEntity entity) {
|
||||
return new LicenseResponse(
|
||||
entity.getId(),
|
||||
entity.getTenantId(),
|
||||
entity.getTier(),
|
||||
entity.getFeatures(),
|
||||
entity.getLimits(),
|
||||
entity.getIssuedAt(),
|
||||
entity.getExpiresAt(),
|
||||
entity.getToken()
|
||||
);
|
||||
return LicenseResponse.from(entity);
|
||||
}
|
||||
}
|
||||
78
src/main/java/io/cameleer/saas/license/LicenseDefaults.java
Normal file
78
src/main/java/io/cameleer/saas/license/LicenseDefaults.java
Normal file
@@ -0,0 +1,78 @@
|
||||
package io.cameleer.saas.license;
|
||||
|
||||
import io.cameleer.saas.tenant.Tier;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public final class LicenseDefaults {
|
||||
|
||||
private LicenseDefaults() {}
|
||||
|
||||
public static final int DEFAULT_GRACE_PERIOD_DAYS = 14;
|
||||
public static final int DEFAULT_LICENSE_DAYS = 365;
|
||||
|
||||
public static Map<String, Integer> limitsForTier(Tier tier) {
|
||||
return switch (tier) {
|
||||
case STARTER -> Map.ofEntries(
|
||||
Map.entry("max_environments", 2),
|
||||
Map.entry("max_apps", 10),
|
||||
Map.entry("max_agents", 20),
|
||||
Map.entry("max_users", 5),
|
||||
Map.entry("max_outbound_connections", 5),
|
||||
Map.entry("max_alert_rules", 10),
|
||||
Map.entry("max_total_cpu_millis", 8000),
|
||||
Map.entry("max_total_memory_mb", 8192),
|
||||
Map.entry("max_total_replicas", 25),
|
||||
Map.entry("max_execution_retention_days", 7),
|
||||
Map.entry("max_log_retention_days", 7),
|
||||
Map.entry("max_metric_retention_days", 7),
|
||||
Map.entry("max_jar_retention_count", 5)
|
||||
);
|
||||
case TEAM -> Map.ofEntries(
|
||||
Map.entry("max_environments", 5),
|
||||
Map.entry("max_apps", 50),
|
||||
Map.entry("max_agents", 100),
|
||||
Map.entry("max_users", 25),
|
||||
Map.entry("max_outbound_connections", 25),
|
||||
Map.entry("max_alert_rules", 50),
|
||||
Map.entry("max_total_cpu_millis", 32000),
|
||||
Map.entry("max_total_memory_mb", 32768),
|
||||
Map.entry("max_total_replicas", 100),
|
||||
Map.entry("max_execution_retention_days", 30),
|
||||
Map.entry("max_log_retention_days", 30),
|
||||
Map.entry("max_metric_retention_days", 30),
|
||||
Map.entry("max_jar_retention_count", 10)
|
||||
);
|
||||
case BUSINESS -> Map.ofEntries(
|
||||
Map.entry("max_environments", 10),
|
||||
Map.entry("max_apps", 200),
|
||||
Map.entry("max_agents", 500),
|
||||
Map.entry("max_users", 100),
|
||||
Map.entry("max_outbound_connections", 100),
|
||||
Map.entry("max_alert_rules", 200),
|
||||
Map.entry("max_total_cpu_millis", 128000),
|
||||
Map.entry("max_total_memory_mb", 131072),
|
||||
Map.entry("max_total_replicas", 500),
|
||||
Map.entry("max_execution_retention_days", 90),
|
||||
Map.entry("max_log_retention_days", 90),
|
||||
Map.entry("max_metric_retention_days", 90),
|
||||
Map.entry("max_jar_retention_count", 25)
|
||||
);
|
||||
case ENTERPRISE -> Map.ofEntries(
|
||||
Map.entry("max_environments", 50),
|
||||
Map.entry("max_apps", 1000),
|
||||
Map.entry("max_agents", 5000),
|
||||
Map.entry("max_users", 1000),
|
||||
Map.entry("max_outbound_connections", 500),
|
||||
Map.entry("max_alert_rules", 1000),
|
||||
Map.entry("max_total_cpu_millis", 512000),
|
||||
Map.entry("max_total_memory_mb", 524288),
|
||||
Map.entry("max_total_replicas", 2000),
|
||||
Map.entry("max_execution_retention_days", 365),
|
||||
Map.entry("max_log_retention_days", 180),
|
||||
Map.entry("max_metric_retention_days", 180),
|
||||
Map.entry("max_jar_retention_count", 50)
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package net.siegeln.cameleer.saas.license;
|
||||
package io.cameleer.saas.license;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
@@ -28,14 +28,16 @@ public class LicenseEntity {
|
||||
@Column(name = "tier", nullable = false, length = 20)
|
||||
private String tier;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "features", nullable = false, columnDefinition = "jsonb")
|
||||
private Map<String, Object> features;
|
||||
@Column(name = "label")
|
||||
private String label;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "limits", nullable = false, columnDefinition = "jsonb")
|
||||
private Map<String, Object> limits;
|
||||
|
||||
@Column(name = "grace_period_days", nullable = false)
|
||||
private int gracePeriodDays;
|
||||
|
||||
@Column(name = "issued_at", nullable = false)
|
||||
private Instant issuedAt;
|
||||
|
||||
@@ -62,10 +64,12 @@ public class LicenseEntity {
|
||||
public void setTenantId(UUID tenantId) { this.tenantId = tenantId; }
|
||||
public String getTier() { return tier; }
|
||||
public void setTier(String tier) { this.tier = tier; }
|
||||
public Map<String, Object> getFeatures() { return features; }
|
||||
public void setFeatures(Map<String, Object> features) { this.features = features; }
|
||||
public String getLabel() { return label; }
|
||||
public void setLabel(String label) { this.label = label; }
|
||||
public Map<String, Object> getLimits() { return limits; }
|
||||
public void setLimits(Map<String, Object> limits) { this.limits = limits; }
|
||||
public int getGracePeriodDays() { return gracePeriodDays; }
|
||||
public void setGracePeriodDays(int gracePeriodDays) { this.gracePeriodDays = gracePeriodDays; }
|
||||
public Instant getIssuedAt() { return issuedAt; }
|
||||
public void setIssuedAt(Instant issuedAt) { this.issuedAt = issuedAt; }
|
||||
public Instant getExpiresAt() { return expiresAt; }
|
||||
@@ -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;
|
||||
144
src/main/java/io/cameleer/saas/license/LicenseService.java
Normal file
144
src/main/java/io/cameleer/saas/license/LicenseService.java
Normal file
@@ -0,0 +1,144 @@
|
||||
package io.cameleer.saas.license;
|
||||
|
||||
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;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class LicenseService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LicenseService.class);
|
||||
|
||||
private final LicenseRepository licenseRepository;
|
||||
private final AuditService auditService;
|
||||
private final SigningKeyService signingKeyService;
|
||||
|
||||
public LicenseService(LicenseRepository licenseRepository,
|
||||
AuditService auditService,
|
||||
SigningKeyService signingKeyService) {
|
||||
this.licenseRepository = licenseRepository;
|
||||
this.auditService = auditService;
|
||||
this.signingKeyService = signingKeyService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint an Ed25519-signed license with full control over limits.
|
||||
*/
|
||||
public LicenseEntity generateLicense(TenantEntity tenant,
|
||||
Map<String, Integer> limits,
|
||||
Instant expiresAt,
|
||||
int gracePeriodDays,
|
||||
String label,
|
||||
UUID actorId) {
|
||||
Instant now = Instant.now();
|
||||
UUID licenseId = UUID.randomUUID();
|
||||
|
||||
LicenseInfo info = new LicenseInfo(
|
||||
licenseId,
|
||||
tenant.getSlug(),
|
||||
label,
|
||||
limits,
|
||||
now,
|
||||
expiresAt,
|
||||
gracePeriodDays
|
||||
);
|
||||
|
||||
String token = LicenseMinter.mint(info, signingKeyService.getPrivateKey());
|
||||
|
||||
var entity = new LicenseEntity();
|
||||
entity.setTenantId(tenant.getId());
|
||||
entity.setTier(tenant.getTier().name());
|
||||
entity.setLabel(label);
|
||||
entity.setLimits(new HashMap<>(limits));
|
||||
entity.setGracePeriodDays(gracePeriodDays);
|
||||
entity.setIssuedAt(now);
|
||||
entity.setExpiresAt(expiresAt);
|
||||
entity.setToken(token);
|
||||
|
||||
var saved = licenseRepository.save(entity);
|
||||
|
||||
auditService.log(actorId, null, tenant.getId(),
|
||||
AuditAction.LICENSE_GENERATE, saved.getId().toString(),
|
||||
null, null, "SUCCESS", null);
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience overload using tier presets and default validity.
|
||||
*/
|
||||
public LicenseEntity generateLicense(TenantEntity tenant, Duration validity, UUID actorId) {
|
||||
var limits = LicenseDefaults.limitsForTier(tenant.getTier());
|
||||
Instant expiresAt = Instant.now().plus(validity);
|
||||
String label = tenant.getName() + " (" + tenant.getTier().name() + ")";
|
||||
return generateLicense(tenant, limits, expiresAt,
|
||||
LicenseDefaults.DEFAULT_GRACE_PERIOD_DAYS, label, actorId);
|
||||
}
|
||||
|
||||
public Optional<LicenseEntity> getActiveLicense(UUID tenantId) {
|
||||
return licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId);
|
||||
}
|
||||
|
||||
public void revokeLicense(UUID tenantId, UUID actorId) {
|
||||
licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId)
|
||||
.ifPresent(license -> {
|
||||
license.setRevokedAt(Instant.now());
|
||||
licenseRepository.save(license);
|
||||
auditService.log(actorId, null, tenantId,
|
||||
AuditAction.LICENSE_REVOKE, "license",
|
||||
null, null, null, Map.of("licenseId", license.getId().toString()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a signed license token using the stored public key.
|
||||
* Returns the parsed LicenseInfo if valid, or empty if invalid.
|
||||
*/
|
||||
public Optional<LicenseInfo> verifyToken(String token, String expectedTenantId) {
|
||||
try {
|
||||
String publicKeyB64 = signingKeyService.getPublicKeyBase64();
|
||||
LicenseValidator validator = new LicenseValidator(publicKeyB64, expectedTenantId);
|
||||
LicenseInfo info = validator.validate(token);
|
||||
return Optional.of(info);
|
||||
} catch (Exception e) {
|
||||
log.debug("License token verification failed: {}", e.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a signed license token without tenant ID check (for vendor verify tool).
|
||||
* Decodes the payload and validates the signature only.
|
||||
*/
|
||||
public Optional<LicenseInfo> verifyTokenSignature(String token) {
|
||||
try {
|
||||
// Decode the payload portion to extract tenantId, then validate
|
||||
String payloadB64 = token.split("\\.", 2)[0];
|
||||
String payloadJson = new String(java.util.Base64.getDecoder().decode(payloadB64));
|
||||
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
var tree = mapper.readTree(payloadJson);
|
||||
String tenantId = tree.has("tid") ? tree.get("tid").asText() : tree.get("tenantId").asText();
|
||||
|
||||
String publicKeyB64 = signingKeyService.getPublicKeyBase64();
|
||||
LicenseValidator validator = new LicenseValidator(publicKeyB64, tenantId);
|
||||
LicenseInfo info = validator.validate(token);
|
||||
return Optional.of(info);
|
||||
} catch (Exception e) {
|
||||
log.debug("License token signature verification failed: {}", e.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
47
src/main/java/io/cameleer/saas/license/SigningKeyEntity.java
Normal file
47
src/main/java/io/cameleer/saas/license/SigningKeyEntity.java
Normal file
@@ -0,0 +1,47 @@
|
||||
package io.cameleer.saas.license;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "signing_keys")
|
||||
public class SigningKeyEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "public_key_b64", nullable = false, columnDefinition = "text")
|
||||
private String publicKeyB64;
|
||||
|
||||
@Column(name = "private_key_b64", nullable = false, columnDefinition = "text")
|
||||
private String privateKeyB64;
|
||||
|
||||
@Column(name = "active", nullable = false)
|
||||
private boolean active = true;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
if (createdAt == null) createdAt = Instant.now();
|
||||
}
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public String getPublicKeyB64() { return publicKeyB64; }
|
||||
public void setPublicKeyB64(String publicKeyB64) { this.publicKeyB64 = publicKeyB64; }
|
||||
public String getPrivateKeyB64() { return privateKeyB64; }
|
||||
public void setPrivateKeyB64(String privateKeyB64) { this.privateKeyB64 = privateKeyB64; }
|
||||
public boolean isActive() { return active; }
|
||||
public void setActive(boolean active) { this.active = active; }
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package io.cameleer.saas.license;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface SigningKeyRepository extends JpaRepository<SigningKeyEntity, UUID> {
|
||||
|
||||
Optional<SigningKeyEntity> findByActiveTrue();
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package io.cameleer.saas.license;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.security.KeyFactory;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
|
||||
@Service
|
||||
public class SigningKeyService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SigningKeyService.class);
|
||||
|
||||
private final SigningKeyRepository signingKeyRepository;
|
||||
|
||||
public SigningKeyService(SigningKeyRepository signingKeyRepository) {
|
||||
this.signingKeyRepository = signingKeyRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the active signing key, generating a new Ed25519 keypair on first call.
|
||||
*/
|
||||
public SigningKeyEntity getOrCreateActiveKey() {
|
||||
return signingKeyRepository.findByActiveTrue()
|
||||
.orElseGet(this::generateAndStoreKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the base64-encoded public key (X.509 SPKI format).
|
||||
*/
|
||||
public String getPublicKeyBase64() {
|
||||
return getOrCreateActiveKey().getPublicKeyB64();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstructs the Ed25519 PrivateKey from the stored base64.
|
||||
*/
|
||||
public PrivateKey getPrivateKey() {
|
||||
SigningKeyEntity key = getOrCreateActiveKey();
|
||||
try {
|
||||
byte[] keyBytes = Base64.getDecoder().decode(key.getPrivateKeyB64());
|
||||
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
|
||||
return KeyFactory.getInstance("Ed25519").generatePrivate(spec);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to reconstruct Ed25519 private key", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstructs the Ed25519 PublicKey from the stored base64.
|
||||
*/
|
||||
public PublicKey getPublicKey() {
|
||||
SigningKeyEntity key = getOrCreateActiveKey();
|
||||
try {
|
||||
byte[] keyBytes = Base64.getDecoder().decode(key.getPublicKeyB64());
|
||||
X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes);
|
||||
return KeyFactory.getInstance("Ed25519").generatePublic(spec);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to reconstruct Ed25519 public key", e);
|
||||
}
|
||||
}
|
||||
|
||||
private SigningKeyEntity generateAndStoreKey() {
|
||||
try {
|
||||
KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
|
||||
String pubB64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
|
||||
String privB64 = Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded());
|
||||
|
||||
var entity = new SigningKeyEntity();
|
||||
entity.setPublicKeyB64(pubB64);
|
||||
entity.setPrivateKeyB64(privB64);
|
||||
entity.setActive(true);
|
||||
|
||||
var saved = signingKeyRepository.save(entity);
|
||||
log.info("Generated new Ed25519 signing keypair (id={})", saved.getId());
|
||||
return saved;
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to generate Ed25519 keypair", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package io.cameleer.saas.license.dto;
|
||||
|
||||
import io.cameleer.saas.license.LicenseEntity;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
public record LicenseBundleResponse(
|
||||
UUID id,
|
||||
UUID tenantId,
|
||||
String tenantSlug,
|
||||
String tier,
|
||||
String label,
|
||||
Map<String, Object> limits,
|
||||
int gracePeriodDays,
|
||||
Instant issuedAt,
|
||||
Instant expiresAt,
|
||||
String token,
|
||||
String publicKeyB64,
|
||||
boolean pushedToServer
|
||||
) {
|
||||
public static LicenseBundleResponse from(LicenseEntity e, String tenantSlug,
|
||||
String publicKeyB64, boolean pushed) {
|
||||
return new LicenseBundleResponse(
|
||||
e.getId(), e.getTenantId(), tenantSlug, e.getTier(),
|
||||
e.getLabel(), e.getLimits(), e.getGracePeriodDays(),
|
||||
e.getIssuedAt(), e.getExpiresAt(), e.getToken(),
|
||||
publicKeyB64, pushed
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package io.cameleer.saas.license.dto;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public record LicensePreset(String tier, Map<String, Integer> limits) {}
|
||||
@@ -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;
|
||||
@@ -10,8 +10,9 @@ public record LicenseResponse(
|
||||
UUID id,
|
||||
UUID tenantId,
|
||||
String tier,
|
||||
Map<String, Object> features,
|
||||
String label,
|
||||
Map<String, Object> limits,
|
||||
int gracePeriodDays,
|
||||
Instant issuedAt,
|
||||
Instant expiresAt,
|
||||
String token
|
||||
@@ -19,7 +20,7 @@ public record LicenseResponse(
|
||||
public static LicenseResponse from(LicenseEntity e) {
|
||||
return new LicenseResponse(
|
||||
e.getId(), e.getTenantId(), e.getTier(),
|
||||
e.getFeatures(), e.getLimits(),
|
||||
e.getLabel(), e.getLimits(), e.getGracePeriodDays(),
|
||||
e.getIssuedAt(), e.getExpiresAt(),
|
||||
e.getToken()
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
package io.cameleer.saas.license.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
public record MintLicenseRequest(
|
||||
String tier,
|
||||
Map<String, Integer> limits,
|
||||
Instant expiresAt,
|
||||
Integer gracePeriodDays,
|
||||
String label,
|
||||
boolean pushToServer
|
||||
) {}
|
||||
@@ -0,0 +1,3 @@
|
||||
package io.cameleer.saas.license.dto;
|
||||
|
||||
public record VerifyLicenseRequest(String token) {}
|
||||
@@ -0,0 +1,20 @@
|
||||
package io.cameleer.saas.license.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
public record VerifyLicenseResponse(
|
||||
boolean valid,
|
||||
String state,
|
||||
String tenantId,
|
||||
String label,
|
||||
Map<String, Integer> limits,
|
||||
Instant issuedAt,
|
||||
Instant expiresAt,
|
||||
int gracePeriodDays,
|
||||
String error
|
||||
) {
|
||||
public static VerifyLicenseResponse invalid(String error) {
|
||||
return new VerifyLicenseResponse(false, "INVALID", null, null, null, null, null, 0, error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package io.cameleer.saas.notification;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/password-reset-notification")
|
||||
public class PasswordResetNotificationController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PasswordResetNotificationController.class);
|
||||
|
||||
private static final long WINDOW_MS = 10 * 60 * 1000L; // 10 minutes
|
||||
private static final int MAX_PER_WINDOW = 3;
|
||||
|
||||
private final PasswordResetNotificationService notificationService;
|
||||
|
||||
// email -> [windowStart, count]
|
||||
private final ConcurrentHashMap<String, long[]> rateLimitMap = new ConcurrentHashMap<>();
|
||||
|
||||
public PasswordResetNotificationController(PasswordResetNotificationService notificationService) {
|
||||
this.notificationService = notificationService;
|
||||
}
|
||||
|
||||
public record NotificationRequest(String email) {}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<Map<String, Object>> sendNotification(@RequestBody NotificationRequest request) {
|
||||
String email = request.email();
|
||||
|
||||
if (email == null || !email.contains("@")) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("error", "Invalid email address"));
|
||||
}
|
||||
|
||||
if (isRateLimited(email)) {
|
||||
log.warn("Rate limit exceeded for password-reset notification to {}", email);
|
||||
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||
.body(Map.of("error", "Too many requests — please wait before retrying"));
|
||||
}
|
||||
|
||||
// Fire-and-forget: send asynchronously to avoid blocking the sign-in flow
|
||||
Thread.ofVirtual().start(() -> notificationService.sendNotification(email));
|
||||
|
||||
return ResponseEntity.ok(Map.of("sent", true));
|
||||
}
|
||||
|
||||
private boolean isRateLimited(String email) {
|
||||
long now = System.currentTimeMillis();
|
||||
var entry = rateLimitMap.compute(email, (key, existing) -> {
|
||||
if (existing == null || now - existing[0] >= WINDOW_MS) {
|
||||
// New window
|
||||
return new long[]{now, 1};
|
||||
}
|
||||
// Same window — increment
|
||||
existing[1]++;
|
||||
return existing;
|
||||
});
|
||||
return entry[1] > MAX_PER_WINDOW;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
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.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
|
||||
@Service
|
||||
public class PasswordResetNotificationService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PasswordResetNotificationService.class);
|
||||
private static final DateTimeFormatter TIMESTAMP_FMT =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm 'UTC'");
|
||||
|
||||
private final EmailConnectorService emailConnectorService;
|
||||
private final LogtoManagementClient logtoClient;
|
||||
private final ProvisioningProperties provisioningProps;
|
||||
|
||||
public PasswordResetNotificationService(EmailConnectorService emailConnectorService,
|
||||
LogtoManagementClient logtoClient,
|
||||
ProvisioningProperties provisioningProps) {
|
||||
this.emailConnectorService = emailConnectorService;
|
||||
this.logtoClient = logtoClient;
|
||||
this.provisioningProps = provisioningProps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a password-reset security notification to the given email address.
|
||||
* Fire-and-forget: logs a warning on failure but does not throw.
|
||||
*/
|
||||
public void sendNotification(String toEmail) {
|
||||
try {
|
||||
doSend(toEmail);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to send password-reset notification to {}: {}", toEmail, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void doSend(String toEmail) throws Exception {
|
||||
// Read the full connector config from Logto (includes password)
|
||||
var connectorStatus = emailConnectorService.getEmailConnector();
|
||||
if (connectorStatus == null) {
|
||||
log.warn("No email connector configured — skipping password-reset notification for {}", toEmail);
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-read the raw connector config to get the password (getEmailConnector() omits it)
|
||||
var connectors = logtoClient.listConnectors();
|
||||
var raw = connectors.stream()
|
||||
.filter(c -> "Email".equals(c.get("type")))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (raw == null) {
|
||||
log.warn("Email connector not found in raw list — skipping notification for {}", toEmail);
|
||||
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 username = connectorStatus.username();
|
||||
String fromEmail = connectorStatus.fromEmail();
|
||||
String password = String.valueOf(auth.getOrDefault("pass", ""));
|
||||
|
||||
String htmlBody = buildHtmlBody();
|
||||
|
||||
// Build a programmatic JavaMailSender from the runtime SMTP config
|
||||
var sender = new JavaMailSenderImpl();
|
||||
sender.setHost(host);
|
||||
sender.setPort(port);
|
||||
sender.setUsername(username);
|
||||
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 password was reset");
|
||||
helper.setText(htmlBody, true);
|
||||
|
||||
sender.send(mimeMessage);
|
||||
log.info("Password-reset notification sent to {}", toEmail);
|
||||
}
|
||||
|
||||
private String buildHtmlBody() throws IOException {
|
||||
String content = new ClassPathResource("email-templates/password-reset-notification.html")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
|
||||
String watermarkUrl = provisioningProps.publicProtocol() + "://"
|
||||
+ provisioningProps.publicHost() + "/platform/assets/email-watermark.png";
|
||||
String timestamp = ZonedDateTime.now(ZoneOffset.UTC).format(TIMESTAMP_FMT);
|
||||
|
||||
return content
|
||||
.replace("{{watermarkUrl}}", watermarkUrl)
|
||||
.replace("{{timestamp}}", timestamp);
|
||||
}
|
||||
}
|
||||
@@ -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,15 +1,20 @@
|
||||
package net.siegeln.cameleer.saas.onboarding;
|
||||
package io.cameleer.saas.onboarding;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import net.siegeln.cameleer.saas.tenant.TenantEntity;
|
||||
import net.siegeln.cameleer.saas.tenant.dto.TenantResponse;
|
||||
import java.util.Map;
|
||||
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;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@@ -17,9 +22,11 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
public class OnboardingController {
|
||||
|
||||
private final OnboardingService onboardingService;
|
||||
private final TenantRepository tenantRepository;
|
||||
|
||||
public OnboardingController(OnboardingService onboardingService) {
|
||||
public OnboardingController(OnboardingService onboardingService, TenantRepository tenantRepository) {
|
||||
this.onboardingService = onboardingService;
|
||||
this.tenantRepository = tenantRepository;
|
||||
}
|
||||
|
||||
public record CreateTrialTenantRequest(
|
||||
@@ -35,13 +42,25 @@ public class OnboardingController {
|
||||
String slug
|
||||
) {}
|
||||
|
||||
@GetMapping("/slug-available")
|
||||
public ResponseEntity<Map<String, Boolean>> slugAvailable(@RequestParam String slug) {
|
||||
boolean taken = tenantRepository.existsBySlugAndStatusNot(slug, TenantStatus.DELETED);
|
||||
return ResponseEntity.ok(Map.of("available", !taken));
|
||||
}
|
||||
|
||||
@PostMapping("/tenant")
|
||||
public ResponseEntity<TenantResponse> createTrialTenant(
|
||||
@Valid @RequestBody CreateTrialTenantRequest request,
|
||||
@AuthenticationPrincipal Jwt jwt) {
|
||||
|
||||
String userId = jwt.getSubject();
|
||||
TenantEntity tenant = onboardingService.createTrialTenant(request.name(), request.slug(), userId);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(TenantResponse.from(tenant));
|
||||
try {
|
||||
TenantEntity tenant = onboardingService.createTrialTenant(request.name(), request.slug(), userId);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(TenantResponse.from(tenant));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).body(null);
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).body(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
package net.siegeln.cameleer.saas.onboarding;
|
||||
package io.cameleer.saas.onboarding;
|
||||
|
||||
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;
|
||||
@@ -22,11 +23,14 @@ public class OnboardingService {
|
||||
|
||||
private final VendorTenantService vendorTenantService;
|
||||
private final LogtoManagementClient logtoClient;
|
||||
private final AccountService accountService;
|
||||
|
||||
public OnboardingService(VendorTenantService vendorTenantService,
|
||||
LogtoManagementClient logtoClient) {
|
||||
LogtoManagementClient logtoClient,
|
||||
AccountService accountService) {
|
||||
this.vendorTenantService = vendorTenantService;
|
||||
this.logtoClient = logtoClient;
|
||||
this.accountService = accountService;
|
||||
}
|
||||
|
||||
public TenantEntity createTrialTenant(String name, String slug, String logtoUserId) {
|
||||
@@ -40,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, "LOW", 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
|
||||
@@ -52,6 +56,17 @@ public class OnboardingService {
|
||||
logtoClient.assignOrganizationRole(tenant.getLogtoOrgId(), logtoUserId, ownerRoleId);
|
||||
}
|
||||
log.info("Added user {} as owner of tenant {}", logtoUserId, slug);
|
||||
|
||||
// Set display name from email if not already set (email-registered users have no name)
|
||||
var profile = accountService.getProfile(logtoUserId);
|
||||
if (profile.name() == null || profile.name().isBlank()) {
|
||||
String email = profile.email();
|
||||
if (!email.isBlank() && email.contains("@")) {
|
||||
String displayName = email.substring(0, email.indexOf('@'));
|
||||
accountService.updateDisplayName(logtoUserId, displayName);
|
||||
log.info("Set display name '{}' for user {}", displayName, logtoUserId);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to add user {} to org for tenant {}: {}", logtoUserId, slug, e.getMessage());
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,344 @@
|
||||
package io.cameleer.saas.portal;
|
||||
|
||||
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;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/tenant")
|
||||
public class TenantPortalController {
|
||||
|
||||
private final TenantPortalService portalService;
|
||||
private final TenantCaCertService caCertService;
|
||||
private final TenantService tenantService;
|
||||
|
||||
public TenantPortalController(TenantPortalService portalService,
|
||||
TenantCaCertService caCertService,
|
||||
TenantService tenantService) {
|
||||
this.portalService = portalService;
|
||||
this.caCertService = caCertService;
|
||||
this.tenantService = tenantService;
|
||||
}
|
||||
|
||||
// --- Request bodies ---
|
||||
|
||||
public record InviteRequest(String email, String roleId) {}
|
||||
|
||||
public record RoleChangeRequest(String roleId) {}
|
||||
|
||||
public record PasswordChangeRequest(String password) {}
|
||||
|
||||
public record TotpVerifyRequest(String secret, String code) {}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
private UUID resolveActorId(Jwt jwt) {
|
||||
try {
|
||||
return UUID.fromString(jwt.getSubject());
|
||||
} catch (Exception e) {
|
||||
return UUID.nameUUIDFromBytes(jwt.getSubject().getBytes());
|
||||
}
|
||||
}
|
||||
|
||||
// --- Endpoints ---
|
||||
|
||||
@GetMapping("/dashboard")
|
||||
public ResponseEntity<TenantPortalService.DashboardData> getDashboard() {
|
||||
return ResponseEntity.ok(portalService.getDashboard());
|
||||
}
|
||||
|
||||
@GetMapping("/license")
|
||||
public ResponseEntity<TenantPortalService.LicenseData> getLicense() {
|
||||
var license = portalService.getLicense();
|
||||
if (license == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok(license);
|
||||
}
|
||||
|
||||
@GetMapping("/team")
|
||||
public ResponseEntity<List<Map<String, Object>>> listTeamMembers() {
|
||||
return ResponseEntity.ok(portalService.listTeamMembers());
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
|
||||
@PostMapping("/team/invite")
|
||||
public ResponseEntity<Map<String, String>> inviteTeamMember(@AuthenticationPrincipal Jwt jwt,
|
||||
@RequestBody InviteRequest body) {
|
||||
String userId = portalService.inviteTeamMember(body.email(), body.roleId(), resolveActorId(jwt));
|
||||
return ResponseEntity.ok(Map.of("userId", userId != null ? userId : ""));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
|
||||
@DeleteMapping("/team/{userId}")
|
||||
public ResponseEntity<Void> removeTeamMember(@AuthenticationPrincipal Jwt jwt,
|
||||
@PathVariable String userId) {
|
||||
portalService.removeTeamMember(userId, resolveActorId(jwt));
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
|
||||
@PatchMapping("/team/{userId}/role")
|
||||
public ResponseEntity<Void> changeTeamMemberRole(@AuthenticationPrincipal Jwt jwt,
|
||||
@PathVariable String userId,
|
||||
@RequestBody RoleChangeRequest body) {
|
||||
portalService.changeTeamMemberRole(userId, body.roleId(), resolveActorId(jwt));
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
|
||||
@PostMapping("/server/admin-password")
|
||||
public ResponseEntity<Void> resetServerAdminPassword(@AuthenticationPrincipal Jwt jwt,
|
||||
@RequestBody PasswordChangeRequest body) {
|
||||
try {
|
||||
portalService.resetServerAdminPassword(body.password(), resolveActorId(jwt));
|
||||
return ResponseEntity.noContent().build();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
|
||||
@PostMapping("/team/{userId}/password")
|
||||
public ResponseEntity<Void> resetTeamMemberPassword(@AuthenticationPrincipal Jwt jwt,
|
||||
@PathVariable String userId,
|
||||
@RequestBody PasswordChangeRequest body) {
|
||||
try {
|
||||
portalService.resetTeamMemberPassword(userId, body.password(), resolveActorId(jwt));
|
||||
return ResponseEntity.noContent().build();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
|
||||
@PostMapping("/server/restart")
|
||||
public ResponseEntity<Void> restartServer(@AuthenticationPrincipal Jwt jwt) {
|
||||
portalService.restartServer(resolveActorId(jwt));
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
|
||||
@PostMapping("/server/upgrade")
|
||||
public ResponseEntity<Void> upgradeServer(@AuthenticationPrincipal Jwt jwt) {
|
||||
portalService.upgradeServer(resolveActorId(jwt));
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/settings")
|
||||
public ResponseEntity<TenantPortalService.TenantSettingsData> getSettings() {
|
||||
return ResponseEntity.ok(portalService.getSettings());
|
||||
}
|
||||
|
||||
// --- MFA endpoints ---
|
||||
|
||||
@GetMapping("/mfa/status")
|
||||
public ResponseEntity<TenantPortalService.MfaStatusData> getMfaStatus(@AuthenticationPrincipal Jwt jwt) {
|
||||
return ResponseEntity.ok(portalService.getMfaStatus(jwt.getSubject()));
|
||||
}
|
||||
|
||||
@PostMapping("/mfa/totp/setup")
|
||||
public ResponseEntity<TenantPortalService.MfaSetupData> setupTotp(@AuthenticationPrincipal Jwt jwt) {
|
||||
return ResponseEntity.ok(portalService.setupTotp(jwt.getSubject()));
|
||||
}
|
||||
|
||||
@PostMapping("/mfa/totp/verify")
|
||||
public ResponseEntity<?> verifyTotp(@AuthenticationPrincipal Jwt jwt,
|
||||
@RequestBody TotpVerifyRequest request) {
|
||||
boolean valid = portalService.verifyTotpCode(request.secret(), request.code());
|
||||
if (!valid) {
|
||||
return ResponseEntity.unprocessableEntity().body(Map.of("verified", false));
|
||||
}
|
||||
return ResponseEntity.ok(Map.of("verified", true));
|
||||
}
|
||||
|
||||
@PostMapping("/mfa/backup-codes")
|
||||
public ResponseEntity<TenantPortalService.BackupCodesData> generateBackupCodes(
|
||||
@AuthenticationPrincipal Jwt jwt) {
|
||||
return ResponseEntity.ok(portalService.generateBackupCodes(jwt.getSubject()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/mfa/totp")
|
||||
public ResponseEntity<Void> removeTotp(@AuthenticationPrincipal Jwt jwt) {
|
||||
portalService.removeTotp(jwt.getSubject());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
|
||||
@DeleteMapping("/users/{userId}/mfa")
|
||||
public ResponseEntity<Void> resetTeamMemberMfa(@AuthenticationPrincipal Jwt jwt,
|
||||
@PathVariable String userId) {
|
||||
try {
|
||||
portalService.resetTeamMemberMfa(userId, resolveActorId(jwt));
|
||||
return ResponseEntity.noContent().build();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Passkey endpoints ---
|
||||
|
||||
@GetMapping("/mfa/webauthn")
|
||||
public ResponseEntity<List<TenantPortalService.PasskeyCredential>> listPasskeys(
|
||||
@AuthenticationPrincipal Jwt jwt) {
|
||||
return ResponseEntity.ok(portalService.listPasskeys(jwt.getSubject()));
|
||||
}
|
||||
|
||||
@PatchMapping("/mfa/webauthn/{id}/name")
|
||||
public ResponseEntity<Void> renamePasskey(@AuthenticationPrincipal Jwt jwt,
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String name = body.get("name");
|
||||
if (name == null || name.isBlank()) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
portalService.renamePasskey(jwt.getSubject(), id, name);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@DeleteMapping("/mfa/webauthn/{id}")
|
||||
public ResponseEntity<Void> deletePasskey(@AuthenticationPrincipal Jwt jwt,
|
||||
@PathVariable String id) {
|
||||
portalService.deletePasskey(jwt.getSubject(), id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/mfa/method-preference")
|
||||
public ResponseEntity<Void> updateMfaMethodPreference(@AuthenticationPrincipal Jwt jwt,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String preference = body.get("preference");
|
||||
if (preference == null || !Set.of("totp", "webauthn").contains(preference)) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
portalService.updateMfaMethodPreference(jwt.getSubject(), preference);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// --- Auth settings endpoints ---
|
||||
|
||||
@GetMapping("/auth-settings")
|
||||
public ResponseEntity<TenantPortalService.AuthSettingsData> getAuthSettings() {
|
||||
return ResponseEntity.ok(portalService.getAuthSettings());
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
|
||||
@PatchMapping("/auth-settings")
|
||||
public ResponseEntity<Void> updateAuthSettings(@AuthenticationPrincipal Jwt jwt,
|
||||
@RequestBody Map<String, Object> updates) {
|
||||
portalService.updateTenantSettings(updates, resolveActorId(jwt));
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@GetMapping("/{slug}/mfa-policy")
|
||||
public ResponseEntity<Map<String, Object>> getMfaPolicy(@PathVariable String slug) {
|
||||
var tenantOpt = tenantService.getBySlug(slug);
|
||||
if (tenantOpt.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
var tenant = tenantOpt.get();
|
||||
Map<String, Object> settings = tenant.getSettings() != null ? tenant.getSettings() : Map.of();
|
||||
String mfaMode = settings.containsKey("mfaMode")
|
||||
? String.valueOf(settings.get("mfaMode"))
|
||||
: (Boolean.TRUE.equals(settings.get("mfaRequired")) ? "required" : "off");
|
||||
boolean passkeyEnabled = Boolean.TRUE.equals(settings.get("passkeyEnabled"));
|
||||
String passkeyMode = settings.containsKey("passkeyMode")
|
||||
? String.valueOf(settings.get("passkeyMode"))
|
||||
: "optional";
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"mfaRequired", "required".equals(mfaMode),
|
||||
"mfaMode", mfaMode,
|
||||
"passkeyEnabled", passkeyEnabled,
|
||||
"passkeyMode", passkeyMode
|
||||
));
|
||||
}
|
||||
|
||||
// --- CA Certificate management ---
|
||||
|
||||
public record CaCertResponse(
|
||||
UUID id, String status, String label, String subject, String issuer,
|
||||
String fingerprint, Instant notBefore, Instant notAfter, Instant createdAt
|
||||
) {
|
||||
public static CaCertResponse from(TenantCaCertEntity e) {
|
||||
return new CaCertResponse(
|
||||
e.getId(), e.getStatus().name(), e.getLabel(), e.getSubject(), e.getIssuer(),
|
||||
e.getFingerprint(), e.getNotBefore(), e.getNotAfter(), e.getCreatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/ca")
|
||||
public ResponseEntity<List<CaCertResponse>> listCaCerts() {
|
||||
UUID tenantId = TenantContext.getTenantId();
|
||||
return ResponseEntity.ok(
|
||||
caCertService.listForTenant(tenantId).stream().map(CaCertResponse::from).toList()
|
||||
);
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
|
||||
@PostMapping("/ca")
|
||||
public ResponseEntity<CaCertResponse> stageCaCert(
|
||||
@AuthenticationPrincipal Jwt jwt,
|
||||
@RequestParam("cert") MultipartFile certFile,
|
||||
@RequestParam(value = "label", required = false) String label) {
|
||||
try {
|
||||
UUID tenantId = TenantContext.getTenantId();
|
||||
var entity = caCertService.stage(tenantId, label, certFile.getBytes(), resolveActorId(jwt));
|
||||
return ResponseEntity.ok(CaCertResponse.from(entity));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.internalServerError().build();
|
||||
}
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
|
||||
@PostMapping("/ca/{id}/activate")
|
||||
public ResponseEntity<CaCertResponse> activateCaCert(@AuthenticationPrincipal Jwt jwt,
|
||||
@PathVariable UUID id) {
|
||||
try {
|
||||
UUID tenantId = TenantContext.getTenantId();
|
||||
var entity = caCertService.activate(tenantId, id, resolveActorId(jwt));
|
||||
return ResponseEntity.ok(CaCertResponse.from(entity));
|
||||
} catch (IllegalArgumentException | IllegalStateException e) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('SCOPE_tenant:manage')")
|
||||
@DeleteMapping("/ca/{id}")
|
||||
public ResponseEntity<Void> deleteCaCert(@AuthenticationPrincipal Jwt jwt,
|
||||
@PathVariable UUID id) {
|
||||
try {
|
||||
UUID tenantId = TenantContext.getTenantId();
|
||||
caCertService.delete(tenantId, id, resolveActorId(jwt));
|
||||
return ResponseEntity.noContent().build();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
}
|
||||
464
src/main/java/io/cameleer/saas/portal/TenantPortalService.java
Normal file
464
src/main/java/io/cameleer/saas/portal/TenantPortalService.java
Normal file
@@ -0,0 +1,464 @@
|
||||
package io.cameleer.saas.portal;
|
||||
|
||||
import io.cameleer.saas.account.AccountService;
|
||||
import io.cameleer.saas.audit.AuditAction;
|
||||
import io.cameleer.saas.audit.AuditService;
|
||||
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;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class TenantPortalService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TenantPortalService.class);
|
||||
|
||||
private final TenantService tenantService;
|
||||
private final LicenseService licenseService;
|
||||
private final ServerApiClient serverApiClient;
|
||||
private final LogtoManagementClient logtoClient;
|
||||
private final TenantProvisioner tenantProvisioner;
|
||||
private final ProvisioningProperties provisioningProps;
|
||||
private final VendorTenantService vendorTenantService;
|
||||
private final AccountService accountService;
|
||||
private final AuditService auditService;
|
||||
|
||||
public TenantPortalService(TenantService tenantService,
|
||||
LicenseService licenseService,
|
||||
ServerApiClient serverApiClient,
|
||||
LogtoManagementClient logtoClient,
|
||||
TenantProvisioner tenantProvisioner,
|
||||
ProvisioningProperties provisioningProps,
|
||||
@Lazy VendorTenantService vendorTenantService,
|
||||
AccountService accountService,
|
||||
AuditService auditService) {
|
||||
this.tenantService = tenantService;
|
||||
this.licenseService = licenseService;
|
||||
this.serverApiClient = serverApiClient;
|
||||
this.logtoClient = logtoClient;
|
||||
this.tenantProvisioner = tenantProvisioner;
|
||||
this.provisioningProps = provisioningProps;
|
||||
this.vendorTenantService = vendorTenantService;
|
||||
this.accountService = accountService;
|
||||
this.auditService = auditService;
|
||||
}
|
||||
|
||||
// --- Inner records ---
|
||||
|
||||
public record DashboardData(
|
||||
String name, String slug, String tier, String status,
|
||||
boolean serverHealthy, String serverStatus, String serverEndpoint,
|
||||
String licenseTier, long licenseDaysRemaining,
|
||||
Map<String, Object> limits,
|
||||
int agentCount, int environmentCount
|
||||
) {}
|
||||
|
||||
public record LicenseData(
|
||||
UUID id, String tier, String label, Map<String, Object> limits,
|
||||
int gracePeriodDays, Instant issuedAt, Instant expiresAt,
|
||||
String token, long daysRemaining,
|
||||
Map<String, Integer> usage
|
||||
) {}
|
||||
|
||||
public record TenantSettingsData(
|
||||
String name, String slug, String tier, String status,
|
||||
String serverEndpoint, Instant createdAt
|
||||
) {}
|
||||
|
||||
public record MfaStatusData(boolean enrolled, boolean hasBackupCodes, boolean passkeyEnrolled, int passkeyCount) {}
|
||||
|
||||
public record MfaSetupData(String secret, String secretQrCode) {}
|
||||
|
||||
public record BackupCodesData(List<String> codes) {}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
private TenantEntity resolveTenant() {
|
||||
UUID tenantId = TenantContext.getTenantId();
|
||||
return tenantService.getById(tenantId)
|
||||
.orElseThrow(() -> new IllegalStateException("Tenant not found: " + tenantId));
|
||||
}
|
||||
|
||||
private long daysUntil(Instant instant) {
|
||||
if (instant == null) return 0;
|
||||
long days = ChronoUnit.DAYS.between(Instant.now(), instant);
|
||||
return Math.max(0, days);
|
||||
}
|
||||
|
||||
// --- Service methods ---
|
||||
|
||||
public DashboardData getDashboard() {
|
||||
TenantEntity tenant = resolveTenant();
|
||||
String endpoint = tenant.getServerEndpoint();
|
||||
|
||||
boolean serverHealthy = false;
|
||||
String serverStatus = "NO_ENDPOINT";
|
||||
int agentCount = 0;
|
||||
int environmentCount = 0;
|
||||
if (endpoint != null && !endpoint.isBlank()) {
|
||||
var health = serverApiClient.getHealth(endpoint);
|
||||
serverHealthy = health.healthy();
|
||||
serverStatus = health.status();
|
||||
if (serverHealthy) {
|
||||
var counts = serverApiClient.getUsageCounts(endpoint);
|
||||
agentCount = counts.agents();
|
||||
environmentCount = counts.environments();
|
||||
}
|
||||
}
|
||||
|
||||
String licenseTier = null;
|
||||
long licenseDaysRemaining = 0;
|
||||
Map<String, Object> limits = Map.of();
|
||||
|
||||
var licenseOpt = licenseService.getActiveLicense(tenant.getId());
|
||||
if (licenseOpt.isPresent()) {
|
||||
LicenseEntity lic = licenseOpt.get();
|
||||
licenseTier = lic.getTier();
|
||||
licenseDaysRemaining = daysUntil(lic.getExpiresAt());
|
||||
limits = lic.getLimits() != null ? lic.getLimits() : Map.of();
|
||||
}
|
||||
|
||||
return new DashboardData(
|
||||
tenant.getName(), tenant.getSlug(),
|
||||
tenant.getTier().name(), tenant.getStatus().name(),
|
||||
serverHealthy, serverStatus, endpoint,
|
||||
licenseTier, licenseDaysRemaining,
|
||||
limits, agentCount, environmentCount
|
||||
);
|
||||
}
|
||||
|
||||
public LicenseData getLicense() {
|
||||
TenantEntity tenant = resolveTenant();
|
||||
return licenseService.getActiveLicense(tenant.getId())
|
||||
.map(lic -> {
|
||||
Map<String, Integer> usage = fetchUsage(tenant);
|
||||
return new LicenseData(
|
||||
lic.getId(), lic.getTier(), lic.getLabel(),
|
||||
lic.getLimits() != null ? lic.getLimits() : Map.of(),
|
||||
lic.getGracePeriodDays(),
|
||||
lic.getIssuedAt(), lic.getExpiresAt(),
|
||||
lic.getToken(), daysUntil(lic.getExpiresAt()),
|
||||
usage
|
||||
);
|
||||
})
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private Map<String, Integer> fetchUsage(TenantEntity tenant) {
|
||||
Map<String, Integer> usage = new HashMap<>();
|
||||
String endpoint = tenant.getServerEndpoint();
|
||||
if (endpoint != null && !endpoint.isBlank()) {
|
||||
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();
|
||||
if (orgId != null && !orgId.isBlank()) {
|
||||
usage.put("users", logtoClient.listOrganizationMembers(orgId).size());
|
||||
}
|
||||
return usage;
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> listTeamMembers() {
|
||||
TenantEntity tenant = resolveTenant();
|
||||
String orgId = tenant.getLogtoOrgId();
|
||||
if (orgId == null || orgId.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
var members = logtoClient.listOrganizationMembers(orgId);
|
||||
// Enrich each member with normalized email and organization role
|
||||
return members.stream().map(m -> {
|
||||
var enriched = new java.util.LinkedHashMap<>(m);
|
||||
// Logto returns primaryEmail, normalize to email
|
||||
if (enriched.get("email") == null && enriched.get("primaryEmail") != null) {
|
||||
enriched.put("email", enriched.get("primaryEmail"));
|
||||
}
|
||||
// Fetch organization roles for this user
|
||||
String userId = String.valueOf(enriched.get("id"));
|
||||
var roles = logtoClient.getUserOrganizationRoles(orgId, userId);
|
||||
if (!roles.isEmpty()) {
|
||||
enriched.put("role", roles.get(0).get("name"));
|
||||
}
|
||||
return (Map<String, Object>) enriched;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
public String inviteTeamMember(String email, String roleName, UUID actorId) {
|
||||
TenantEntity tenant = resolveTenant();
|
||||
String orgId = tenant.getLogtoOrgId();
|
||||
if (orgId == null || orgId.isBlank()) {
|
||||
throw new IllegalStateException("Tenant has no Logto organization configured");
|
||||
}
|
||||
String resolvedRoleId = resolveOrgRoleId(roleName);
|
||||
String userId = logtoClient.createAndInviteUser(email, orgId, resolvedRoleId);
|
||||
log.info("Invited team member {} to tenant {}", email, tenant.getSlug());
|
||||
auditService.log(actorId, null, tenant.getId(), AuditAction.TEAM_INVITE,
|
||||
userId, null, null, "SUCCESS", Map.of("email", email, "role", roleName != null ? roleName : ""));
|
||||
return userId;
|
||||
}
|
||||
|
||||
public void removeTeamMember(String userId, UUID actorId) {
|
||||
TenantEntity tenant = resolveTenant();
|
||||
String orgId = tenant.getLogtoOrgId();
|
||||
if (orgId == null || orgId.isBlank()) {
|
||||
throw new IllegalStateException("Tenant has no Logto organization configured");
|
||||
}
|
||||
logtoClient.removeUserFromOrganization(orgId, userId);
|
||||
|
||||
// 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);
|
||||
}
|
||||
auditService.log(actorId, null, tenant.getId(), AuditAction.TEAM_REMOVE,
|
||||
userId, null, null, "SUCCESS", null);
|
||||
}
|
||||
|
||||
public void changeTeamMemberRole(String userId, String roleName, UUID actorId) {
|
||||
TenantEntity tenant = resolveTenant();
|
||||
String orgId = tenant.getLogtoOrgId();
|
||||
if (orgId == null || orgId.isBlank()) {
|
||||
throw new IllegalStateException("Tenant has no Logto organization configured");
|
||||
}
|
||||
String resolvedRoleId = resolveOrgRoleId(roleName);
|
||||
logtoClient.assignOrganizationRole(orgId, userId, resolvedRoleId);
|
||||
log.info("Changed role for user {} in tenant {}", userId, tenant.getSlug());
|
||||
auditService.log(actorId, null, tenant.getId(), AuditAction.TEAM_ROLE_CHANGE,
|
||||
userId, null, null, "SUCCESS", Map.of("role", roleName != null ? roleName : ""));
|
||||
}
|
||||
|
||||
/** 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, UUID actorId) {
|
||||
TenantEntity tenant = resolveTenant();
|
||||
String endpoint = tenant.getServerEndpoint();
|
||||
if (endpoint == null || endpoint.isBlank()) {
|
||||
throw new IllegalStateException("Server not provisioned yet");
|
||||
}
|
||||
if (newPassword == null || newPassword.length() < 8) {
|
||||
throw new IllegalArgumentException("Password must be at least 8 characters");
|
||||
}
|
||||
serverApiClient.resetServerAdminPassword(endpoint, newPassword);
|
||||
log.info("Reset server admin password for tenant {}", tenant.getSlug());
|
||||
auditService.log(actorId, null, tenant.getId(), AuditAction.SERVER_ADMIN_PASSWORD_RESET,
|
||||
tenant.getSlug(), null, null, "SUCCESS", null);
|
||||
}
|
||||
|
||||
public void changePassword(String userId, String newPassword) {
|
||||
accountService.validatePassword(newPassword);
|
||||
logtoClient.updateUserPassword(userId, newPassword);
|
||||
}
|
||||
|
||||
public void resetTeamMemberPassword(String userId, String newPassword, UUID actorId) {
|
||||
TenantEntity tenant = resolveTenant();
|
||||
String orgId = tenant.getLogtoOrgId();
|
||||
if (orgId == null || orgId.isBlank()) {
|
||||
throw new IllegalStateException("Tenant has no Logto organization configured");
|
||||
}
|
||||
// Verify the target user belongs to this tenant's org
|
||||
var members = logtoClient.listOrganizationMembers(orgId);
|
||||
boolean isMember = members.stream()
|
||||
.anyMatch(m -> userId.equals(String.valueOf(m.get("id"))));
|
||||
if (!isMember) {
|
||||
throw new IllegalArgumentException("User is not a member of this organization");
|
||||
}
|
||||
if (newPassword == null || newPassword.length() < 8) {
|
||||
throw new IllegalArgumentException("Password must be at least 8 characters");
|
||||
}
|
||||
logtoClient.updateUserPassword(userId, newPassword);
|
||||
log.info("Reset password for team member {} in tenant {}", userId, tenant.getSlug());
|
||||
auditService.log(actorId, null, tenant.getId(), AuditAction.TEAM_MEMBER_PASSWORD_RESET,
|
||||
userId, null, null, "SUCCESS", null);
|
||||
}
|
||||
|
||||
public TenantSettingsData getSettings() {
|
||||
TenantEntity tenant = resolveTenant();
|
||||
String publicEndpoint = provisioningProps.publicProtocol() + "://"
|
||||
+ provisioningProps.publicHost() + "/t/" + tenant.getSlug() + "/";
|
||||
return new TenantSettingsData(
|
||||
tenant.getName(), tenant.getSlug(),
|
||||
tenant.getTier().name(), tenant.getStatus().name(),
|
||||
publicEndpoint, tenant.getCreatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
public void restartServer(UUID actorId) {
|
||||
TenantEntity tenant = resolveTenant();
|
||||
if (!tenantProvisioner.isAvailable()) return;
|
||||
|
||||
tenantProvisioner.stop(tenant.getSlug());
|
||||
try {
|
||||
tenantProvisioner.start(tenant.getSlug());
|
||||
} catch (RuntimeException e) {
|
||||
if (e.getMessage() != null && e.getMessage().contains("re-provision required")) {
|
||||
log.info("Containers missing for '{}' — re-provisioning", tenant.getSlug());
|
||||
tenantProvisioner.remove(tenant.getSlug());
|
||||
var license = licenseService.getActiveLicense(tenant.getId()).orElse(null);
|
||||
String token = license != null ? license.getToken() : "";
|
||||
vendorTenantService.provisionAsync(
|
||||
tenant.getId(), tenant.getSlug(), tenant.getTier().name(), token, null);
|
||||
auditService.log(actorId, null, tenant.getId(), AuditAction.SERVER_RESTARTED,
|
||||
tenant.getSlug(), null, null, "SUCCESS", null);
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
auditService.log(actorId, null, tenant.getId(), AuditAction.SERVER_RESTARTED,
|
||||
tenant.getSlug(), null, null, "SUCCESS", null);
|
||||
}
|
||||
|
||||
public void upgradeServer(UUID actorId) {
|
||||
TenantEntity tenant = resolveTenant();
|
||||
if (!tenantProvisioner.isAvailable()) return;
|
||||
|
||||
tenantProvisioner.upgrade(tenant.getSlug());
|
||||
|
||||
var license = licenseService.getActiveLicense(tenant.getId()).orElse(null);
|
||||
String token = license != null ? license.getToken() : "";
|
||||
vendorTenantService.provisionAsync(
|
||||
tenant.getId(), tenant.getSlug(), tenant.getTier().name(), token, null);
|
||||
log.info("Upgrading server for tenant {}", tenant.getSlug());
|
||||
auditService.log(actorId, null, tenant.getId(), AuditAction.SERVER_UPGRADED,
|
||||
tenant.getSlug(), null, null, "SUCCESS", null);
|
||||
}
|
||||
|
||||
// --- MFA methods ---
|
||||
|
||||
public MfaStatusData getMfaStatus(String userId) {
|
||||
var data = accountService.getMfaStatus(userId);
|
||||
return new MfaStatusData(data.enrolled(), data.hasBackupCodes(), data.passkeyEnrolled(), data.passkeyCount());
|
||||
}
|
||||
|
||||
public MfaSetupData setupTotp(String userId) {
|
||||
var data = accountService.setupTotp(userId);
|
||||
return new MfaSetupData(data.secret(), data.secretQrCode());
|
||||
}
|
||||
|
||||
public boolean verifyTotpCode(String secret, String code) {
|
||||
return accountService.verifyTotpCode(secret, code);
|
||||
}
|
||||
|
||||
public BackupCodesData generateBackupCodes(String userId) {
|
||||
var data = accountService.generateBackupCodes(userId);
|
||||
return new BackupCodesData(data.codes());
|
||||
}
|
||||
|
||||
public void removeTotp(String userId) {
|
||||
accountService.removeMfa(userId);
|
||||
}
|
||||
|
||||
public void resetTeamMemberMfa(String userId, UUID actorId) {
|
||||
TenantEntity tenant = resolveTenant();
|
||||
String orgId = tenant.getLogtoOrgId();
|
||||
if (orgId == null || orgId.isBlank()) {
|
||||
throw new IllegalStateException("Tenant has no Logto organization configured");
|
||||
}
|
||||
// Verify the target user belongs to this tenant's org
|
||||
var members = logtoClient.listOrganizationMembers(orgId);
|
||||
boolean isMember = members.stream()
|
||||
.anyMatch(m -> userId.equals(String.valueOf(m.get("id"))));
|
||||
if (!isMember) {
|
||||
throw new IllegalArgumentException("User is not a member of this organization");
|
||||
}
|
||||
logtoClient.deleteAllMfaVerifications(userId);
|
||||
log.info("Reset MFA for team member {} in tenant {}", userId, tenant.getSlug());
|
||||
auditService.log(actorId, null, tenant.getId(), AuditAction.TEAM_MEMBER_MFA_RESET,
|
||||
userId, null, null, "SUCCESS", null);
|
||||
}
|
||||
|
||||
// --- Passkey methods ---
|
||||
|
||||
public record PasskeyCredential(String id, String name, String agent, String createdAt) {}
|
||||
|
||||
public List<PasskeyCredential> listPasskeys(String userId) {
|
||||
return accountService.listPasskeys(userId).stream()
|
||||
.map(p -> new PasskeyCredential(p.id(), p.name(), p.agent(), p.createdAt()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
public void renamePasskey(String userId, String credentialId, String name) {
|
||||
accountService.renamePasskey(userId, credentialId, name);
|
||||
}
|
||||
|
||||
public void deletePasskey(String userId, String credentialId) {
|
||||
accountService.deletePasskey(userId, credentialId);
|
||||
}
|
||||
|
||||
public void updateMfaMethodPreference(String userId, String preference) {
|
||||
accountService.setMfaMethodPreference(userId, preference);
|
||||
}
|
||||
|
||||
public void updateTenantSettings(Map<String, Object> updates, UUID actorId) {
|
||||
TenantEntity tenant = resolveTenant();
|
||||
Map<String, Object> settings = new HashMap<>(
|
||||
tenant.getSettings() != null ? tenant.getSettings() : Map.of());
|
||||
if (updates.containsKey("mfaRequired")) {
|
||||
settings.put("mfaRequired", Boolean.TRUE.equals(updates.get("mfaRequired")));
|
||||
}
|
||||
if (updates.containsKey("mfaMode")) {
|
||||
String mode = String.valueOf(updates.get("mfaMode"));
|
||||
if (Set.of("off", "optional", "required").contains(mode)) {
|
||||
settings.put("mfaMode", mode);
|
||||
}
|
||||
}
|
||||
if (updates.containsKey("passkeyEnabled")) {
|
||||
settings.put("passkeyEnabled", Boolean.TRUE.equals(updates.get("passkeyEnabled")));
|
||||
}
|
||||
if (updates.containsKey("passkeyMode")) {
|
||||
String mode = String.valueOf(updates.get("passkeyMode"));
|
||||
if (Set.of("optional", "preferred", "required").contains(mode)) {
|
||||
settings.put("passkeyMode", mode);
|
||||
}
|
||||
}
|
||||
tenant.setSettings(settings);
|
||||
tenantService.save(tenant);
|
||||
log.info("Updated auth settings for tenant {}", tenant.getSlug());
|
||||
auditService.log(actorId, null, tenant.getId(), AuditAction.TENANT_AUTH_SETTINGS_UPDATED,
|
||||
tenant.getSlug(), null, null, "SUCCESS", updates);
|
||||
}
|
||||
|
||||
public record AuthSettingsData(String mfaMode, boolean passkeyEnabled, String passkeyMode) {}
|
||||
|
||||
public AuthSettingsData getAuthSettings() {
|
||||
TenantEntity tenant = resolveTenant();
|
||||
Map<String, Object> settings = tenant.getSettings() != null ? tenant.getSettings() : Map.of();
|
||||
String mfaMode = settings.containsKey("mfaMode")
|
||||
? String.valueOf(settings.get("mfaMode"))
|
||||
: (Boolean.TRUE.equals(settings.get("mfaRequired")) ? "required" : "off");
|
||||
boolean passkeyEnabled = Boolean.TRUE.equals(settings.get("passkeyEnabled"));
|
||||
String passkeyMode = settings.containsKey("passkeyMode")
|
||||
? String.valueOf(settings.get("passkeyMode"))
|
||||
: "optional";
|
||||
return new AuthSettingsData(mfaMode, passkeyEnabled, passkeyMode);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package net.siegeln.cameleer.saas.portal;
|
||||
package io.cameleer.saas.portal;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
@@ -13,6 +15,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/tenant/sso")
|
||||
@@ -31,16 +34,25 @@ public class TenantSsoController {
|
||||
List<String> domains
|
||||
) {}
|
||||
|
||||
private UUID resolveActorId(Jwt jwt) {
|
||||
try {
|
||||
return UUID.fromString(jwt.getSubject());
|
||||
} catch (Exception e) {
|
||||
return UUID.nameUUIDFromBytes(jwt.getSubject().getBytes());
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<Map<String, Object>>> list() {
|
||||
return ResponseEntity.ok(ssoService.listConnectors());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<Map<String, Object>> create(@RequestBody CreateSsoConnectorRequest request) {
|
||||
public ResponseEntity<Map<String, Object>> create(@AuthenticationPrincipal Jwt jwt,
|
||||
@RequestBody CreateSsoConnectorRequest request) {
|
||||
var connector = ssoService.createConnector(
|
||||
request.providerName(), request.connectorName(),
|
||||
request.config(), request.domains());
|
||||
request.config(), request.domains(), resolveActorId(jwt));
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(connector);
|
||||
}
|
||||
|
||||
@@ -50,14 +62,16 @@ public class TenantSsoController {
|
||||
}
|
||||
|
||||
@PatchMapping("/{connectorId}")
|
||||
public ResponseEntity<Map<String, Object>> update(@PathVariable String connectorId,
|
||||
public ResponseEntity<Map<String, Object>> update(@AuthenticationPrincipal Jwt jwt,
|
||||
@PathVariable String connectorId,
|
||||
@RequestBody Map<String, Object> updates) {
|
||||
return ResponseEntity.ok(ssoService.updateConnector(connectorId, updates));
|
||||
return ResponseEntity.ok(ssoService.updateConnector(connectorId, updates, resolveActorId(jwt)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{connectorId}")
|
||||
public ResponseEntity<Void> delete(@PathVariable String connectorId) {
|
||||
ssoService.deleteConnector(connectorId);
|
||||
public ResponseEntity<Void> delete(@AuthenticationPrincipal Jwt jwt,
|
||||
@PathVariable String connectorId) {
|
||||
ssoService.deleteConnector(connectorId, resolveActorId(jwt));
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
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.audit.AuditAction;
|
||||
import io.cameleer.saas.audit.AuditService;
|
||||
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;
|
||||
@@ -23,10 +25,13 @@ public class TenantSsoService {
|
||||
|
||||
private final LogtoManagementClient logtoClient;
|
||||
private final TenantService tenantService;
|
||||
private final AuditService auditService;
|
||||
|
||||
public TenantSsoService(LogtoManagementClient logtoClient, TenantService tenantService) {
|
||||
public TenantSsoService(LogtoManagementClient logtoClient, TenantService tenantService,
|
||||
AuditService auditService) {
|
||||
this.logtoClient = logtoClient;
|
||||
this.tenantService = tenantService;
|
||||
this.auditService = auditService;
|
||||
}
|
||||
|
||||
/** List SSO connectors linked to the current tenant's organization. */
|
||||
@@ -49,7 +54,8 @@ public class TenantSsoService {
|
||||
|
||||
/** Create an SSO connector and link it to the tenant's organization. */
|
||||
public Map<String, Object> createConnector(String providerName, String connectorName,
|
||||
Map<String, Object> config, List<String> domains) {
|
||||
Map<String, Object> config, List<String> domains,
|
||||
UUID actorId) {
|
||||
String orgId = resolveOrgId();
|
||||
var connector = logtoClient.createSsoConnector(providerName, connectorName, config, domains);
|
||||
if (connector == null) {
|
||||
@@ -58,6 +64,10 @@ public class TenantSsoService {
|
||||
String connectorId = String.valueOf(connector.get("id"));
|
||||
logtoClient.linkSsoConnectorToOrg(orgId, connectorId);
|
||||
log.info("Created SSO connector '{}' ({}) and linked to org {}", connectorName, connectorId, orgId);
|
||||
auditService.log(actorId, null, TenantContext.getTenantId(), AuditAction.SSO_CONNECTOR_CREATED,
|
||||
connectorId, null, null, "SUCCESS",
|
||||
Map.of("providerName", providerName != null ? providerName : "",
|
||||
"connectorName", connectorName != null ? connectorName : ""));
|
||||
return connector;
|
||||
}
|
||||
|
||||
@@ -68,18 +78,25 @@ public class TenantSsoService {
|
||||
}
|
||||
|
||||
/** Update an SSO connector (validates it belongs to this tenant). */
|
||||
public Map<String, Object> updateConnector(String connectorId, Map<String, Object> updates) {
|
||||
public Map<String, Object> updateConnector(String connectorId, Map<String, Object> updates,
|
||||
UUID actorId) {
|
||||
validateConnectorBelongsToTenant(connectorId);
|
||||
return logtoClient.updateSsoConnector(connectorId, updates);
|
||||
var result = logtoClient.updateSsoConnector(connectorId, updates);
|
||||
log.info("Updated SSO connector {}", connectorId);
|
||||
auditService.log(actorId, null, TenantContext.getTenantId(), AuditAction.SSO_CONNECTOR_UPDATED,
|
||||
connectorId, null, null, "SUCCESS", null);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Delete an SSO connector (unlinks from org and deletes). */
|
||||
public void deleteConnector(String connectorId) {
|
||||
public void deleteConnector(String connectorId, UUID actorId) {
|
||||
String orgId = resolveOrgId();
|
||||
validateConnectorBelongsToTenant(connectorId);
|
||||
logtoClient.unlinkSsoConnectorFromOrg(orgId, connectorId);
|
||||
logtoClient.deleteSsoConnector(connectorId);
|
||||
log.info("Deleted SSO connector {} from org {}", connectorId, orgId);
|
||||
auditService.log(actorId, null, TenantContext.getTenantId(), AuditAction.SSO_CONNECTOR_DELETED,
|
||||
connectorId, null, null, "SUCCESS", null);
|
||||
}
|
||||
|
||||
/** Test an SSO connector by fetching its details (validates provider metadata). */
|
||||
@@ -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,9 +21,12 @@ public class DockerTenantProvisioner implements TenantProvisioner {
|
||||
|
||||
private final DockerClient docker;
|
||||
private final ProvisioningProperties props;
|
||||
private final io.cameleer.saas.license.SigningKeyService signingKeyService;
|
||||
|
||||
public DockerTenantProvisioner(DockerClientConfig config, ProvisioningProperties props) {
|
||||
public DockerTenantProvisioner(DockerClientConfig config, ProvisioningProperties props,
|
||||
io.cameleer.saas.license.SigningKeyService signingKeyService) {
|
||||
this.props = props;
|
||||
this.signingKeyService = signingKeyService;
|
||||
DockerHttpClient httpClient = new ZerodepDockerHttpClient.Builder()
|
||||
.dockerHost(config.getDockerHost())
|
||||
.maxConnections(10)
|
||||
@@ -217,12 +220,13 @@ public class DockerTenantProvisioner implements TenantProvisioner {
|
||||
"CAMELEER_SERVER_CLICKHOUSE_PASSWORD=" + props.clickhousePassword(),
|
||||
"CAMELEER_SERVER_TENANT_ID=" + slug,
|
||||
"CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKEN=" + req.licenseToken(),
|
||||
"CAMELEER_SERVER_SECURITY_JWTSECRET=cameleer-dev-jwt-secret-change-in-production",
|
||||
"CAMELEER_SERVER_SECURITY_JWTSECRET=" + req.jwtSecret(),
|
||||
"CAMELEER_SERVER_SECURITY_OIDC_ISSUERURI=" + props.oidcIssuerUri(),
|
||||
"CAMELEER_SERVER_SECURITY_OIDC_JWKSETURI=" + props.oidcJwkSetUri(),
|
||||
"CAMELEER_SERVER_SECURITY_OIDC_AUDIENCE=https://api.cameleer.local",
|
||||
"CAMELEER_SERVER_SECURITY_CORSALLOWEDORIGINS=" + props.corsOrigins(),
|
||||
"CAMELEER_SERVER_LICENSE_TOKEN=" + req.licenseToken(),
|
||||
"CAMELEER_SERVER_LICENSE_PUBLICKEY=" + signingKeyService.getPublicKeyBase64(),
|
||||
"CAMELEER_SERVER_RUNTIME_ENABLED=true",
|
||||
"CAMELEER_SERVER_RUNTIME_SERVERURL=http://" + name + ":8081",
|
||||
"CAMELEER_SERVER_RUNTIME_ROUTINGDOMAIN=" + props.publicHost(),
|
||||
@@ -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));
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user