10 Commits

Author SHA1 Message Date
hsiegeln
24a443ef30 refactor: consolidate Flyway migrations into single V001 baseline
Some checks failed
CI / build (push) Failing after 51s
CI / docker (push) Has been skipped
Replace 14 incremental migrations (V001-V015) with a single V001__init.sql
representing the final schema. Tables that were created and later dropped
(environments, api_keys, apps, deployments) are excluded.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:24:25 +02:00
hsiegeln
d7eb700860 refactor: move PG cleanup to TenantDatabaseService, keep only ClickHouse
TenantDataCleanupService now handles only ClickHouse GDPR erasure;
the dropPostgresSchema private method is removed and the public method
renamed cleanupClickHouse(). VendorTenantService updated accordingly
with the TODO comment removed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 00:17:00 +02:00
hsiegeln
c1458e4995 feat: create per-tenant PG database during provisioning, drop on delete
Inject TenantDatabaseService; call createTenantDatabase() at the start
of provisionAsync() (stores generated password on TenantEntity), and
dropTenantDatabase() in delete() before GDPR data erasure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 00:16:06 +02:00
hsiegeln
b79a7fe405 feat: construct per-tenant JDBC URL with currentSchema and ApplicationName
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 00:14:35 +02:00
hsiegeln
6d6c1f3562 feat: add TenantDatabaseService for per-tenant PG user+schema
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 00:13:34 +02:00
hsiegeln
0e3f383cf4 feat: add dbPassword to TenantProvisionRequest 2026-04-15 00:13:27 +02:00
hsiegeln
cd6dd1e5af feat: add dbPassword field to TenantEntity 2026-04-15 00:13:12 +02:00
hsiegeln
dfa2a6bfa2 feat: add db_password column to tenants table (V015) 2026-04-15 00:13:11 +02:00
hsiegeln
a7196ff4c1 docs: per-tenant PostgreSQL isolation implementation plan
8-task plan covering migration, entity change, TenantDatabaseService,
provisioner JDBC URL construction, VendorTenantService integration,
and TenantDataCleanupService refactor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:11:34 +02:00
hsiegeln
17c6723f7e docs: per-tenant PostgreSQL isolation design spec
Per-tenant PG users and schemas for DB-level data isolation.
Each tenant server gets its own credentials and currentSchema/ApplicationName
JDBC parameters, aligned with server team's commit 7a63135.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:08:35 +02:00
22 changed files with 879 additions and 193 deletions

View File

