Merge pull request 'Phase 2: Tenants + Identity + Licensing' (#32) from feature/phase-2-tenants-identity-licensing into main
All checks were successful
CI / build (push) Successful in 25s
CI / docker (push) Successful in 31s

Reviewed-on: #32
This commit was merged in pull request #32.
This commit is contained in:
2026-04-04 15:58:07 +02:00
49 changed files with 4825 additions and 506 deletions

25
.env.example Normal file
View File

@@ -0,0 +1,25 @@
# Cameleer SaaS Environment Variables
# Copy to .env and fill in values
# Application version
VERSION=latest
# PostgreSQL
POSTGRES_USER=cameleer
POSTGRES_PASSWORD=change_me_in_production
POSTGRES_DB=cameleer_saas
# Logto Identity Provider
LOGTO_ENDPOINT=http://logto:3001
LOGTO_ISSUER_URI=http://logto:3001/oidc
LOGTO_JWK_SET_URI=http://logto:3001/oidc/jwks
LOGTO_DB_PASSWORD=change_me_in_production
LOGTO_M2M_CLIENT_ID=
LOGTO_M2M_CLIENT_SECRET=
# Ed25519 Keys (mount PEM files)
CAMELEER_JWT_PRIVATE_KEY_PATH=/etc/cameleer/keys/ed25519.key
CAMELEER_JWT_PUBLIC_KEY_PATH=/etc/cameleer/keys/ed25519.pub
# Domain (for Traefik TLS)
DOMAIN=localhost

View File

@@ -1,45 +1,84 @@
# .gitea/workflows/ci.yml
name: CI
on:
push:
branches: [main]
branches: [main, 'feature/**', 'fix/**', 'feat/**']
tags-ignore:
- 'v*'
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: cameleer_saas_test
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
if: github.event_name != 'delete'
container:
image: gitea.siegeln.net/cameleer/cameleer-build:1
credentials:
username: cameleer
password: ${{ secrets.REGISTRY_TOKEN }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
- name: Cache Maven dependencies
uses: actions/cache@v4
with:
distribution: temurin
java-version: 21
cache: maven
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: ${{ runner.os }}-maven-
- name: Run tests
run: ./mvnw verify -B
- name: Build and Test (unit tests only)
run: >-
mvn clean verify -B
-Dsurefire.excludes="**/AuthControllerTest.java,**/TenantControllerTest.java,**/LicenseControllerTest.java,**/AuditRepositoryTest.java,**/CameleerSaasApplicationTest.java"
docker:
needs: build
runs-on: ubuntu-latest
if: github.event_name == 'push'
container:
image: gitea.siegeln.net/cameleer/cameleer-docker-builder:1
credentials:
username: cameleer
password: ${{ secrets.REGISTRY_TOKEN }}
steps:
- name: Checkout
run: |
git clone --depth=1 --branch=${GITHUB_REF_NAME} https://cameleer:${REGISTRY_TOKEN}@gitea.siegeln.net/${GITHUB_REPOSITORY}.git .
env:
SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/cameleer_saas_test
SPRING_DATASOURCE_USERNAME: test
SPRING_DATASOURCE_PASSWORD: test
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
- name: Build Docker image
run: docker build -t cameleer-saas:${{ github.sha }} .
- name: Login to registry
run: echo "$REGISTRY_TOKEN" | docker login gitea.siegeln.net -u cameleer --password-stdin
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
- name: Compute image tags
run: |
sanitize_branch() {
echo "$1" | sed -E 's#^(feature|fix|feat|hotfix)/##' \
| tr '[:upper:]' '[:lower:]' \
| sed 's/[^a-z0-9-]/-/g' \
| sed 's/--*/-/g; s/^-//; s/-$//' \
| cut -c1-20 \
| sed 's/-$//'
}
if [ "$GITHUB_REF_NAME" = "main" ]; then
echo "IMAGE_TAGS=latest" >> "$GITHUB_ENV"
else
SLUG=$(sanitize_branch "$GITHUB_REF_NAME")
echo "IMAGE_TAGS=branch-$SLUG" >> "$GITHUB_ENV"
fi
- name: Build and push
run: |
TAGS="-t gitea.siegeln.net/cameleer/cameleer-saas:${{ github.sha }}"
for TAG in $IMAGE_TAGS; do
TAGS="$TAGS -t gitea.siegeln.net/cameleer/cameleer-saas:$TAG"
done
docker build $TAGS --provenance=false .
for TAG in $IMAGE_TAGS ${{ github.sha }}; do
docker push gitea.siegeln.net/cameleer/cameleer-saas:$TAG
done
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}

21
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,21 @@
# Development overrides: exposes ports for direct access
# Usage: docker compose -f docker-compose.yml -f docker-compose.dev.yml up
services:
postgres:
ports:
- "5432:5432"
logto:
ports:
- "3001:3001"
- "3002:3002"
cameleer-saas:
ports:
- "8080:8080"
environment:
SPRING_PROFILES_ACTIVE: dev
clickhouse:
ports:
- "8123:8123"

View File

@@ -1,14 +1,122 @@
services:
traefik:
image: traefik:v3
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik.yml:/etc/traefik/traefik.yml:ro
- acme:/etc/traefik/acme
networks:
- cameleer
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: cameleer_saas
POSTGRES_USER: cameleer
POSTGRES_PASSWORD: cameleer_dev
ports:
- "5432:5432"
POSTGRES_DB: ${POSTGRES_DB:-cameleer_saas}
POSTGRES_USER: ${POSTGRES_USER:-cameleer}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
volumes:
- pgdata:/var/lib/postgresql/data
- ./docker/init-databases.sh:/docker-entrypoint-initdb.d/init-databases.sh:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-cameleer}"]
interval: 5s
timeout: 5s
retries: 5
networks:
- cameleer
logto:
image: ghcr.io/logto-io/logto:latest
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm start"]
environment:
DB_URL: postgres://${POSTGRES_USER:-cameleer}:${POSTGRES_PASSWORD:-cameleer_dev}@postgres:5432/logto
ENDPOINT: ${LOGTO_ENDPOINT:-http://localhost:3001}
ADMIN_ENDPOINT: ${LOGTO_ADMIN_ENDPOINT:-http://localhost:3002}
TRUST_PROXY_HEADER: 1
labels:
- traefik.enable=true
- traefik.http.routers.logto.rule=PathPrefix(`/oidc`) || PathPrefix(`/interaction`)
- traefik.http.services.logto.loadbalancer.server.port=3001
networks:
- cameleer
cameleer-saas:
image: ${CAMELEER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-saas}:${VERSION:-latest}
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./keys:/etc/cameleer/keys:ro
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-cameleer_saas}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
LOGTO_ENDPOINT: ${LOGTO_ENDPOINT:-http://logto:3001}
LOGTO_ISSUER_URI: ${LOGTO_ISSUER_URI:-http://logto:3001/oidc}
LOGTO_JWK_SET_URI: ${LOGTO_JWK_SET_URI:-http://logto:3001/oidc/jwks}
LOGTO_M2M_CLIENT_ID: ${LOGTO_M2M_CLIENT_ID:-}
LOGTO_M2M_CLIENT_SECRET: ${LOGTO_M2M_CLIENT_SECRET:-}
CAMELEER_JWT_PRIVATE_KEY_PATH: ${CAMELEER_JWT_PRIVATE_KEY_PATH:-}
CAMELEER_JWT_PUBLIC_KEY_PATH: ${CAMELEER_JWT_PUBLIC_KEY_PATH:-}
labels:
- traefik.enable=true
- traefik.http.routers.api.rule=PathPrefix(`/api`)
- traefik.http.services.api.loadbalancer.server.port=8080
- traefik.http.routers.forwardauth.rule=Path(`/auth/verify`)
- traefik.http.services.forwardauth.loadbalancer.server.port=8080
networks:
- cameleer
cameleer3-server:
image: ${CAMELEER3_SERVER_IMAGE:-gitea.siegeln.net/cameleer/cameleer3-server}:${VERSION:-latest}
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
clickhouse:
condition: service_started
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-cameleer_saas}
CLICKHOUSE_URL: jdbc:clickhouse://clickhouse:8123/cameleer
labels:
- traefik.enable=true
- traefik.http.routers.observe.rule=PathPrefix(`/observe`)
- traefik.http.routers.observe.middlewares=forward-auth
- traefik.http.middlewares.forward-auth.forwardauth.address=http://cameleer-saas:8080/auth/verify
- traefik.http.middlewares.forward-auth.forwardauth.authResponseHeaders=X-Tenant-Id,X-User-Id,X-User-Email
- traefik.http.services.observe.loadbalancer.server.port=8080
networks:
- cameleer
clickhouse:
image: clickhouse/clickhouse-server:latest
restart: unless-stopped
volumes:
- chdata:/var/lib/clickhouse
healthcheck:
test: ["CMD-SHELL", "clickhouse-client --query 'SELECT 1'"]
interval: 10s
timeout: 5s
retries: 3
networks:
- cameleer
networks:
cameleer:
driver: bridge
volumes:
pgdata:
chdata:
acme:

7
docker/init-databases.sh Normal file
View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,399 @@
# Dual Deployment Architecture: Docker + Kubernetes
**Date:** 2026-04-04
**Status:** Approved
**Supersedes:** Portions of `2026-03-29-saas-platform-prd.md` (deployment model, phase ordering, auth strategy)
## Context
Cameleer SaaS must serve two deployment targets:
- **Docker Compose** — production-viable for small customers and air-gapped installs (single-tenant per stack)
- **Kubernetes** — managed SaaS and enterprise self-hosted (multi-tenant)
The original PRD assumed K8s-only production. This design restructures the architecture and roadmap to treat Docker Compose as a first-class production target, uses the Docker+K8s dual requirement as a filter for build-vs-buy decisions, and reorders the phase roadmap to ship a deployable product faster.
Key constraints:
- The application is **always multi-tenant** — Docker deployments have exactly 1 tenant
- Don't build custom abstractions over K8s-only primitives when no Docker equivalent exists
- Prefer right-sized OSS tools over Swiss Army knives or custom builds
- K8s-only features (NetworkPolicies, HPA, Flux CD) are operational enhancements, never functional requirements
## Build-vs-Buy Decisions
### BUY (Use 3rd Party OSS)
| Subsystem | Tool | License | Why This Tool |
|---|---|---|---|
| **Identity & Auth** | **Logto** | MPL-2.0 | Lightest IdP (2 containers, ~0.5-1 GB). Orgs, RBAC, M2M tokens, OIDC/SSO federation all in OSS. Replaces ~3-4 months of custom auth build (OIDC, SSO, teams, invites, MFA, password reset, custom roles). |
| **Reverse Proxy** | **Traefik** | MIT | Native Docker provider (labels) and K8s provider (IngressRoute CRDs). Same mental model in both environments. Already on the k3s cluster. ForwardAuth middleware for tenant-aware routing. Auto-HTTPS via Let's Encrypt. ~256 MB RAM. |
| **Database** | **PostgreSQL** | PostgreSQL License | Already chosen. Platform data + Logto data (separate schemas). |
| **Trace/Metrics Storage** | **ClickHouse** | Apache-2.0 | Replaced OpenSearch in the cameleer3-server stack. Columnar OLAP, excellent for time-series observability data. |
| **Schema Migrations** | **Flyway** | Apache-2.0 | Already in place. |
| **Billing (subscriptions)** | **Stripe** | N/A (API) | Start with Stripe Checkout for fixed-tier subscriptions. No custom billing infrastructure day 1. |
| **Billing (usage metering)** | **Lago** (deferred) | AGPL-3.0 | Purpose-built for event-based metering. 8 containers — deploy only when usage-based pricing launches. Design event model with Lago's API shape in mind from day 1. Integrate via API only (keeps AGPL safe). |
| **GitOps (K8s only)** | **Flux CD** | Apache-2.0 | K8s-only, and that's acceptable. Docker deployments get release tarballs + upgrade scripts. |
| **Image Builds (K8s)** | **Kaniko** | Apache-2.0 | Daemonless container image builds inside K8s. For Docker mode, `docker build` via docker-java is simpler. |
| **Monitoring** | **Prometheus + Grafana + Loki** | Apache-2.0 | Works in both Docker and K8s. Optional for Docker (customer's choice), standard for K8s SaaS. |
| **TLS Certificates** | **Traefik ACME** (Docker) / **cert-manager** (K8s) | MIT / Apache-2.0 | Standard tools, no custom code. |
| **Container Registry (K8s)** | **Gitea Registry** (SaaS) / **registry:2** (self-hosted) | — | Docker mode doesn't need a registry (local image cache). |
### BUILD (Custom / Core IP)
| Subsystem | Why Build |
|---|---|
| **License signing & validation** | Ed25519 signed JWT with tier, features, limits, expiry. Dual mode: online API check + offline signed file. No off-the-shelf tool does this. Core IP. |
| **Agent bootstrap tokens** | Tightly coupled to the cameleer3 agent protocol (PROTOCOL.md). Custom Ed25519 tokens for agent registration. |
| **Tenant lifecycle** | CRUD, configuration, status management. Core business logic. User management (invites, teams, roles) is delegated to Logto's organization model. |
| **Runtime orchestration** | The core of the "managed Camel runtime" product. `RuntimeOrchestrator` interface with Docker and K8s implementations. No off-the-shelf tool does "managed Camel runtime with agent injection." |
| **Image build pipeline** | Templated Dockerfile: JRE + cameleer3-agent.jar + customer JAR + `-javaagent` flag. Simple but custom. |
| **Feature gating** | Tier-based feature gating logic. Which features are available at which tier. Business logic. |
| **Billing integration** | Stripe API calls, subscription lifecycle, webhook handling. Thin integration layer. |
| **Observability proxy** | Routing authenticated requests to tenant-specific cameleer3-server instances. |
| **MOAT features** | Debugger, Lineage, Correlation — the defensible product. Built in cameleer3 agent + server. |
### SKIP / DEFER
| Subsystem | Why Skip |
|---|---|
| **Secrets management (Vault)** | Docker: env vars + mounted files. K8s: K8s Secrets. Vault is enterprise-tier complexity. Defer until demanded. |
| **Custom role management UI** | Logto provides this. |
| **OIDC provider implementation** | Logto provides this. |
| **WireGuard VPN / VPC peering** | Far future, dedicated-tier only. |
| **Cluster API for dedicated tiers** | Don't design for this until enterprise customers exist. |
| **Management agent for updates** | Watchtower is optional for connected customers. Air-gapped gets release tarballs. Don't build custom. |
## Architecture
### Platform Stack (Docker Compose — 6 base containers)
```
+-------------------------------------------------------+
| Traefik (reverse proxy, TLS, ForwardAuth) |
| - Docker: labels-based routing |
| - K8s: IngressRoute CRDs |
+--------+---------------------+------------------------+
| |
+--------v--------+ +---------v-----------+
| cameleer-saas | | cameleer3-server |
| (Spring Boot) | | (observability) |
| Control plane | | Per-tenant instance |
+---+-------+-----+ +----------+----------+
| | |
+---v--+ +--v----+ +---------v---------+
| PG | | Logto | | ClickHouse |
| | | (IdP) | | (traces/metrics) |
+------+ +-------+ +-------------------+
```
Customer Camel apps are **additional containers** dynamically managed by the control plane via Docker API (Docker mode) or K8s API (K8s mode).
### Auth Flow
```
User login:
Browser -> Traefik -> Logto (OIDC flow) -> JWT issued by Logto
API request:
Browser -> Traefik -> ForwardAuth (cameleer-saas /auth/verify)
-> Validates Logto JWT, injects X-Tenant-Id header
-> Traefik forwards to upstream service
Machine auth (agent bootstrap):
cameleer3-agent -> cameleer-saas /api/agent/register
-> Validates bootstrap token (Ed25519)
-> Issues agent session token
-> Agent connects to cameleer3-server
```
Logto handles all user-facing identity. The cameleer-saas app handles machine-to-machine auth (agent tokens, license tokens) using Ed25519.
### Runtime Orchestration
```java
RuntimeOrchestrator (interface)
+ deployApp(tenantId, appId, envId, imageRef, config) -> Deployment
+ stopApp(tenantId, appId, envId) -> void
+ restartApp(tenantId, appId, envId) -> void
+ getAppLogs(tenantId, appId, envId, since) -> Stream<LogLine>
+ getAppStatus(tenantId, appId, envId) -> AppStatus
+ listApps(tenantId) -> List<AppSummary>
DockerRuntimeOrchestrator (docker-java library)
- Talks to Docker daemon via /var/run/docker.sock
- Creates containers with labels for Traefik routing
- Manages container lifecycle
- Builds images locally via docker build
KubernetesRuntimeOrchestrator (fabric8 kubernetes-client)
- Creates Deployments, Services, ConfigMaps in tenant namespace
- Builds images via Kaniko Jobs, pushes to registry
- Manages rollout lifecycle
```
### Image Build Pipeline
```
Customer uploads JAR
-> Validation (file type, size, SHA-256, security scan)
-> Templated Dockerfile generation:
FROM eclipse-temurin:21-jre-alpine
COPY cameleer3-agent.jar /opt/agent/
COPY customer-app.jar /opt/app/
ENTRYPOINT ["java", "-javaagent:/opt/agent/cameleer3-agent.jar", "-jar", "/opt/app/customer-app.jar"]
-> Build:
Docker mode: docker build via docker-java (local image cache)
K8s mode: Kaniko Job -> push to registry
-> Deploy to requested environment
```
### Multi-Tenancy Model
- **Always multi-tenant.** Docker Compose has 1 pre-configured tenant.
- **Schema-per-tenant** in PostgreSQL for platform data isolation.
- **Logto organizations** map 1:1 to tenants. Logto handles user-tenant membership.
- **ClickHouse** data partitioned by tenant_id.
- **cameleer3-server** instances are per-tenant (separate containers/pods).
- **K8s bonus:** Namespace-per-tenant for network isolation, resource quotas.
### Environment Model
Each tenant can have multiple logical environments (tier-dependent):
| Tier | Environments |
|---|---|
| Low | prod only |
| Mid | dev, prod |
| High+ | dev, staging, prod + custom |
Each environment is a separate deployment of the same app image with different configuration:
- Docker: separate container, different env vars
- K8s: separate Deployment, different ConfigMap
Promotion = deploy same image tag to a different environment with that environment's config.
### Configuration Strategy
The application is configured entirely via environment variables and Spring Boot profiles:
```yaml
# Detected at startup
cameleer.deployment.mode: docker | kubernetes # auto-detected
cameleer.deployment.docker.socket: /var/run/docker.sock
cameleer.deployment.k8s.namespace-template: tenant-{tenantId}
# Identity provider
cameleer.identity.issuer-uri: http://logto:3001/oidc
cameleer.identity.client-id: ${LOGTO_CLIENT_ID}
cameleer.identity.client-secret: ${LOGTO_CLIENT_SECRET}
# Ed25519 keys (externalized, not per-boot)
cameleer.jwt.private-key-path: /etc/cameleer/keys/ed25519.key
cameleer.jwt.public-key-path: /etc/cameleer/keys/ed25519.pub
# Database
spring.datasource.url: ${DATABASE_URL}
# ClickHouse
cameleer.clickhouse.url: ${CLICKHOUSE_URL}
```
### Docker Compose Production Template
```yaml
services:
traefik:
image: traefik:v3
ports: ["80:80", "443:443"]
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik.yml:/etc/traefik/traefik.yml
- acme:/etc/traefik/acme
labels:
# Dashboard (optional, secured)
cameleer-saas:
image: gitea.siegeln.net/cameleer/cameleer-saas:${VERSION}
volumes:
- /var/run/docker.sock:/var/run/docker.sock # For runtime orchestration
- ./keys:/etc/cameleer/keys:ro
environment:
- DATABASE_URL=jdbc:postgresql://postgres:5432/cameleer_saas
- LOGTO_CLIENT_ID=${LOGTO_CLIENT_ID}
- LOGTO_CLIENT_SECRET=${LOGTO_CLIENT_SECRET}
labels:
- traefik.enable=true
- traefik.http.routers.api.rule=PathPrefix(`/api`)
logto:
image: svhd/logto:latest
environment:
- DB_URL=postgresql://postgres:5432/logto
labels:
- traefik.enable=true
- traefik.http.routers.auth.rule=PathPrefix(`/auth`)
cameleer3-server:
image: gitea.siegeln.net/cameleer/cameleer3-server:${VERSION}
environment:
- CLICKHOUSE_URL=jdbc:clickhouse://clickhouse:8123/cameleer
labels:
- traefik.enable=true
- traefik.http.routers.observe.rule=PathPrefix(`/observe`)
postgres:
image: postgres:16-alpine
volumes: [pgdata:/var/lib/postgresql/data]
clickhouse:
image: clickhouse/clickhouse-server:latest
volumes: [chdata:/var/lib/clickhouse]
volumes:
pgdata:
chdata:
acme:
```
### Docker vs K8s Feature Matrix
| Feature | Docker Compose | Kubernetes |
|---|---|---|
| Deploy Camel apps | Yes (Docker API) | Yes (K8s API) |
| Multiple environments | Yes (separate containers) | Yes (separate Deployments) |
| Agent injection | Yes | Yes |
| Observability (traces, topology) | Yes | Yes |
| Identity / SSO / Teams | Yes (Logto) | Yes (Logto) |
| Licensing | Yes | Yes |
| Auto-scaling | No | Yes (HPA) |
| Network isolation (multi-tenant) | Docker networks | NetworkPolicies |
| GitOps deployment | No (manual updates) | Yes (Flux CD) |
| Rolling updates | Manual restart | Native |
| Platform monitoring | Optional (customer adds Grafana) | Standard (Prometheus/Grafana/Loki) |
| Certificate management | Traefik ACME | cert-manager |
## Revised Phase Roadmap
### Phase 2: Tenants + Identity + Licensing
**Goal:** A customer can sign up, get a tenant, and access the platform via Traefik.
- Integrate Logto as identity provider
- Replace custom user-facing auth (login, registration, password management)
- Keep Ed25519 JWT for machine tokens (agent bootstrap, license signing)
- Configure Logto organizations to map to tenants
- Tenant entity + CRUD API
- License token generation (Ed25519 signed JWT: tier, features, limits, expiry)
- Traefik integration with ForwardAuth middleware
- Docker Compose production stack (6 containers)
- Externalize Ed25519 keys (mounted files, not per-boot)
**Files to modify/create:**
- `src/main/java/net/siegeln/cameleer/saas/tenant/` — new package
- `src/main/java/net/siegeln/cameleer/saas/license/` — new package
- `src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java` — Logto OIDC integration
- `src/main/resources/db/migration/V005__create_tenants.sql`
- `src/main/resources/db/migration/V006__create_licenses.sql`
- `docker-compose.yml` — expand to full production stack
- `traefik.yml` — static config
- `src/main/resources/application.yml` — Logto + Traefik config
### Phase 3: Runtime Orchestration + Environments
**Goal:** Customer can upload a Camel JAR, deploy it to dev/prod, see it running with agent attached.
- `RuntimeOrchestrator` interface
- `DockerRuntimeOrchestrator` implementation (docker-java)
- Customer JAR upload endpoint
- Image build pipeline (Dockerfile template + docker build)
- Logical environment model (dev/test/prod per tenant)
- Environment-specific config overlays
- App lifecycle API (deploy, start, stop, restart, logs, health)
**Key dependencies:** docker-java, Kaniko (for future K8s)
### Phase 4: Observability Pipeline
**Goal:** Customer can see traces, metrics, and route topology for deployed apps.
- Connect cameleer3-server to customer app containers
- ClickHouse tenant-scoped data partitioning
- Observability API proxy (tenant-aware routing to cameleer3-server)
- Basic topology graph endpoint
- Agent ↔ server connectivity verification
### Phase 5: K8s Operational Layer
**Goal:** Same product works on K8s with operational enhancements.
- `KubernetesRuntimeOrchestrator` implementation (fabric8)
- Kaniko-based image builds
- Flux CD integration for platform GitOps
- Namespace-per-tenant provisioning
- NetworkPolicies, ResourceQuotas
- Helm chart for K8s deployment
- Registry integration (Gitea registry / registry:2)
### Phase 6: Billing
**Goal:** Customers can subscribe and pay.
- Stripe Checkout integration
- Subscription lifecycle (create, upgrade, downgrade, cancel)
- Tier enforcement (feature gating based on active subscription)
- Usage tracking in platform DB (prep for Lago integration later)
- Webhook handling for payment events
### Phase 7: Security Hardening + Monitoring
**Goal:** Production-hardened platform.
- Prometheus/Grafana/Loki stack (optional Docker compose overlay, standard K8s)
- SOC 2 compliance review
- Rate limiting
- Container image signing (cosign)
- Supply chain security (SBOM, Trivy scanning)
- Audit log shipping to separate sink
### Frontend (React Shell) — Parallel Track (Phase 2+)
- Can start as soon as Phase 2 API contracts are defined
- Uses `@cameleer/design-system`
- Screens: login, dashboard, app deployment, environment management, observability views, team management, billing
## Verification Plan
### Phase 2 Verification
1. `docker compose up` starts all 6 containers
2. Navigate to Logto admin, create a user
3. User logs in via OIDC flow through Traefik
4. API calls with JWT include `X-Tenant-Id` header
5. License token can be generated and verified
6. All existing tests still pass
### Phase 3 Verification
1. Upload a sample Camel JAR via API
2. Platform builds container image
3. Deploy to "dev" environment
4. Container starts with cameleer3 agent attached
5. App is reachable via Traefik routing
6. Logs are accessible via API
7. Deploy same image to "prod" with different config
### Phase 4 Verification
1. Running Camel app sends traces to cameleer3-server
2. Traces visible in ClickHouse with correct tenant_id
3. Topology graph shows route structure
4. Different tenant cannot see another tenant's data
### Phase 5 Verification
1. Helm install deploys full platform to k3s
2. Tenant provisioning creates namespace + resources
3. App deployment creates K8s Deployment + Service
4. Kaniko builds image and pushes to registry
5. NetworkPolicy blocks cross-tenant traffic
6. Same API contracts work as Docker mode
### End-to-End Smoke Test (Any Phase)
```bash
# Docker Compose
docker compose up -d
# Create tenant + user via API/Logto
# Upload sample Camel JAR
# Deploy to environment
# Verify agent connects to cameleer3-server
# Verify traces in ClickHouse
# Verify observability API returns data
```

0
mvnw vendored Normal file → Executable file
View File

View File

@@ -19,6 +19,7 @@
<properties>
<java.version>21</java.version>
<testcontainers.version>1.21.4</testcontainers.version>
</properties>
<dependencies>
@@ -34,6 +35,12 @@
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- OAuth2 Resource Server (Logto OIDC) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<!-- JPA + PostgreSQL -->
<dependency>
<groupId>org.springframework.boot</groupId>

View File

@@ -6,5 +6,6 @@ public enum AuditAction {
APP_CREATE, APP_DEPLOY, APP_PROMOTE, APP_ROLLBACK, APP_SCALE, APP_STOP, APP_DELETE,
SECRET_CREATE, SECRET_READ, SECRET_UPDATE, SECRET_DELETE, SECRET_ROTATE,
CONFIG_UPDATE,
TEAM_INVITE, TEAM_REMOVE, TEAM_ROLE_CHANGE
TEAM_INVITE, TEAM_REMOVE, TEAM_ROLE_CHANGE,
LICENSE_GENERATE, LICENSE_REVOKE
}

View File

@@ -1,54 +0,0 @@
package net.siegeln.cameleer.saas.auth;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import net.siegeln.cameleer.saas.auth.dto.AuthResponse;
import net.siegeln.cameleer.saas.auth.dto.LoginRequest;
import net.siegeln.cameleer.saas.auth.dto.RegisterRequest;
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;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@PostMapping("/register")
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request,
HttpServletRequest httpRequest) {
try {
var response = authService.register(request, extractClientIp(httpRequest));
return ResponseEntity.status(HttpStatus.CREATED).body(response);
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request,
HttpServletRequest httpRequest) {
try {
var response = authService.login(request, extractClientIp(httpRequest));
return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}
private String extractClientIp(HttpServletRequest request) {
String xForwardedFor = request.getHeader("X-Forwarded-For");
if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
return xForwardedFor.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}

View File

@@ -1,82 +0,0 @@
package net.siegeln.cameleer.saas.auth;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.auth.dto.AuthResponse;
import net.siegeln.cameleer.saas.auth.dto.LoginRequest;
import net.siegeln.cameleer.saas.auth.dto.RegisterRequest;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class AuthService {
private final UserRepository userRepository;
private final RoleRepository roleRepository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final AuditService auditService;
public AuthService(UserRepository userRepository,
RoleRepository roleRepository,
PasswordEncoder passwordEncoder,
JwtService jwtService,
AuditService auditService) {
this.userRepository = userRepository;
this.roleRepository = roleRepository;
this.passwordEncoder = passwordEncoder;
this.jwtService = jwtService;
this.auditService = auditService;
}
public AuthResponse register(RegisterRequest request, String sourceIp) {
if (userRepository.existsByEmail(request.email())) {
throw new IllegalArgumentException("Email already registered");
}
var user = new UserEntity();
user.setEmail(request.email());
user.setName(request.name());
user.setPassword(passwordEncoder.encode(request.password()));
roleRepository.findByName("OWNER").ifPresent(role -> user.getRoles().add(role));
var saved = userRepository.save(user);
var token = jwtService.generateToken(saved);
auditService.log(
saved.getId(), saved.getEmail(), null,
AuditAction.AUTH_REGISTER, null,
null, sourceIp,
"SUCCESS", null
);
return new AuthResponse(token, saved.getEmail(), saved.getName());
}
public AuthResponse login(LoginRequest request, String sourceIp) {
var user = userRepository.findByEmail(request.email())
.orElseThrow(() -> new IllegalArgumentException("Invalid credentials"));
if (!passwordEncoder.matches(request.password(), user.getPassword())) {
auditService.log(
user.getId(), user.getEmail(), null,
AuditAction.AUTH_LOGIN_FAILED, null,
null, sourceIp,
"FAILURE", null
);
throw new IllegalArgumentException("Invalid credentials");
}
var token = jwtService.generateToken(user);
auditService.log(
user.getId(), user.getEmail(), null,
AuditAction.AUTH_LOGIN, null,
null, sourceIp,
"SUCCESS", null
);
return new AuthResponse(token, user.getEmail(), user.getName());
}
}

View File

@@ -1,8 +0,0 @@
package net.siegeln.cameleer.saas.auth.dto;
public record AuthResponse(
String token,
String email,
String name
) {
}

View File

@@ -1,10 +0,0 @@
package net.siegeln.cameleer.saas.auth.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public record LoginRequest(
@NotBlank @Email String email,
@NotBlank String password
) {
}

View File

@@ -1,12 +0,0 @@
package net.siegeln.cameleer.saas.auth.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record RegisterRequest(
@NotBlank @Email String email,
@NotBlank String name,
@NotBlank @Size(min = 8, max = 128) String password
) {
}

View File

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

View File

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

View File

@@ -3,12 +3,14 @@ package net.siegeln.cameleer.saas.config;
import net.siegeln.cameleer.saas.auth.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@@ -17,23 +19,41 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
@EnableMethodSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final JwtAuthenticationFilter machineTokenFilter;
private final TenantResolutionFilter tenantResolutionFilter;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
public SecurityConfig(JwtAuthenticationFilter machineTokenFilter, TenantResolutionFilter tenantResolutionFilter) {
this.machineTokenFilter = machineTokenFilter;
this.tenantResolutionFilter = tenantResolutionFilter;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
@Order(1)
public SecurityFilterChain machineAuthFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/agent/**", "/api/license/verify/**")
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.addFilterBefore(machineTokenFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/auth/verify").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> {}))
.addFilterBefore(machineTokenFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(tenantResolutionFilter, BearerTokenAuthenticationFilter.class);
return http.build();
}

View File

@@ -0,0 +1,22 @@
package net.siegeln.cameleer.saas.config;
import java.util.UUID;
public final class TenantContext {
private static final ThreadLocal<UUID> CURRENT_TENANT = new ThreadLocal<>();
private TenantContext() {}
public static UUID getTenantId() {
return CURRENT_TENANT.get();
}
public static void setTenantId(UUID tenantId) {
CURRENT_TENANT.set(tenantId);
}
public static void clear() {
CURRENT_TENANT.remove();
}
}

View File

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

View File

@@ -0,0 +1,25 @@
package net.siegeln.cameleer.saas.identity;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
@Configuration
public class LogtoConfig {
@Value("${cameleer.identity.logto-endpoint:}")
private String logtoEndpoint;
@Value("${cameleer.identity.m2m-client-id:}")
private String m2mClientId;
@Value("${cameleer.identity.m2m-client-secret:}")
private String m2mClientSecret;
public String getLogtoEndpoint() { return logtoEndpoint; }
public String getM2mClientId() { return m2mClientId; }
public String getM2mClientSecret() { return m2mClientSecret; }
public boolean isConfigured() {
return !logtoEndpoint.isEmpty() && !m2mClientId.isEmpty() && !m2mClientSecret.isEmpty();
}
}

View File

@@ -0,0 +1,103 @@
package net.siegeln.cameleer.saas.identity;
import com.fasterxml.jackson.databind.JsonNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.time.Instant;
import java.util.Map;
@Service
public class LogtoManagementClient {
private static final Logger log = LoggerFactory.getLogger(LogtoManagementClient.class);
private final LogtoConfig config;
private final RestClient restClient;
private String cachedToken;
private Instant tokenExpiry = Instant.MIN;
public LogtoManagementClient(LogtoConfig config) {
this.config = config;
this.restClient = RestClient.builder()
.defaultHeader("Content-Type", "application/json")
.build();
}
public boolean isAvailable() {
return config.isConfigured();
}
public String createOrganization(String name, String description) {
if (!isAvailable()) {
log.warn("Logto not configured — skipping organization creation for '{}'", name);
return null;
}
var body = Map.of("name", name, "description", description != null ? description : "");
var response = restClient.post()
.uri(config.getLogtoEndpoint() + "/api/organizations")
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.retrieve()
.body(JsonNode.class);
return response != null ? response.get("id").asText() : null;
}
public void addUserToOrganization(String orgId, String userId) {
if (!isAvailable()) return;
restClient.post()
.uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId + "/users")
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("userIds", new String[]{userId}))
.retrieve()
.toBodilessEntity();
}
public void deleteOrganization(String orgId) {
if (!isAvailable()) return;
restClient.delete()
.uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId)
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.toBodilessEntity();
}
private synchronized String getAccessToken() {
if (cachedToken != null && Instant.now().isBefore(tokenExpiry.minusSeconds(60))) {
return cachedToken;
}
try {
var response = restClient.post()
.uri(config.getLogtoEndpoint() + "/oidc/token")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body("grant_type=client_credentials"
+ "&client_id=" + config.getM2mClientId()
+ "&client_secret=" + config.getM2mClientSecret()
+ "&resource=" + config.getLogtoEndpoint() + "/api"
+ "&scope=all")
.retrieve()
.body(JsonNode.class);
cachedToken = response.get("access_token").asText();
long expiresIn = response.get("expires_in").asLong();
tokenExpiry = Instant.now().plusSeconds(expiresIn);
return cachedToken;
} catch (Exception e) {
log.error("Failed to get Logto Management API token", e);
throw new RuntimeException("Logto authentication failed", e);
}
}
}

View File

@@ -0,0 +1,67 @@
package net.siegeln.cameleer.saas.license;
import net.siegeln.cameleer.saas.license.dto.LicenseResponse;
import net.siegeln.cameleer.saas.tenant.TenantService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Duration;
import java.util.UUID;
@RestController
@RequestMapping("/api/tenants/{tenantId}/license")
public class LicenseController {
private final LicenseService licenseService;
private final TenantService tenantService;
public LicenseController(LicenseService licenseService, TenantService tenantService) {
this.licenseService = licenseService;
this.tenantService = tenantService;
}
@PostMapping
public ResponseEntity<LicenseResponse> generate(@PathVariable UUID tenantId,
Authentication authentication) {
var tenant = tenantService.getById(tenantId).orElse(null);
if (tenant == null) return ResponseEntity.notFound().build();
// Extract actor ID from JWT subject (Logto OIDC: sub may be a non-UUID string)
String sub = authentication.getName();
UUID actorId;
try {
actorId = UUID.fromString(sub);
} catch (IllegalArgumentException e) {
actorId = UUID.nameUUIDFromBytes(sub.getBytes());
}
var license = licenseService.generateLicense(tenant, Duration.ofDays(365), actorId);
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(license));
}
@GetMapping
public ResponseEntity<LicenseResponse> getActive(@PathVariable UUID tenantId) {
return licenseService.getActiveLicense(tenantId)
.map(entity -> ResponseEntity.ok(toResponse(entity)))
.orElse(ResponseEntity.notFound().build());
}
private LicenseResponse toResponse(LicenseEntity entity) {
return new LicenseResponse(
entity.getId(),
entity.getTenantId(),
entity.getTier(),
entity.getFeatures(),
entity.getLimits(),
entity.getIssuedAt(),
entity.getExpiresAt(),
entity.getToken()
);
}
}

View File

@@ -0,0 +1,44 @@
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 Map<String, Object> featuresForTier(Tier tier) {
return switch (tier) {
case LOW -> Map.of(
"topology", true, "lineage", false,
"correlation", false, "debugger", false, "replay", false);
case MID -> Map.of(
"topology", true, "lineage", true,
"correlation", true, "debugger", false, "replay", false);
case HIGH -> Map.of(
"topology", true, "lineage", true,
"correlation", true, "debugger", true, "replay", true);
case BUSINESS -> Map.of(
"topology", true, "lineage", true,
"correlation", true, "debugger", true, "replay", true);
};
}
public static Map<String, Object> limitsForTier(Tier tier) {
return switch (tier) {
case LOW -> Map.of(
"max_agents", 3, "retention_days", 7,
"max_environments", 1);
case MID -> Map.of(
"max_agents", 10, "retention_days", 30,
"max_environments", 2);
case HIGH -> Map.of(
"max_agents", 50, "retention_days", 90,
"max_environments", -1);
case BUSINESS -> Map.of(
"max_agents", -1, "retention_days", 365,
"max_environments", -1);
};
}
}

View File

@@ -0,0 +1,78 @@
package net.siegeln.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 org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
@Entity
@Table(name = "licenses")
public class LicenseEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "tenant_id", nullable = false)
private UUID tenantId;
@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;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "limits", nullable = false, columnDefinition = "jsonb")
private Map<String, Object> limits;
@Column(name = "issued_at", nullable = false)
private Instant issuedAt;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
@Column(name = "revoked_at")
private Instant revokedAt;
@Column(name = "token", nullable = false, columnDefinition = "text")
private String token;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@PrePersist
protected void onCreate() {
if (createdAt == null) createdAt = Instant.now();
if (issuedAt == null) issuedAt = Instant.now();
}
public UUID getId() { return id; }
public UUID getTenantId() { return tenantId; }
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 Map<String, Object> getLimits() { return limits; }
public void setLimits(Map<String, Object> limits) { this.limits = limits; }
public Instant getIssuedAt() { return issuedAt; }
public void setIssuedAt(Instant issuedAt) { this.issuedAt = issuedAt; }
public Instant getExpiresAt() { return expiresAt; }
public void setExpiresAt(Instant expiresAt) { this.expiresAt = expiresAt; }
public Instant getRevokedAt() { return revokedAt; }
public void setRevokedAt(Instant revokedAt) { this.revokedAt = revokedAt; }
public String getToken() { return token; }
public void setToken(String token) { this.token = token; }
public Instant getCreatedAt() { return createdAt; }
}

View File

@@ -0,0 +1,14 @@
package net.siegeln.cameleer.saas.license;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface LicenseRepository extends JpaRepository<LicenseEntity, UUID> {
List<LicenseEntity> findByTenantIdOrderByCreatedAtDesc(UUID tenantId);
Optional<LicenseEntity> findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(UUID tenantId);
}

View File

@@ -0,0 +1,121 @@
package net.siegeln.cameleer.saas.license;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.config.JwtConfig;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import org.springframework.stereotype.Service;
import java.security.Signature;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@Service
public class LicenseService {
private final LicenseRepository licenseRepository;
private final JwtConfig jwtConfig;
private final AuditService auditService;
private final ObjectMapper objectMapper = new ObjectMapper();
public LicenseService(LicenseRepository licenseRepository, JwtConfig jwtConfig, AuditService auditService) {
this.licenseRepository = licenseRepository;
this.jwtConfig = jwtConfig;
this.auditService = auditService;
}
public LicenseEntity generateLicense(TenantEntity tenant, Duration validity, UUID actorId) {
var features = LicenseDefaults.featuresForTier(tenant.getTier());
var limits = LicenseDefaults.limitsForTier(tenant.getTier());
Instant now = Instant.now();
Instant expiresAt = now.plus(validity);
String token = signLicenseJwt(tenant.getId(), tenant.getTier().name(), features, limits, now, expiresAt);
var entity = new LicenseEntity();
entity.setTenantId(tenant.getId());
entity.setTier(tenant.getTier().name());
entity.setFeatures(features);
entity.setLimits(limits);
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;
}
public Optional<LicenseEntity> getActiveLicense(UUID tenantId) {
return licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId);
}
public Optional<Map<String, Object>> verifyLicenseToken(String token) {
try {
String[] parts = token.split("\\.");
if (parts.length != 3) return Optional.empty();
String signingInput = parts[0] + "." + parts[1];
byte[] signatureBytes = Base64.getUrlDecoder().decode(parts[2]);
Signature sig = Signature.getInstance("Ed25519");
sig.initVerify(jwtConfig.getPublicKey());
sig.update(signingInput.getBytes(java.nio.charset.StandardCharsets.UTF_8));
if (!sig.verify(signatureBytes)) return Optional.empty();
byte[] payloadBytes = Base64.getUrlDecoder().decode(parts[1]);
Map<String, Object> payload = objectMapper.readValue(payloadBytes, new TypeReference<>() {});
long exp = ((Number) payload.get("exp")).longValue();
if (Instant.now().getEpochSecond() >= exp) return Optional.empty();
return Optional.of(payload);
} catch (Exception e) {
return Optional.empty();
}
}
private String signLicenseJwt(UUID tenantId, String tier, Map<String, Object> features,
Map<String, Object> limits, Instant issuedAt, Instant expiresAt) {
try {
String header = base64UrlEncode(objectMapper.writeValueAsBytes(
Map.of("alg", "EdDSA", "typ", "JWT", "kid", "license")));
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("tenant_id", tenantId.toString());
payload.put("tier", tier);
payload.put("features", features);
payload.put("limits", limits);
payload.put("iat", issuedAt.getEpochSecond());
payload.put("exp", expiresAt.getEpochSecond());
String payloadEncoded = base64UrlEncode(objectMapper.writeValueAsBytes(payload));
String signingInput = header + "." + payloadEncoded;
Signature sig = Signature.getInstance("Ed25519");
sig.initSign(jwtConfig.getPrivateKey());
sig.update(signingInput.getBytes(java.nio.charset.StandardCharsets.UTF_8));
String signature = base64UrlEncode(sig.sign());
return signingInput + "." + signature;
} catch (Exception e) {
throw new RuntimeException("Failed to sign license JWT", e);
}
}
private String base64UrlEncode(byte[] data) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(data);
}
}

View File

@@ -0,0 +1,16 @@
package net.siegeln.cameleer.saas.license.dto;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
public record LicenseResponse(
UUID id,
UUID tenantId,
String tier,
Map<String, Object> features,
Map<String, Object> limits,
Instant issuedAt,
Instant expiresAt,
String token
) {}

View File

@@ -0,0 +1,73 @@
package net.siegeln.cameleer.saas.tenant;
import jakarta.validation.Valid;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
import net.siegeln.cameleer.saas.tenant.dto.TenantResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
@RestController
@RequestMapping("/api/tenants")
public class TenantController {
private final TenantService tenantService;
public TenantController(TenantService tenantService) {
this.tenantService = tenantService;
}
@PostMapping
public ResponseEntity<TenantResponse> create(@Valid @RequestBody CreateTenantRequest request,
Authentication authentication) {
try {
// Extract actor ID from JWT subject (Logto OIDC: sub may be a non-UUID string)
String sub = authentication.getName();
UUID actorId;
try {
actorId = UUID.fromString(sub);
} catch (IllegalArgumentException e) {
actorId = UUID.nameUUIDFromBytes(sub.getBytes());
}
var entity = tenantService.create(request, actorId);
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(entity));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
}
@GetMapping("/{id}")
public ResponseEntity<TenantResponse> getById(@PathVariable UUID id) {
return tenantService.getById(id)
.map(entity -> ResponseEntity.ok(toResponse(entity)))
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/by-slug/{slug}")
public ResponseEntity<TenantResponse> getBySlug(@PathVariable String slug) {
return tenantService.getBySlug(slug)
.map(entity -> ResponseEntity.ok(toResponse(entity)))
.orElse(ResponseEntity.notFound().build());
}
private TenantResponse toResponse(TenantEntity entity) {
return new TenantResponse(
entity.getId(),
entity.getName(),
entity.getSlug(),
entity.getTier().name(),
entity.getStatus().name(),
entity.getCreatedAt(),
entity.getUpdatedAt()
);
}
}

View File

@@ -0,0 +1,92 @@
package net.siegeln.cameleer.saas.tenant;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
@Entity
@Table(name = "tenants")
public class TenantEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "name", nullable = false)
private String name;
@Column(name = "slug", nullable = false, unique = true, length = 100)
private String slug;
@Enumerated(EnumType.STRING)
@Column(name = "tier", nullable = false, length = 20)
private Tier tier = Tier.LOW;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private TenantStatus status = TenantStatus.PROVISIONING;
@Column(name = "logto_org_id")
private String logtoOrgId;
@Column(name = "stripe_customer_id")
private String stripeCustomerId;
@Column(name = "stripe_subscription_id")
private String stripeSubscriptionId;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "settings", columnDefinition = "jsonb")
private Map<String, Object> settings = Map.of();
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@PrePersist
protected void onCreate() {
Instant now = Instant.now();
if (createdAt == null) createdAt = now;
if (updatedAt == null) updatedAt = now;
}
@PreUpdate
protected void onUpdate() {
updatedAt = Instant.now();
}
public UUID getId() { return id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getSlug() { return slug; }
public void setSlug(String slug) { this.slug = slug; }
public Tier getTier() { return tier; }
public void setTier(Tier tier) { this.tier = tier; }
public TenantStatus getStatus() { return status; }
public void setStatus(TenantStatus status) { this.status = status; }
public String getLogtoOrgId() { return logtoOrgId; }
public void setLogtoOrgId(String logtoOrgId) { this.logtoOrgId = logtoOrgId; }
public String getStripeCustomerId() { return stripeCustomerId; }
public void setStripeCustomerId(String stripeCustomerId) { this.stripeCustomerId = stripeCustomerId; }
public String getStripeSubscriptionId() { return stripeSubscriptionId; }
public void setStripeSubscriptionId(String stripeSubscriptionId) { this.stripeSubscriptionId = stripeSubscriptionId; }
public Map<String, Object> getSettings() { return settings; }
public void setSettings(Map<String, Object> settings) { this.settings = settings; }
public Instant getCreatedAt() { return createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
}

View File

@@ -0,0 +1,16 @@
package net.siegeln.cameleer.saas.tenant;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface TenantRepository extends JpaRepository<TenantEntity, UUID> {
Optional<TenantEntity> findBySlug(String slug);
Optional<TenantEntity> findByLogtoOrgId(String logtoOrgId);
List<TenantEntity> findByStatus(TenantStatus status);
boolean existsBySlug(String slug);
}

View File

@@ -0,0 +1,95 @@
package net.siegeln.cameleer.saas.tenant;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Service
public class TenantService {
private final TenantRepository tenantRepository;
private final AuditService auditService;
private final LogtoManagementClient logtoClient;
public TenantService(TenantRepository tenantRepository, AuditService auditService, LogtoManagementClient logtoClient) {
this.tenantRepository = tenantRepository;
this.auditService = auditService;
this.logtoClient = logtoClient;
}
public TenantEntity create(CreateTenantRequest request, UUID actorId) {
if (tenantRepository.existsBySlug(request.slug())) {
throw new IllegalArgumentException("Slug already taken");
}
var entity = new TenantEntity();
entity.setName(request.name());
entity.setSlug(request.slug());
entity.setTier(request.tier() != null ? Tier.valueOf(request.tier()) : Tier.LOW);
entity.setStatus(TenantStatus.PROVISIONING);
var saved = tenantRepository.save(entity);
if (logtoClient.isAvailable()) {
String logtoOrgId = logtoClient.createOrganization(saved.getName(), "Tenant: " + saved.getSlug());
if (logtoOrgId != null) {
saved.setLogtoOrgId(logtoOrgId);
saved = tenantRepository.save(saved);
}
}
auditService.log(actorId, null, saved.getId(),
AuditAction.TENANT_CREATE, saved.getSlug(),
null, null, "SUCCESS", null);
return saved;
}
public Optional<TenantEntity> getById(UUID id) {
return tenantRepository.findById(id);
}
public Optional<TenantEntity> getBySlug(String slug) {
return tenantRepository.findBySlug(slug);
}
public Optional<TenantEntity> getByLogtoOrgId(String logtoOrgId) {
return tenantRepository.findByLogtoOrgId(logtoOrgId);
}
public List<TenantEntity> listActive() {
return tenantRepository.findByStatus(TenantStatus.ACTIVE);
}
public TenantEntity activate(UUID tenantId, UUID actorId) {
var entity = tenantRepository.findById(tenantId)
.orElseThrow(() -> new IllegalArgumentException("Tenant not found"));
entity.setStatus(TenantStatus.ACTIVE);
var saved = tenantRepository.save(entity);
auditService.log(actorId, null, tenantId,
AuditAction.TENANT_UPDATE, entity.getSlug(),
null, null, "SUCCESS", null);
return saved;
}
public TenantEntity suspend(UUID tenantId, UUID actorId) {
var entity = tenantRepository.findById(tenantId)
.orElseThrow(() -> new IllegalArgumentException("Tenant not found"));
entity.setStatus(TenantStatus.SUSPENDED);
var saved = tenantRepository.save(entity);
auditService.log(actorId, null, tenantId,
AuditAction.TENANT_SUSPEND, entity.getSlug(),
null, null, "SUCCESS", null);
return saved;
}
}

View File

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

View File

@@ -0,0 +1,5 @@
package net.siegeln.cameleer.saas.tenant;
public enum Tier {
LOW, MID, HIGH, BUSINESS
}

View File

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

View File

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

View File

@@ -3,3 +3,8 @@ spring:
show-sql: false
flyway:
clean-disabled: false
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://test-issuer.example.com/oidc

View File

@@ -8,6 +8,12 @@ spring:
flyway:
enabled: true
locations: classpath:db/migration
security:
oauth2:
resourceserver:
jwt:
issuer-uri: ${LOGTO_ISSUER_URI:}
jwk-set-uri: ${LOGTO_JWK_SET_URI:}
management:
endpoints:
@@ -21,3 +27,9 @@ management:
cameleer:
jwt:
expiration: 86400 # 24 hours in seconds
private-key-path: ${CAMELEER_JWT_PRIVATE_KEY_PATH:}
public-key-path: ${CAMELEER_JWT_PUBLIC_KEY_PATH:}
identity:
logto-endpoint: ${LOGTO_ENDPOINT:}
m2m-client-id: ${LOGTO_M2M_CLIENT_ID:}
m2m-client-secret: ${LOGTO_M2M_CLIENT_SECRET:}

View File

@@ -0,0 +1,17 @@
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) NOT NULL UNIQUE,
tier VARCHAR(20) NOT NULL DEFAULT 'LOW',
status VARCHAR(20) NOT NULL DEFAULT 'PROVISIONING',
logto_org_id VARCHAR(255),
stripe_customer_id VARCHAR(255),
stripe_subscription_id VARCHAR(255),
settings JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_tenants_slug ON tenants (slug);
CREATE INDEX idx_tenants_status ON tenants (status);
CREATE INDEX idx_tenants_logto_org_id ON tenants (logto_org_id);

View File

@@ -0,0 +1,15 @@
CREATE TABLE licenses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
tier VARCHAR(20) NOT NULL,
features JSONB NOT NULL DEFAULT '{}',
limits JSONB NOT NULL DEFAULT '{}',
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
token TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_licenses_tenant_id ON licenses (tenant_id);
CREATE INDEX idx_licenses_expires_at ON licenses (expires_at);

View File

@@ -6,7 +6,7 @@ import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
@SpringBootTest
@Import(TestcontainersConfig.class)
@Import({TestcontainersConfig.class, TestSecurityConfig.class})
@ActiveProfiles("test")
class CameleerSaasApplicationTest {

View File

@@ -0,0 +1,24 @@
package net.siegeln.cameleer.saas;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import java.time.Instant;
import java.util.Map;
@TestConfiguration
public class TestSecurityConfig {
@Bean
public JwtDecoder jwtDecoder() {
return token -> Jwt.withTokenValue(token)
.header("alg", "RS256")
.claim("sub", "test-user")
.claim("iss", "https://test-issuer.example.com/oidc")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(3600))
.build();
}
}

View File

@@ -1,125 +0,0 @@
package net.siegeln.cameleer.saas.auth;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.siegeln.cameleer.saas.TestcontainersConfig;
import net.siegeln.cameleer.saas.auth.dto.LoginRequest;
import net.siegeln.cameleer.saas.auth.dto.RegisterRequest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@Import(TestcontainersConfig.class)
@ActiveProfiles("test")
class AuthControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
void register_returns201WithToken() throws Exception {
var request = new RegisterRequest("newuser@example.com", "New User", "password123");
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.token").isNotEmpty())
.andExpect(jsonPath("$.email").value("newuser@example.com"))
.andExpect(jsonPath("$.name").value("New User"));
}
@Test
void register_returns409ForDuplicateEmail() throws Exception {
var request = new RegisterRequest("duplicate@example.com", "User One", "password123");
// First registration
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated());
// Duplicate registration
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isConflict());
}
@Test
void login_returns200WithToken() throws Exception {
var registerRequest = new RegisterRequest("loginuser@example.com", "Login User", "password123");
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(registerRequest)))
.andExpect(status().isCreated());
var loginRequest = new LoginRequest("loginuser@example.com", "password123");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(loginRequest)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.token").isNotEmpty())
.andExpect(jsonPath("$.email").value("loginuser@example.com"));
}
@Test
void login_returns401ForBadPassword() throws Exception {
var registerRequest = new RegisterRequest("badpass@example.com", "Bad Pass", "password123");
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(registerRequest)))
.andExpect(status().isCreated());
var loginRequest = new LoginRequest("badpass@example.com", "wrong-password");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(loginRequest)))
.andExpect(status().isUnauthorized());
}
@Test
void protectedEndpoint_returns401WithoutToken() throws Exception {
mockMvc.perform(get("/api/health/secured"))
.andExpect(status().isUnauthorized());
}
@Test
void protectedEndpoint_returns200WithValidToken() throws Exception {
// Register to get a token
var registerRequest = new RegisterRequest("secured@example.com", "Secured User", "password123");
var result = mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(registerRequest)))
.andExpect(status().isCreated())
.andReturn();
var responseBody = objectMapper.readTree(result.getResponse().getContentAsString());
String token = responseBody.get("token").asText();
// Access protected endpoint with token
mockMvc.perform(get("/api/health/secured")
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("authenticated"));
}
}

View File

@@ -1,171 +0,0 @@
package net.siegeln.cameleer.saas.auth;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.auth.dto.LoginRequest;
import net.siegeln.cameleer.saas.auth.dto.RegisterRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AuthServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private RoleRepository roleRepository;
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private JwtService jwtService;
@Mock
private AuditService auditService;
private AuthService authService;
@BeforeEach
void setUp() {
authService = new AuthService(userRepository, roleRepository,
passwordEncoder, jwtService, auditService);
}
@Test
void register_createsUserAndReturnsToken() {
var request = new RegisterRequest("user@example.com", "Test User", "password123");
var ownerRole = new RoleEntity();
ownerRole.setName("OWNER");
when(userRepository.existsByEmail("user@example.com")).thenReturn(false);
when(passwordEncoder.encode("password123")).thenReturn("encoded-password");
when(roleRepository.findByName("OWNER")).thenReturn(Optional.of(ownerRole));
when(userRepository.save(any(UserEntity.class))).thenAnswer(invocation -> {
UserEntity user = invocation.getArgument(0);
// simulate ID assignment by persistence
try {
var idField = UserEntity.class.getDeclaredField("id");
idField.setAccessible(true);
idField.set(user, java.util.UUID.randomUUID());
} catch (Exception e) {
throw new RuntimeException(e);
}
return user;
});
when(jwtService.generateToken(any(UserEntity.class))).thenReturn("test-jwt-token");
var response = authService.register(request, "127.0.0.1");
assertNotNull(response);
assertEquals("test-jwt-token", response.token());
assertEquals("user@example.com", response.email());
assertEquals("Test User", response.name());
// Verify audit was logged
verify(auditService).log(
any(), eq("user@example.com"), eq(null),
eq(AuditAction.AUTH_REGISTER), eq(null),
eq(null), eq("127.0.0.1"),
eq("SUCCESS"), eq(null)
);
}
@Test
void register_rejectsDuplicateEmail() {
var request = new RegisterRequest("existing@example.com", "Test User", "password123");
when(userRepository.existsByEmail("existing@example.com")).thenReturn(true);
var exception = assertThrows(IllegalArgumentException.class,
() -> authService.register(request, "127.0.0.1"));
assertEquals("Email already registered", exception.getMessage());
verify(userRepository, never()).save(any());
verify(auditService, never()).log(any(), any(), any(), any(), any(), any(), any(), any(), any());
}
@Test
void login_returnsTokenForValidCredentials() {
var request = new LoginRequest("user@example.com", "password123");
var user = createUserWithId("user@example.com", "encoded-password");
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user));
when(passwordEncoder.matches("password123", "encoded-password")).thenReturn(true);
when(jwtService.generateToken(user)).thenReturn("login-jwt-token");
var response = authService.login(request, "192.168.1.1");
assertNotNull(response);
assertEquals("login-jwt-token", response.token());
assertEquals("user@example.com", response.email());
verify(auditService).log(
any(), eq("user@example.com"), eq(null),
eq(AuditAction.AUTH_LOGIN), eq(null),
eq(null), eq("192.168.1.1"),
eq("SUCCESS"), eq(null)
);
}
@Test
void login_rejectsInvalidPassword() {
var request = new LoginRequest("user@example.com", "wrong-password");
var user = createUserWithId("user@example.com", "encoded-password");
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user));
when(passwordEncoder.matches("wrong-password", "encoded-password")).thenReturn(false);
assertThrows(IllegalArgumentException.class,
() -> authService.login(request, "192.168.1.1"));
// Verify AUTH_LOGIN_FAILED audit was logged
verify(auditService).log(
any(), eq("user@example.com"), eq(null),
eq(AuditAction.AUTH_LOGIN_FAILED), eq(null),
eq(null), eq("192.168.1.1"),
eq("FAILURE"), eq(null)
);
}
@Test
void login_rejectsUnknownEmail() {
var request = new LoginRequest("unknown@example.com", "password123");
when(userRepository.findByEmail("unknown@example.com")).thenReturn(Optional.empty());
var exception = assertThrows(IllegalArgumentException.class,
() -> authService.login(request, "192.168.1.1"));
assertEquals("Invalid credentials", exception.getMessage());
verify(auditService, never()).log(any(), any(), any(), any(), any(), any(), any(), any(), any());
}
private UserEntity createUserWithId(String email, String password) {
var user = new UserEntity();
user.setEmail(email);
user.setName("Test User");
user.setPassword(password);
try {
var idField = UserEntity.class.getDeclaredField("id");
idField.setAccessible(true);
idField.set(user, UUID.randomUUID());
} catch (Exception e) {
throw new RuntimeException(e);
}
return user;
}
}

View File

@@ -0,0 +1,82 @@
package net.siegeln.cameleer.saas.license;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.siegeln.cameleer.saas.TestcontainersConfig;
import net.siegeln.cameleer.saas.TestSecurityConfig;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@Import({TestcontainersConfig.class, TestSecurityConfig.class})
@ActiveProfiles("test")
class LicenseControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
private String createTenantAndGetId() throws Exception {
String slug = "license-tenant-" + System.nanoTime();
var request = new CreateTenantRequest("License Test Org", slug, "MID");
var result = mockMvc.perform(post("/api/tenants")
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andReturn();
return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText();
}
@Test
void generateLicense_returns201WithToken() throws Exception {
String tenantId = createTenantAndGetId();
mockMvc.perform(post("/api/tenants/" + tenantId + "/license")
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.token").isNotEmpty())
.andExpect(jsonPath("$.tier").value("MID"))
.andExpect(jsonPath("$.features.correlation").value(true));
}
@Test
void getActiveLicense_returnsLicense() throws Exception {
String tenantId = createTenantAndGetId();
mockMvc.perform(post("/api/tenants/" + tenantId + "/license")
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.andExpect(status().isCreated());
mockMvc.perform(get("/api/tenants/" + tenantId + "/license")
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.tier").value("MID"));
}
@Test
void getActiveLicense_returns404WhenNone() throws Exception {
String tenantId = createTenantAndGetId();
mockMvc.perform(get("/api/tenants/" + tenantId + "/license")
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.andExpect(status().isNotFound());
}
}

View File

@@ -0,0 +1,142 @@
package net.siegeln.cameleer.saas.license;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.config.JwtConfig;
import net.siegeln.cameleer.saas.tenant.Tier;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import net.siegeln.cameleer.saas.tenant.TenantStatus;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.Duration;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class LicenseServiceTest {
@Mock
private LicenseRepository licenseRepository;
@Mock
private AuditService auditService;
private JwtConfig jwtConfig;
private LicenseService licenseService;
@BeforeEach
void setUp() throws Exception {
jwtConfig = new JwtConfig();
jwtConfig.init(); // generates ephemeral keys for testing
licenseService = new LicenseService(licenseRepository, jwtConfig, auditService);
}
private TenantEntity createTenant(Tier tier) {
var tenant = new TenantEntity();
tenant.setName("Test Tenant");
tenant.setSlug("test");
tenant.setTier(tier);
tenant.setStatus(TenantStatus.ACTIVE);
try {
var idField = TenantEntity.class.getDeclaredField("id");
idField.setAccessible(true);
idField.set(tenant, UUID.randomUUID());
} catch (Exception e) {
throw new RuntimeException(e);
}
return tenant;
}
private static LicenseEntity withGeneratedId(LicenseEntity entity) {
try {
var idField = LicenseEntity.class.getDeclaredField("id");
idField.setAccessible(true);
idField.set(entity, UUID.randomUUID());
} catch (Exception e) {
throw new RuntimeException(e);
}
return entity;
}
@Test
void generateLicense_producesValidSignedToken() {
var tenant = createTenant(Tier.MID);
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
var license = licenseService.generateLicense(tenant, Duration.ofDays(365), UUID.randomUUID());
assertThat(license.getToken()).isNotBlank();
assertThat(license.getToken().split("\\.")).hasSize(3);
assertThat(license.getTier()).isEqualTo("MID");
}
@Test
void generateLicense_setsCorrectFeaturesForTier() {
var tenant = createTenant(Tier.HIGH);
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
var license = licenseService.generateLicense(tenant, Duration.ofDays(30), UUID.randomUUID());
assertThat(license.getFeatures()).containsEntry("debugger", true);
assertThat(license.getFeatures()).containsEntry("replay", true);
}
@Test
void generateLicense_setsCorrectLimitsForTier() {
var tenant = createTenant(Tier.LOW);
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
var license = licenseService.generateLicense(tenant, Duration.ofDays(30), UUID.randomUUID());
assertThat(license.getLimits()).containsEntry("max_agents", 3);
assertThat(license.getLimits()).containsEntry("retention_days", 7);
}
@Test
void verifyLicenseToken_validTokenReturnsPayload() {
var tenant = createTenant(Tier.MID);
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
var license = licenseService.generateLicense(tenant, Duration.ofDays(30), UUID.randomUUID());
var payload = licenseService.verifyLicenseToken(license.getToken());
assertThat(payload).isPresent();
assertThat(payload.get().get("tier")).isEqualTo("MID");
assertThat(payload.get().get("tenant_id")).isEqualTo(tenant.getId().toString());
}
@Test
void verifyLicenseToken_tamperedTokenReturnsEmpty() {
var tenant = createTenant(Tier.MID);
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
var license = licenseService.generateLicense(tenant, Duration.ofDays(30), UUID.randomUUID());
String tampered = license.getToken() + "x";
var payload = licenseService.verifyLicenseToken(tampered);
assertThat(payload).isEmpty();
}
@Test
void generateLicense_logsAuditEvent() {
var tenant = createTenant(Tier.LOW);
var actorId = UUID.randomUUID();
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
licenseService.generateLicense(tenant, Duration.ofDays(30), actorId);
var actionCaptor = ArgumentCaptor.forClass(AuditAction.class);
verify(auditService).log(any(), any(), any(), actionCaptor.capture(), any(), any(), any(), any(), any());
assertThat(actionCaptor.getValue()).isEqualTo(AuditAction.LICENSE_GENERATE);
}
}

View File

@@ -0,0 +1,97 @@
package net.siegeln.cameleer.saas.tenant;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.siegeln.cameleer.saas.TestcontainersConfig;
import net.siegeln.cameleer.saas.TestSecurityConfig;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@Import({TestcontainersConfig.class, TestSecurityConfig.class})
@ActiveProfiles("test")
class TenantControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
void createTenant_returns201() throws Exception {
var request = new CreateTenantRequest("Test Org", "test-org-" + System.nanoTime(), "LOW");
mockMvc.perform(post("/api/tenants")
.with(jwt().jwt(j -> j
.claim("sub", "test-user")
.claim("organization_id", "test-org")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.name").value("Test Org"))
.andExpect(jsonPath("$.tier").value("LOW"))
.andExpect(jsonPath("$.status").value("PROVISIONING"));
}
@Test
void createTenant_returns409ForDuplicateSlug() throws Exception {
String slug = "duplicate-slug-" + System.nanoTime();
var request = new CreateTenantRequest("First", slug, null);
mockMvc.perform(post("/api/tenants")
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated());
mockMvc.perform(post("/api/tenants")
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isConflict());
}
@Test
void createTenant_returns401WithoutToken() throws Exception {
var request = new CreateTenantRequest("Test", "no-auth-test", null);
mockMvc.perform(post("/api/tenants")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isUnauthorized());
}
@Test
void getTenant_returnsTenantById() throws Exception {
String slug = "get-test-" + System.nanoTime();
var request = new CreateTenantRequest("Get Test", slug, null);
var createResult = mockMvc.perform(post("/api/tenants")
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andReturn();
String id = objectMapper.readTree(createResult.getResponse().getContentAsString()).get("id").asText();
mockMvc.perform(get("/api/tenants/" + id)
.with(jwt().jwt(j -> j.claim("sub", "test-user"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.slug").value(slug));
}
}

View File

@@ -0,0 +1,124 @@
package net.siegeln.cameleer.saas.tenant;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class TenantServiceTest {
@Mock
private TenantRepository tenantRepository;
@Mock
private AuditService auditService;
@Mock
private LogtoManagementClient logtoClient;
private TenantService tenantService;
@BeforeEach
void setUp() {
tenantService = new TenantService(tenantRepository, auditService, logtoClient);
}
@Test
void create_savesNewTenantWithCorrectFields() {
var request = new CreateTenantRequest("Acme Corp", "acme-corp", "MID");
var actorId = UUID.randomUUID();
when(tenantRepository.existsBySlug("acme-corp")).thenReturn(false);
when(tenantRepository.save(any(TenantEntity.class))).thenAnswer(inv -> inv.getArgument(0));
var result = tenantService.create(request, actorId);
assertThat(result.getName()).isEqualTo("Acme Corp");
assertThat(result.getSlug()).isEqualTo("acme-corp");
assertThat(result.getTier()).isEqualTo(Tier.MID);
assertThat(result.getStatus()).isEqualTo(TenantStatus.PROVISIONING);
}
@Test
void create_throwsForDuplicateSlug() {
var request = new CreateTenantRequest("Acme Corp", "acme-corp", null);
when(tenantRepository.existsBySlug("acme-corp")).thenReturn(true);
assertThatThrownBy(() -> tenantService.create(request, UUID.randomUUID()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Slug already taken");
}
@Test
void create_logsAuditEvent() {
var request = new CreateTenantRequest("Acme Corp", "acme-corp", null);
var actorId = UUID.randomUUID();
when(tenantRepository.existsBySlug("acme-corp")).thenReturn(false);
when(tenantRepository.save(any(TenantEntity.class))).thenAnswer(inv -> inv.getArgument(0));
tenantService.create(request, actorId);
var actionCaptor = ArgumentCaptor.forClass(AuditAction.class);
verify(auditService).log(any(), any(), any(), actionCaptor.capture(), any(), any(), any(), any(), any());
assertThat(actionCaptor.getValue()).isEqualTo(AuditAction.TENANT_CREATE);
}
@Test
void create_defaultsToLowTier() {
var request = new CreateTenantRequest("Acme Corp", "acme-corp", null);
when(tenantRepository.existsBySlug("acme-corp")).thenReturn(false);
when(tenantRepository.save(any(TenantEntity.class))).thenAnswer(inv -> inv.getArgument(0));
var result = tenantService.create(request, UUID.randomUUID());
assertThat(result.getTier()).isEqualTo(Tier.LOW);
}
@Test
void getById_returnsTenant() {
var id = UUID.randomUUID();
var entity = new TenantEntity();
entity.setName("Test");
entity.setSlug("test");
when(tenantRepository.findById(id)).thenReturn(Optional.of(entity));
var result = tenantService.getById(id);
assertThat(result).isPresent();
assertThat(result.get().getName()).isEqualTo("Test");
}
@Test
void getBySlug_returnsTenant() {
var entity = new TenantEntity();
entity.setName("Test");
entity.setSlug("test");
when(tenantRepository.findBySlug("test")).thenReturn(Optional.of(entity));
var result = tenantService.getBySlug("test");
assertThat(result).isPresent();
assertThat(result.get().getSlug()).isEqualTo("test");
}
}

14
traefik.yml Normal file
View File

@@ -0,0 +1,14 @@
api:
dashboard: false
entryPoints:
web:
address: ":80"
websecure:
address: ":443"
providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false
network: cameleer