Merge pull request 'Phase 2: Tenants + Identity + Licensing' (#32) from feature/phase-2-tenants-identity-licensing into main
Reviewed-on: #32
This commit was merged in pull request #32.
This commit is contained in:
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
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main, 'feature/**', 'fix/**', 'feat/**']
|
||||||
|
tags-ignore:
|
||||||
|
- 'v*'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
services:
|
if: github.event_name != 'delete'
|
||||||
postgres:
|
container:
|
||||||
image: postgres:16-alpine
|
image: gitea.siegeln.net/cameleer/cameleer-build:1
|
||||||
env:
|
credentials:
|
||||||
POSTGRES_DB: cameleer_saas_test
|
username: cameleer
|
||||||
POSTGRES_USER: test
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
POSTGRES_PASSWORD: test
|
|
||||||
ports:
|
|
||||||
- 5432:5432
|
|
||||||
options: >-
|
|
||||||
--health-cmd pg_isready
|
|
||||||
--health-interval 10s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 5
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-java@v4
|
- name: Cache Maven dependencies
|
||||||
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
path: ~/.m2/repository
|
||||||
java-version: 21
|
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
|
||||||
cache: maven
|
restore-keys: ${{ runner.os }}-maven-
|
||||||
|
|
||||||
- name: Run tests
|
- name: Build and Test (unit tests only)
|
||||||
run: ./mvnw verify -B
|
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:
|
env:
|
||||||
SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/cameleer_saas_test
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
SPRING_DATASOURCE_USERNAME: test
|
|
||||||
SPRING_DATASOURCE_PASSWORD: test
|
|
||||||
|
|
||||||
- name: Build Docker image
|
- name: Login to registry
|
||||||
run: docker build -t cameleer-saas:${{ github.sha }} .
|
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:
|
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:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: cameleer_saas
|
POSTGRES_DB: ${POSTGRES_DB:-cameleer_saas}
|
||||||
POSTGRES_USER: cameleer
|
POSTGRES_USER: ${POSTGRES_USER:-cameleer}
|
||||||
POSTGRES_PASSWORD: cameleer_dev
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- 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:
|
volumes:
|
||||||
pgdata:
|
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>
|
<properties>
|
||||||
<java.version>21</java.version>
|
<java.version>21</java.version>
|
||||||
|
<testcontainers.version>1.21.4</testcontainers.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
@@ -34,6 +35,12 @@
|
|||||||
<artifactId>spring-boot-starter-security</artifactId>
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- OAuth2 Resource Server (Logto OIDC) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- JPA + PostgreSQL -->
|
<!-- JPA + PostgreSQL -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<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,
|
APP_CREATE, APP_DEPLOY, APP_PROMOTE, APP_ROLLBACK, APP_SCALE, APP_STOP, APP_DELETE,
|
||||||
SECRET_CREATE, SECRET_READ, SECRET_UPDATE, SECRET_DELETE, SECRET_ROTATE,
|
SECRET_CREATE, SECRET_READ, SECRET_UPDATE, SECRET_DELETE, SECRET_ROTATE,
|
||||||
CONFIG_UPDATE,
|
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;
|
package net.siegeln.cameleer.saas.config;
|
||||||
|
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Component;
|
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.KeyPair;
|
||||||
import java.security.KeyPairGenerator;
|
import java.security.KeyPairGenerator;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.security.PrivateKey;
|
import java.security.PrivateKey;
|
||||||
import java.security.PublicKey;
|
import java.security.PublicKey;
|
||||||
|
import java.security.spec.InvalidKeySpecException;
|
||||||
|
import java.security.spec.PKCS8EncodedKeySpec;
|
||||||
|
import java.security.spec.X509EncodedKeySpec;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class JwtConfig {
|
public class JwtConfig {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(JwtConfig.class);
|
||||||
|
|
||||||
@Value("${cameleer.jwt.expiration:86400}")
|
@Value("${cameleer.jwt.expiration:86400}")
|
||||||
private long expirationSeconds = 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;
|
private KeyPair keyPair;
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void init() throws NoSuchAlgorithmException {
|
public void init() throws NoSuchAlgorithmException, IOException, InvalidKeySpecException {
|
||||||
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("Ed25519");
|
if (privateKeyPath.isEmpty() || publicKeyPath.isEmpty()) {
|
||||||
this.keyPair = keyGen.generateKeyPair();
|
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() {
|
public PrivateKey getPrivateKey() {
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ package net.siegeln.cameleer.saas.config;
|
|||||||
import net.siegeln.cameleer.saas.auth.JwtAuthenticationFilter;
|
import net.siegeln.cameleer.saas.auth.JwtAuthenticationFilter;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
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.method.configuration.EnableMethodSecurity;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
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.SecurityFilterChain;
|
||||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
|
|
||||||
@@ -17,23 +19,41 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
|
|||||||
@EnableMethodSecurity
|
@EnableMethodSecurity
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
private final JwtAuthenticationFilter machineTokenFilter;
|
||||||
|
private final TenantResolutionFilter tenantResolutionFilter;
|
||||||
|
|
||||||
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
|
public SecurityConfig(JwtAuthenticationFilter machineTokenFilter, TenantResolutionFilter tenantResolutionFilter) {
|
||||||
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
|
this.machineTokenFilter = machineTokenFilter;
|
||||||
|
this.tenantResolutionFilter = tenantResolutionFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@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
|
http
|
||||||
.csrf(csrf -> csrf.disable())
|
.csrf(csrf -> csrf.disable())
|
||||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/api/auth/**").permitAll()
|
|
||||||
.requestMatchers("/actuator/health").permitAll()
|
.requestMatchers("/actuator/health").permitAll()
|
||||||
|
.requestMatchers("/auth/verify").permitAll()
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
)
|
)
|
||||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> {}))
|
||||||
|
.addFilterBefore(machineTokenFilter, UsernamePasswordAuthenticationFilter.class)
|
||||||
|
.addFilterAfter(tenantResolutionFilter, BearerTokenAuthenticationFilter.class);
|
||||||
|
|
||||||
return http.build();
|
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
|
show-sql: false
|
||||||
flyway:
|
flyway:
|
||||||
clean-disabled: false
|
clean-disabled: false
|
||||||
|
security:
|
||||||
|
oauth2:
|
||||||
|
resourceserver:
|
||||||
|
jwt:
|
||||||
|
issuer-uri: https://test-issuer.example.com/oidc
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ spring:
|
|||||||
flyway:
|
flyway:
|
||||||
enabled: true
|
enabled: true
|
||||||
locations: classpath:db/migration
|
locations: classpath:db/migration
|
||||||
|
security:
|
||||||
|
oauth2:
|
||||||
|
resourceserver:
|
||||||
|
jwt:
|
||||||
|
issuer-uri: ${LOGTO_ISSUER_URI:}
|
||||||
|
jwk-set-uri: ${LOGTO_JWK_SET_URI:}
|
||||||
|
|
||||||
management:
|
management:
|
||||||
endpoints:
|
endpoints:
|
||||||
@@ -21,3 +27,9 @@ management:
|
|||||||
cameleer:
|
cameleer:
|
||||||
jwt:
|
jwt:
|
||||||
expiration: 86400 # 24 hours in seconds
|
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;
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@Import(TestcontainersConfig.class)
|
@Import({TestcontainersConfig.class, TestSecurityConfig.class})
|
||||||
@ActiveProfiles("test")
|
@ActiveProfiles("test")
|
||||||
class CameleerSaasApplicationTest {
|
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