@@ -0,0 +1,464 @@
# Per-Tenant PostgreSQL Isolation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Give each tenant its own PostgreSQL user and schema so tenant servers can only access their own data at the database level.
**Architecture:** During provisioning, create a dedicated PG user (`tenant_<slug>`) with a matching schema. Pass per-tenant credentials and `currentSchema`/`ApplicationName` JDBC parameters to the server container. On delete, drop both schema and user. Existing tenants without `dbPassword` fall back to shared credentials for backwards compatibility.
**Tech Stack:** Java 21, Spring Boot 3.4, Flyway, PostgreSQL 16, Docker Java API
**Spec:** `docs/superpowers/specs/2026-04-15-per-tenant-pg-isolation-design.md`
---
### Task 1: Flyway Migration — add `db_password` column
**Files:**
- Create: `src/main/resources/db/migration/V015__add_tenant_db_password.sql`
- [ ] **Step 1: Create migration file**
```sql
ALTER TABLE tenants ADD COLUMN db_password VARCHAR(255);
```
- [ ] **Step 2: Verify migration applies**
Run: `mvn flyway:info -pl .` or start the app and check logs for `V015__add_tenant_db_password` in Flyway output.
- [ ] **Step 3: Commit**
```bash
git add src/main/resources/db/migration/V015__add_tenant_db_password.sql
git commit -m "feat: add db_password column to tenants table (V015)"
```
---
### Task 2: TenantEntity — add `dbPassword` field
**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/tenant/TenantEntity.java`
- [ ] **Step 1: Add field and accessors**
After the `provisionError` field (line 59), add:
```java
@Column(name = "db_password")
private String dbPassword;
```
After the `setProvisionError` method (line 102), add:
```java
public String getDbPassword() { return dbPassword; }
public void setDbPassword(String dbPassword) { this.dbPassword = dbPassword; }
```
- [ ] **Step 2: Commit**
```bash
git add src/main/java/net/siegeln/cameleer/saas/tenant/TenantEntity.java
git commit -m "feat: add dbPassword field to TenantEntity"
```
---
### Task 3: Create `TenantDatabaseService`
**Files:**
- Create: `src/main/java/net/siegeln/cameleer/saas/provisioning/TenantDatabaseService.java`
- [ ] **Step 1: Implement the service**
```java
package net.siegeln.cameleer.saas.provisioning;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;
/**
* Creates and drops per-tenant PostgreSQL users and schemas
* on the shared cameleer3 database for DB-level tenant isolation.
*/
@Service
public class TenantDatabaseService {
private static final Logger log = LoggerFactory.getLogger(TenantDatabaseService.class);
private final ProvisioningProperties props;
public TenantDatabaseService(ProvisioningProperties props) {
this.props = props;
}
/**
* Create a dedicated PG user and schema for a tenant.
* Idempotent — skips if user/schema already exist.
*/
public void createTenantDatabase(String slug, String password) {
validateSlug(slug);
String url = props.datasourceUrl();
if (url == null || url.isBlank()) {
log.warn("No datasource URL configured — skipping tenant DB setup");
return;
}
String user = "tenant_" + slug;
String schema = "tenant_" + slug;
try (Connection conn = DriverManager.getConnection(url, props.datasourceUsername(), props.datasourcePassword());
Statement stmt = conn.createStatement()) {
// Create user if not exists
boolean userExists;
try (ResultSet rs = stmt.executeQuery(
"SELECT 1 FROM pg_roles WHERE rolname = '" + user + "'")) {
userExists = rs.next();
}
if (!userExists) {
stmt.execute("CREATE USER \"" + user + "\" WITH PASSWORD '" + escapePassword(password) + "'");
log.info("Created PostgreSQL user: {}", user);
} else {
// Update password on re-provision
stmt.execute("ALTER USER \"" + user + "\" WITH PASSWORD '" + escapePassword(password) + "'");
log.info("Updated password for existing PostgreSQL user: {}", user);
}
// Create schema if not exists
boolean schemaExists;
try (ResultSet rs = stmt.executeQuery(
"SELECT 1 FROM information_schema.schemata WHERE schema_name = '" + schema + "'")) {
schemaExists = rs.next();
}
if (!schemaExists) {
stmt.execute("CREATE SCHEMA \"" + schema + "\" AUTHORIZATION \"" + user + "\"");
log.info("Created PostgreSQL schema: {}", schema);
} else {
// Ensure ownership is correct
stmt.execute("ALTER SCHEMA \"" + schema + "\" OWNER TO \"" + user + "\"");
log.info("Schema {} already exists — ensured ownership", schema);
}
// Revoke access to public schema
stmt.execute("REVOKE ALL ON SCHEMA public FROM \"" + user + "\"");
} catch (Exception e) {
throw new RuntimeException("Failed to create tenant database for '" + slug + "': " + e.getMessage(), e);
}
}
/**
* Drop tenant schema (CASCADE) and user. Idempotent.
*/
public void dropTenantDatabase(String slug) {
validateSlug(slug);
String url = props.datasourceUrl();
if (url == null || url.isBlank()) {
log.warn("No datasource URL configured — skipping tenant DB cleanup");
return;
}
String user = "tenant_" + slug;
String schema = "tenant_" + slug;
try (Connection conn = DriverManager.getConnection(url, props.datasourceUsername(), props.datasourcePassword());
Statement stmt = conn.createStatement()) {
stmt.execute("DROP SCHEMA IF EXISTS \"" + schema + "\" CASCADE");
log.info("Dropped PostgreSQL schema: {}", schema);
stmt.execute("DROP USER IF EXISTS \"" + user + "\"");
log.info("Dropped PostgreSQL user: {}", user);
} catch (Exception e) {
log.warn("Failed to drop tenant database for '{}': {}", slug, e.getMessage());
}
}
private void validateSlug(String slug) {
if (slug == null || !slug.matches("^[a-z0-9-]+$")) {
throw new IllegalArgumentException("Invalid tenant slug: " + slug);
}
}
private String escapePassword(String password) {
return password.replace("'", "''");
}
}
```
- [ ] **Step 2: Commit**
```bash
git add src/main/java/net/siegeln/cameleer/saas/provisioning/TenantDatabaseService.java
git commit -m "feat: add TenantDatabaseService for per-tenant PG user+schema"
```
---
### Task 4: Add `dbPassword` to `TenantProvisionRequest`
**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/provisioning/TenantProvisionRequest.java`
- [ ] **Step 1: Add field to record**
Replace the entire record with:
```java
package net.siegeln.cameleer.saas.provisioning;
import java.util.UUID;
public record TenantProvisionRequest(
UUID tenantId,
String slug,
String tier,
String licenseToken,
String dbPassword
) {}
```
- [ ] **Step 2: Commit**
```bash
git add src/main/java/net/siegeln/cameleer/saas/provisioning/TenantProvisionRequest.java
git commit -m "feat: add dbPassword to TenantProvisionRequest"
```
---
### Task 5: Update `DockerTenantProvisioner` — per-tenant JDBC URL
**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java:197-200`
- [ ] **Step 1: Replace shared credentials with per-tenant credentials**
In `createServerContainer()` (line 197-200), replace:
```java
var env = new java.util.ArrayList<>(List.of(
"SPRING_DATASOURCE_URL=" + props.datasourceUrl(),
"SPRING_DATASOURCE_USERNAME=" + props.datasourceUsername(),
"SPRING_DATASOURCE_PASSWORD=" + props.datasourcePassword(),
```
With:
```java
// Per-tenant DB isolation: dedicated user+schema when dbPassword is set,
// shared credentials for backwards compatibility with pre-isolation tenants.
String dsUrl;
String dsUser;
String dsPass;
if (req.dbPassword() != null) {
dsUrl = props.datasourceUrl() + "?currentSchema=tenant_" + slug + "&ApplicationName=tenant_" + slug;
dsUser = "tenant_" + slug;
dsPass = req.dbPassword();
} else {
dsUrl = props.datasourceUrl();
dsUser = props.datasourceUsername();
dsPass = props.datasourcePassword();
}
var env = new java.util.ArrayList<>(List.of(
"SPRING_DATASOURCE_URL=" + dsUrl,
"SPRING_DATASOURCE_USERNAME=" + dsUser,
"SPRING_DATASOURCE_PASSWORD=" + dsPass,
```
- [ ] **Step 2: Commit**
```bash
git add src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java
git commit -m "feat: construct per-tenant JDBC URL with currentSchema and ApplicationName"
```
---
### Task 6: Update `VendorTenantService` — provisioning and delete flows
**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java`
- [ ] **Step 1: Inject `TenantDatabaseService`**
Add to the constructor and field declarations:
```java
private final TenantDatabaseService tenantDatabaseService;
```
Add to the constructor parameter list and assignment. (Follow the existing pattern of other injected services.)
- [ ] **Step 2: Update `provisionAsync()` — create DB before containers**
In `provisionAsync()` (around line 120), add DB creation before the provision call. Replace:
```java
var provisionRequest = new TenantProvisionRequest(tenantId, slug, tier, licenseToken);
ProvisionResult result = tenantProvisioner.provision(provisionRequest);
```
With:
```java
// Create per-tenant PG user + schema
String dbPassword = UUID.randomUUID().toString().replace("-", "")
+ UUID.randomUUID().toString().replace("-", "").substring(0, 8);
try {
tenantDatabaseService.createTenantDatabase(slug, dbPassword);
} catch (Exception e) {
log.error("Failed to create tenant database for {}: {}", slug, e.getMessage(), e);
tenantRepository.findById(tenantId).ifPresent(t -> {
t.setProvisionError("Database setup failed: " + e.getMessage());
tenantRepository.save(t);
});
return;
}
// Store DB password on entity
TenantEntity tenantForDb = tenantRepository.findById(tenantId).orElse(null);
if (tenantForDb == null) {
log.error("Tenant {} disappeared during provisioning", slug);
return;
}
tenantForDb.setDbPassword(dbPassword);
tenantRepository.save(tenantForDb);
var provisionRequest = new TenantProvisionRequest(tenantId, slug, tier, licenseToken, dbPassword);
ProvisionResult result = tenantProvisioner.provision(provisionRequest);
```
- [ ] **Step 3: Update the existing `TenantProvisionRequest` constructor call in upgrade flow**
Search for any other `new TenantProvisionRequest(...)` calls. The `upgradeServer` method (or re-provision after upgrade) also creates a provision request. Update it to pass `dbPassword` from the entity:
```java
TenantEntity tenant = ...;
var provisionRequest = new TenantProvisionRequest(
tenant.getId(), tenant.getSlug(), tenant.getTier().name(),
licenseToken, tenant.getDbPassword());
```
If the tenant has `dbPassword == null` (pre-existing), this is fine — Task 5 handles the null fallback.
- [ ] **Step 4: Update `delete()` — use TenantDatabaseService**
In `delete()` (around line 306), replace:
```java
// Erase tenant data from server databases (GDPR)
dataCleanupService.cleanup(tenant.getSlug());
```
With:
```java
// Drop per-tenant PG schema + user
tenantDatabaseService.dropTenantDatabase(tenant.getSlug());
// Erase ClickHouse data (GDPR)
dataCleanupService.cleanupClickHouse(tenant.getSlug());
```
- [ ] **Step 5: Commit**
```bash
git add src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java
git commit -m "feat: create per-tenant PG database during provisioning, drop on delete"
```
---
### Task 7: Refactor `TenantDataCleanupService` — ClickHouse only
**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/provisioning/TenantDataCleanupService.java`
- [ ] **Step 1: Remove PG logic, rename public method**
Remove the `dropPostgresSchema()` method and the `cleanup()` method. Replace with a single public method:
```java
/**
* Deletes tenant data from ClickHouse tables (GDPR data erasure).
* PostgreSQL cleanup is handled by TenantDatabaseService.
*/
public void cleanupClickHouse(String slug) {
deleteClickHouseData(slug);
}
```
Remove the `dropPostgresSchema()` private method entirely. Keep `deleteClickHouseData()` unchanged.
- [ ] **Step 2: Commit**
```bash
git add src/main/java/net/siegeln/cameleer/saas/provisioning/TenantDataCleanupService.java
git commit -m "refactor: move PG cleanup to TenantDatabaseService, keep only ClickHouse"
```
---
### Task 8: Verify end-to-end
- [ ] **Step 1: Build**
```bash
mvn compile -pl .
```
Verify no compilation errors.
- [ ] **Step 2: Deploy and test tenant creation**
Deploy the updated SaaS image. Create a new tenant via the UI. Verify in PostgreSQL:
```sql
-- Should show the new tenant user
SELECT rolname FROM pg_roles WHERE rolname LIKE 'tenant_%';
-- Should show the new tenant schema
SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant_%';
```
- [ ] **Step 3: Verify server container env vars**
```bash
docker inspect cameleer-server-<slug> | grep -E "DATASOURCE|currentSchema|ApplicationName"
```
Expected: URL contains `?currentSchema=tenant_<slug>&ApplicationName=tenant_<slug>`, username is `tenant_<slug>`.
- [ ] **Step 4: Verify Infrastructure page**
Navigate to Vendor > Infrastructure. The PostgreSQL card should now show the tenant schema with size/tables/rows.
- [ ] **Step 5: Test tenant deletion**
Delete the tenant. Verify:
```sql
-- User should be gone
SELECT rolname FROM pg_roles WHERE rolname LIKE 'tenant_%';
-- Schema should be gone
SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant_%';
```
- [ ] **Step 6: Commit all remaining changes**
```bash
git add -A
git commit -m "feat: per-tenant PostgreSQL isolation — complete implementation"
```

View File

@@ -0,0 +1,147 @@
# Per-Tenant PostgreSQL Isolation
**Date:** 2026-04-15
**Status:** Approved
## Context
The cameleer3-server team introduced `currentSchema` and `ApplicationName` JDBC parameters (commit `7a63135`) to scope admin diagnostic queries to a single tenant's connections. Previously, all tenant servers shared one PostgreSQL user and connected to the `cameleer3` database without schema isolation — a tenant's server could theoretically see SQL text from other tenants via `pg_stat_activity`.
This spec adds per-tenant PostgreSQL users and schemas so each tenant server can only access its own data at the database level.
## Architecture
### Current State
- All tenant servers connect as the shared admin PG user to `cameleer3` database, `public` schema.
- No per-tenant schemas exist — the server's Flyway runs in `public`.
- `TenantDataCleanupService` already attempts `DROP SCHEMA tenant_<slug>` on delete (no-op today since schemas don't exist).
- Standalone mode sets `currentSchema=tenant_default` in the compose file and is unaffected by this change.
### Target State
- Each tenant gets a dedicated PG user (`tenant_<slug>`) and schema (`tenant_<slug>`).
- The tenant user owns only its schema. `REVOKE ALL ON SCHEMA public` prevents cross-tenant access.
- The server's Flyway runs inside `tenant_<slug>` via the `currentSchema` JDBC parameter.
- `ApplicationName=tenant_<slug>` scopes `pg_stat_activity` visibility per the server team's convention.
- On tenant delete, both schema and user are dropped.
## New Component: `TenantDatabaseService`
A focused service with two methods:
```java
@Service
public class TenantDatabaseService {
void createTenantDatabase(String slug, String password);
void dropTenantDatabase(String slug);
}
```
### `createTenantDatabase(slug, password)`
Connects to `cameleer3` using the admin PG credentials from `ProvisioningProperties`. Executes:
1. Validate slug against `^[a-z0-9-]+$` (reject unexpected characters).
2. `CREATE USER "tenant_<slug>" WITH PASSWORD '<password>'` (skip if user already exists — idempotent for re-provisioning).
3. `CREATE SCHEMA "tenant_<slug>" AUTHORIZATION "tenant_<slug>"` (skip if schema already exists).
4. `REVOKE ALL ON SCHEMA public FROM "tenant_<slug>"`.
All identifiers are double-quoted. The password is a 32-character random alphanumeric string generated by the same `SecureRandom` utility used for other credential generation.
### `dropTenantDatabase(slug)`
1. `DROP SCHEMA IF EXISTS "tenant_<slug>" CASCADE`
2. `DROP USER IF EXISTS "tenant_<slug>"`
Schema must be dropped first (with `CASCADE`) because PG won't drop a user that owns objects.
## Entity Change
**New Flyway migration:** `V014__add_tenant_db_password.sql`
```sql
ALTER TABLE tenants ADD COLUMN db_password VARCHAR(255);
```
Nullable — existing tenants won't have it. Code checks for null and falls back to shared credentials for backwards compatibility.
**TenantEntity:** new `dbPassword` field with JPA `@Column` mapping.
## Provisioning Flow Changes
### `VendorTenantService.provisionAsync()` — new steps before container creation
```
1. Generate 32-char random password
2. tenantDatabaseService.createTenantDatabase(slug, password)
3. entity.setDbPassword(password)
4. tenantRepository.save(entity)
5. tenantProvisioner.provision(request) ← request now includes dbPassword
6. ... rest unchanged (health check, license push, OIDC push)
```
If step 2 fails, provisioning aborts with a stored error — no orphaned containers.
### `DockerTenantProvisioner` — JDBC URL construction
The `ProvisionRequest` record gains `dbPassword` field.
**When `dbPassword` is present** (new tenants):
```
SPRING_DATASOURCE_URL=jdbc:postgresql://cameleer-postgres:5432/cameleer3?currentSchema=tenant_<slug>&ApplicationName=tenant_<slug>
SPRING_DATASOURCE_USERNAME=tenant_<slug>
SPRING_DATASOURCE_PASSWORD=<generated>
```
**When `dbPassword` is null** (pre-existing tenants, backwards compat):
```
SPRING_DATASOURCE_URL=<props.datasourceUrl()> (no currentSchema/ApplicationName)
SPRING_DATASOURCE_USERNAME=<props.datasourceUsername()>
SPRING_DATASOURCE_PASSWORD=<props.datasourcePassword()>
```
Server restart/upgrade re-creates containers via `provisionAsync()`, which re-reads `dbPassword` from the entity. Restarting an upgraded tenant picks up isolated credentials automatically.
## Delete Flow Changes
### `VendorTenantService.delete()`
```
1. tenantProvisioner.remove(slug) ← existing
2. licenseService.revokeLicense(...) ← existing
3. logtoClient.deleteOrganization(...) ← existing
4. tenantDatabaseService.dropTenantDatabase(slug) ← replaces TenantDataCleanupService PG logic
5. dataCleanupService.cleanupClickHouse(slug) ← ClickHouse cleanup stays separate
6. entity.setStatus(DELETED) ← existing
```
`TenantDataCleanupService` loses its PostgreSQL cleanup responsibility (delegated to `TenantDatabaseService`). It keeps only the ClickHouse cleanup. Rename method to `cleanupClickHouse(slug)` for clarity.
## Backwards Compatibility
| Scenario | Behavior |
|----------|----------|
| **Standalone mode** | Unaffected. Server is in compose, not provisioned by SaaS. Defaults to `tenant_default`. |
| **Existing SaaS tenants** (dbPassword=null) | Shared credentials, no `currentSchema`. Same as before. |
| **Existing tenants after restart/upgrade** | Still use shared credentials until re-provisioned with new code. |
| **New tenants** | Isolated user+schema+JDBC URL. Full isolation. |
| **Delete of pre-existing tenant** | `DROP USER IF EXISTS` is a no-op (user doesn't exist). Schema drop unchanged. |
## InfrastructureService
No changes needed. Already queries `information_schema.schemata WHERE schema_name LIKE 'tenant_%'`. With per-tenant schemas now created, the PostgreSQL tenant table on the Infrastructure page will populate automatically.
## Files Changed
| File | Change |
|------|--------|
| `TenantDatabaseService.java` | **New** — create/drop PG user+schema |
| `TenantEntity.java` | Add `dbPassword` field |
| `V014__add_tenant_db_password.sql` | **New** — nullable column |
| `VendorTenantService.java` | Call `createTenantDatabase` in provision, `dropTenantDatabase` in delete |
| `DockerTenantProvisioner.java` | Construct per-tenant JDBC URL, username, password |
| `ProvisionRequest` record | Add `dbPassword` field |
| `TenantDataCleanupService.java` | Remove PG logic, keep ClickHouse only, rename method |

View File

@@ -194,10 +194,24 @@ public class DockerTenantProvisioner implements TenantProvisioner {
labels.put("prometheus.path", "/api/v1/prometheus");
labels.put("prometheus.port", "8081");
// Per-tenant DB isolation: dedicated user+schema when dbPassword is set,
// shared credentials for backwards compatibility with pre-isolation tenants.
String dsUrl;
String dsUser;
String dsPass;
if (req.dbPassword() != null) {
dsUrl = props.datasourceUrl() + "?currentSchema=tenant_" + slug + "&ApplicationName=tenant_" + slug;
dsUser = "tenant_" + slug;
dsPass = req.dbPassword();
} else {
dsUrl = props.datasourceUrl();
dsUser = props.datasourceUsername();
dsPass = props.datasourcePassword();
}
var env = new java.util.ArrayList<>(List.of(
"SPRING_DATASOURCE_URL=" + props.datasourceUrl(),
"SPRING_DATASOURCE_USERNAME=" + props.datasourceUsername(),
"SPRING_DATASOURCE_PASSWORD=" + props.datasourcePassword(),
"SPRING_DATASOURCE_URL=" + dsUrl,
"SPRING_DATASOURCE_USERNAME=" + dsUser,
"SPRING_DATASOURCE_PASSWORD=" + dsPass,
"CAMELEER_SERVER_CLICKHOUSE_URL=jdbc:clickhouse://cameleer-clickhouse:8123/cameleer",
"CAMELEER_SERVER_CLICKHOUSE_USERNAME=" + props.clickhouseUser(),
"CAMELEER_SERVER_CLICKHOUSE_PASSWORD=" + props.clickhousePassword(),

View File

@@ -12,8 +12,8 @@ import java.util.ArrayList;
import java.util.List;
/**
* Cleans up tenant data from the server PostgreSQL and ClickHouse databases
* when a tenant is deleted (GDPR data erasure).
* Deletes tenant data from ClickHouse tables when a tenant is deleted
* (GDPR data erasure). PostgreSQL cleanup is handled by TenantDatabaseService.
*/
@Service
public class TenantDataCleanupService {
@@ -26,33 +26,14 @@ public class TenantDataCleanupService {
this.props = props;
}
public void cleanup(String slug) {
dropPostgresSchema(slug);
/**
* Deletes tenant data from ClickHouse tables (GDPR data erasure).
* PostgreSQL cleanup is handled by TenantDatabaseService.
*/
public void cleanupClickHouse(String slug) {
deleteClickHouseData(slug);
}
private void dropPostgresSchema(String slug) {
String url = props.datasourceUrl();
if (url == null || url.isBlank()) {
log.warn("No server datasource URL configured — skipping PostgreSQL schema cleanup");
return;
}
String schema = "tenant_" + slug;
if (!schema.matches("^[a-z0-9_-]+$")) {
log.error("Refusing to drop schema with unexpected characters: {}", schema);
return;
}
try (Connection conn = DriverManager.getConnection(url, props.datasourceUsername(), props.datasourcePassword());
Statement stmt = conn.createStatement()) {
stmt.execute("DROP SCHEMA IF EXISTS \"" + schema + "\" CASCADE");
log.info("Dropped PostgreSQL schema: {}", schema);
} catch (Exception e) {
log.warn("Failed to drop PostgreSQL schema '{}': {}", schema, e.getMessage());
}
}
private void deleteClickHouseData(String slug) {
String url = props.clickhouseUrl();
if (url == null || url.isBlank()) {

View File

@@ -0,0 +1,104 @@
package net.siegeln.cameleer.saas.provisioning;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;
@Service
public class TenantDatabaseService {
private static final Logger log = LoggerFactory.getLogger(TenantDatabaseService.class);
private final ProvisioningProperties props;
public TenantDatabaseService(ProvisioningProperties props) {
this.props = props;
}
public void createTenantDatabase(String slug, String password) {
validateSlug(slug);
String url = props.datasourceUrl();
if (url == null || url.isBlank()) {
log.warn("No datasource URL configured — skipping tenant DB setup");
return;
}
String user = "tenant_" + slug;
String schema = "tenant_" + slug;
try (Connection conn = DriverManager.getConnection(url, props.datasourceUsername(), props.datasourcePassword());
Statement stmt = conn.createStatement()) {
boolean userExists;
try (ResultSet rs = stmt.executeQuery(
"SELECT 1 FROM pg_roles WHERE rolname = '" + user + "'")) {
userExists = rs.next();
}
if (!userExists) {
stmt.execute("CREATE USER \"" + user + "\" WITH PASSWORD '" + escapePassword(password) + "'");
log.info("Created PostgreSQL user: {}", user);
} else {
stmt.execute("ALTER USER \"" + user + "\" WITH PASSWORD '" + escapePassword(password) + "'");
log.info("Updated password for existing PostgreSQL user: {}", user);
}
boolean schemaExists;
try (ResultSet rs = stmt.executeQuery(
"SELECT 1 FROM information_schema.schemata WHERE schema_name = '" + schema + "'")) {
schemaExists = rs.next();
}
if (!schemaExists) {
stmt.execute("CREATE SCHEMA \"" + schema + "\" AUTHORIZATION \"" + user + "\"");
log.info("Created PostgreSQL schema: {}", schema);
} else {
stmt.execute("ALTER SCHEMA \"" + schema + "\" OWNER TO \"" + user + "\"");
log.info("Schema {} already exists — ensured ownership", schema);
}
stmt.execute("REVOKE ALL ON SCHEMA public FROM \"" + user + "\"");
} catch (Exception e) {
throw new RuntimeException("Failed to create tenant database for '" + slug + "': " + e.getMessage(), e);
}
}
public void dropTenantDatabase(String slug) {
validateSlug(slug);
String url = props.datasourceUrl();
if (url == null || url.isBlank()) {
log.warn("No datasource URL configured — skipping tenant DB cleanup");
return;
}
String user = "tenant_" + slug;
String schema = "tenant_" + slug;
try (Connection conn = DriverManager.getConnection(url, props.datasourceUsername(), props.datasourcePassword());
Statement stmt = conn.createStatement()) {
stmt.execute("DROP SCHEMA IF EXISTS \"" + schema + "\" CASCADE");
log.info("Dropped PostgreSQL schema: {}", schema);
stmt.execute("DROP USER IF EXISTS \"" + user + "\"");
log.info("Dropped PostgreSQL user: {}", user);
} catch (Exception e) {
log.warn("Failed to drop tenant database for '{}': {}", slug, e.getMessage());
}
}
private void validateSlug(String slug) {
if (slug == null || !slug.matches("^[a-z0-9-]+$")) {
throw new IllegalArgumentException("Invalid tenant slug: " + slug);
}
}
private String escapePassword(String password) {
return password.replace("'", "''");
}
}

View File

@@ -6,5 +6,6 @@ public record TenantProvisionRequest(
UUID tenantId,
String slug,
String tier,
String licenseToken
String licenseToken,
String dbPassword
) {}

View File

@@ -58,6 +58,9 @@ public class TenantEntity {
@Column(name = "provision_error", columnDefinition = "TEXT")
private String provisionError;
@Column(name = "db_password")
private String dbPassword;
@Column(name = "ca_applied_at")
private Instant caAppliedAt;
@@ -100,6 +103,8 @@ public class TenantEntity {
public void setServerEndpoint(String serverEndpoint) { this.serverEndpoint = serverEndpoint; }
public String getProvisionError() { return provisionError; }
public void setProvisionError(String provisionError) { this.provisionError = provisionError; }
public String getDbPassword() { return dbPassword; }
public void setDbPassword(String dbPassword) { this.dbPassword = dbPassword; }
public Instant getCaAppliedAt() { return caAppliedAt; }
public void setCaAppliedAt(Instant caAppliedAt) { this.caAppliedAt = caAppliedAt; }
public Instant getCreatedAt() { return createdAt; }

View File

@@ -6,6 +6,7 @@ import net.siegeln.cameleer.saas.identity.LogtoConfig;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.identity.ServerApiClient;
import net.siegeln.cameleer.saas.provisioning.ProvisioningProperties;
import net.siegeln.cameleer.saas.provisioning.TenantDatabaseService;
import net.siegeln.cameleer.saas.provisioning.TenantDataCleanupService;
import net.siegeln.cameleer.saas.identity.ServerApiClient.ServerHealthResponse;
import net.siegeln.cameleer.saas.license.LicenseEntity;
@@ -47,6 +48,7 @@ public class VendorTenantService {
private final AuditService auditService;
private final ProvisioningProperties provisioningProps;
private final TenantDataCleanupService dataCleanupService;
private final TenantDatabaseService tenantDatabaseService;
public VendorTenantService(TenantService tenantService,
TenantRepository tenantRepository,
@@ -57,7 +59,8 @@ public class VendorTenantService {
LogtoConfig logtoConfig,
AuditService auditService,
ProvisioningProperties provisioningProps,
TenantDataCleanupService dataCleanupService) {
TenantDataCleanupService dataCleanupService,
TenantDatabaseService tenantDatabaseService) {
this.tenantService = tenantService;
this.tenantRepository = tenantRepository;
this.licenseService = licenseService;
@@ -68,6 +71,7 @@ public class VendorTenantService {
this.auditService = auditService;
this.provisioningProps = provisioningProps;
this.dataCleanupService = dataCleanupService;
this.tenantDatabaseService = tenantDatabaseService;
}
@Transactional
@@ -119,7 +123,30 @@ public class VendorTenantService {
@Async
public void provisionAsync(UUID tenantId, String slug, String tier, String licenseToken, UUID actorId) {
try {
var provisionRequest = new TenantProvisionRequest(tenantId, slug, tier, licenseToken);
// Create per-tenant PG user + schema
String dbPassword = java.util.UUID.randomUUID().toString().replace("-", "")
+ java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 8);
try {
tenantDatabaseService.createTenantDatabase(slug, dbPassword);
} catch (Exception e) {
log.error("Failed to create tenant database for {}: {}", slug, e.getMessage(), e);
tenantRepository.findById(tenantId).ifPresent(t -> {
t.setProvisionError("Database setup failed: " + e.getMessage());
tenantRepository.save(t);
});
return;
}
// Store DB password on entity
TenantEntity tenantForDb = tenantRepository.findById(tenantId).orElse(null);
if (tenantForDb == null) {
log.error("Tenant {} disappeared during provisioning", slug);
return;
}
tenantForDb.setDbPassword(dbPassword);
tenantRepository.save(tenantForDb);
var provisionRequest = new TenantProvisionRequest(tenantId, slug, tier, licenseToken, dbPassword);
ProvisionResult result = tenantProvisioner.provision(provisionRequest);
TenantEntity tenant = tenantRepository.findById(tenantId).orElse(null);
@@ -302,8 +329,15 @@ public class VendorTenantService {
}
}
// Erase tenant data from server databases (GDPR)
dataCleanupService.cleanup(tenant.getSlug());
// Drop per-tenant PG user + database
try {
tenantDatabaseService.dropTenantDatabase(tenant.getSlug());
} catch (Exception e) {
log.warn("Failed to drop tenant database for {}: {}", tenant.getSlug(), e.getMessage());
}
// Erase tenant data from ClickHouse (GDPR)
dataCleanupService.cleanupClickHouse(tenant.getSlug());
// Soft-delete
tenant.setStatus(TenantStatus.DELETED);

View File

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

View File

@@ -0,0 +1,95 @@
-- Cameleer SaaS schema baseline
-- Consolidated from V001-V015
-- Tenants
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) NOT NULL,
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 '{}',
server_endpoint VARCHAR(512),
provision_error TEXT,
db_password VARCHAR(255),
ca_applied_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX tenants_slug_active_key ON tenants (slug) WHERE status != 'DELETED';
CREATE INDEX idx_tenants_status ON tenants (status);
CREATE INDEX idx_tenants_logto_org_id ON tenants (logto_org_id);
-- Licenses
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);
-- Audit log
CREATE TABLE audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
actor_id UUID,
actor_email VARCHAR(255),
tenant_id UUID,
action VARCHAR(100) NOT NULL,
resource VARCHAR(500),
environment VARCHAR(50),
source_ip VARCHAR(45),
result VARCHAR(20) NOT NULL DEFAULT 'SUCCESS',
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_audit_log_tenant ON audit_log (tenant_id, created_at DESC);
CREATE INDEX idx_audit_log_actor ON audit_log (actor_id, created_at DESC);
CREATE INDEX idx_audit_log_action ON audit_log (action, created_at DESC);
-- Platform TLS certificates
CREATE TABLE certificates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
status VARCHAR(10) NOT NULL CHECK (status IN ('ACTIVE', 'STAGED', 'ARCHIVED')),
subject VARCHAR(500),
issuer VARCHAR(500),
not_before TIMESTAMPTZ,
not_after TIMESTAMPTZ,
fingerprint VARCHAR(128),
has_ca BOOLEAN NOT NULL DEFAULT FALSE,
self_signed BOOLEAN NOT NULL DEFAULT FALSE,
uploaded_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
activated_at TIMESTAMPTZ,
archived_at TIMESTAMPTZ
);
-- Per-tenant CA certificates
CREATE TABLE tenant_ca_certs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
status VARCHAR(10) NOT NULL CHECK (status IN ('ACTIVE', 'STAGED')),
label VARCHAR(200),
subject VARCHAR(500),
issuer VARCHAR(500),
fingerprint VARCHAR(128),
not_before TIMESTAMPTZ,
not_after TIMESTAMPTZ,
cert_pem TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_tenant_ca_certs_tenant ON tenant_ca_certs(tenant_id);

View File

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

View File

@@ -1,12 +0,0 @@
CREATE TABLE environments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
slug VARCHAR(100) NOT NULL,
display_name VARCHAR(255) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, slug)
);
CREATE INDEX idx_environments_tenant_id ON environments(tenant_id);

View File

@@ -1,12 +0,0 @@
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
environment_id UUID NOT NULL REFERENCES environments(id) ON DELETE CASCADE,
key_hash VARCHAR(64) NOT NULL,
key_prefix VARCHAR(12) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked_at TIMESTAMPTZ
);
CREATE INDEX idx_api_keys_env ON api_keys(environment_id);
CREATE INDEX idx_api_keys_hash ON api_keys(key_hash);

View File

@@ -1,18 +0,0 @@
CREATE TABLE apps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
environment_id UUID NOT NULL REFERENCES environments(id) ON DELETE CASCADE,
slug VARCHAR(100) NOT NULL,
display_name VARCHAR(255) NOT NULL,
jar_storage_path VARCHAR(500),
jar_checksum VARCHAR(64),
jar_original_filename VARCHAR(255),
jar_size_bytes BIGINT,
exposed_port INTEGER,
current_deployment_id UUID,
previous_deployment_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(environment_id, slug)
);
CREATE INDEX idx_apps_environment_id ON apps(environment_id);

View File

@@ -1,16 +0,0 @@
CREATE TABLE deployments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
image_ref VARCHAR(500) NOT NULL,
desired_status VARCHAR(20) NOT NULL DEFAULT 'RUNNING',
observed_status VARCHAR(20) NOT NULL DEFAULT 'BUILDING',
orchestrator_metadata JSONB DEFAULT '{}',
error_message TEXT,
deployed_at TIMESTAMPTZ,
stopped_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(app_id, version)
);
CREATE INDEX idx_deployments_app_id ON deployments(app_id);

View File

@@ -1,17 +0,0 @@
CREATE TABLE audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
actor_id UUID,
actor_email VARCHAR(255),
tenant_id UUID,
action VARCHAR(100) NOT NULL,
resource VARCHAR(500),
environment VARCHAR(50),
source_ip VARCHAR(45),
result VARCHAR(20) NOT NULL DEFAULT 'SUCCESS',
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_audit_log_tenant ON audit_log (tenant_id, created_at DESC);
CREATE INDEX idx_audit_log_actor ON audit_log (actor_id, created_at DESC);
CREATE INDEX idx_audit_log_action ON audit_log (action, created_at DESC);

View File

@@ -1,2 +0,0 @@
ALTER TABLE apps ADD COLUMN memory_limit VARCHAR(20);
ALTER TABLE apps ADD COLUMN cpu_shares INTEGER;

View File

@@ -1,7 +0,0 @@
-- V010__drop_migrated_tables.sql
-- Drop tables that have been migrated to cameleer3-server
DROP TABLE IF EXISTS deployments CASCADE;
DROP TABLE IF EXISTS apps CASCADE;
DROP TABLE IF EXISTS environments CASCADE;
DROP TABLE IF EXISTS api_keys CASCADE;

View File

@@ -1,3 +0,0 @@
-- V011__add_provisioning_fields.sql
ALTER TABLE tenants ADD COLUMN server_endpoint VARCHAR(512);
ALTER TABLE tenants ADD COLUMN provision_error TEXT;

View File

@@ -1,19 +0,0 @@
-- Certificate management: track platform TLS certs and CA bundles
CREATE TABLE certificates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
status VARCHAR(10) NOT NULL CHECK (status IN ('ACTIVE', 'STAGED', 'ARCHIVED')),
subject VARCHAR(500),
issuer VARCHAR(500),
not_before TIMESTAMPTZ,
not_after TIMESTAMPTZ,
fingerprint VARCHAR(128),
has_ca BOOLEAN NOT NULL DEFAULT FALSE,
self_signed BOOLEAN NOT NULL DEFAULT FALSE,
uploaded_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
activated_at TIMESTAMPTZ,
archived_at TIMESTAMPTZ
);
-- Track when each tenant last picked up the CA bundle
ALTER TABLE tenants ADD COLUMN ca_applied_at TIMESTAMPTZ;

View File

@@ -1,16 +0,0 @@
-- Per-tenant CA certificates for enterprise SSO trust
CREATE TABLE tenant_ca_certs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
status VARCHAR(10) NOT NULL CHECK (status IN ('ACTIVE', 'STAGED')),
label VARCHAR(200),
subject VARCHAR(500),
issuer VARCHAR(500),
fingerprint VARCHAR(128),
not_before TIMESTAMPTZ,
not_after TIMESTAMPTZ,
cert_pem TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_tenant_ca_certs_tenant ON tenant_ca_certs(tenant_id);

View File

@@ -1,5 +0,0 @@
-- Replace absolute unique constraint on slug with partial unique index
-- that excludes DELETED tenants, allowing slug reuse after soft-delete.
ALTER TABLE tenants DROP CONSTRAINT tenants_slug_key;
DROP INDEX IF EXISTS idx_tenants_slug;
CREATE UNIQUE INDEX tenants_slug_active_key ON tenants (slug) WHERE status != 'DELETED';