Compare commits
19 Commits
fcb372023f
...
5d14f78b9d
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d14f78b9d | |||
|
|
eb4e0b2b07 | ||
|
|
cd866ec7fe | ||
|
|
b0eba3c709 | ||
|
|
d9f0da6e91 | ||
|
|
0e3d314dd1 | ||
|
|
db7647f7f4 | ||
|
|
ab9ad1ab7f | ||
|
|
42bd116af1 | ||
|
|
0f3bd209a1 | ||
|
|
e58e2caf8e | ||
|
|
0d9c51843d | ||
|
|
9a575eaa94 | ||
|
|
d987969e05 | ||
|
|
a74894e0f1 | ||
|
|
c1cae25db7 | ||
|
|
119034307c | ||
|
|
0a2d5970e4 | ||
|
|
24309eab94 |
25
.env.example
Normal file
25
.env.example
Normal 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
|
||||
@@ -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
21
docker-compose.dev.yml
Normal 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"
|
||||
@@ -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
7
docker/init-databases.sh
Normal 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
@@ -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
|
||||
```
|
||||
7
pom.xml
7
pom.xml
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package net.siegeln.cameleer.saas.auth.dto;
|
||||
|
||||
public record AuthResponse(
|
||||
String token,
|
||||
String email,
|
||||
String name
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("Ed25519");
|
||||
this.keyPair = keyGen.generateKeyPair();
|
||||
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() {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package net.siegeln.cameleer.saas.tenant;
|
||||
|
||||
public enum TenantStatus {
|
||||
PROVISIONING, ACTIVE, SUSPENDED, DELETED
|
||||
}
|
||||
5
src/main/java/net/siegeln/cameleer/saas/tenant/Tier.java
Normal file
5
src/main/java/net/siegeln/cameleer/saas/tenant/Tier.java
Normal file
@@ -0,0 +1,5 @@
|
||||
package net.siegeln.cameleer.saas.tenant;
|
||||
|
||||
public enum Tier {
|
||||
LOW, MID, HIGH, BUSINESS
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -3,3 +3,8 @@ spring:
|
||||
show-sql: false
|
||||
flyway:
|
||||
clean-disabled: false
|
||||
security:
|
||||
oauth2:
|
||||
resourceserver:
|
||||
jwt:
|
||||
issuer-uri: https://test-issuer.example.com/oidc
|
||||
|
||||
@@ -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:}
|
||||
|
||||
17
src/main/resources/db/migration/V005__create_tenants.sql
Normal file
17
src/main/resources/db/migration/V005__create_tenants.sql
Normal 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);
|
||||
15
src/main/resources/db/migration/V006__create_licenses.sql
Normal file
15
src/main/resources/db/migration/V006__create_licenses.sql
Normal 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);
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
14
traefik.yml
Normal 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
|
||||
Reference in New Issue
Block a user