Rename Java packages from net.siegeln.cameleer3 to net.siegeln.cameleer, update all references in workflows, Docker configs, docs, and bootstrap. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
465 lines
14 KiB
Markdown
465 lines
14 KiB
Markdown
# 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 cameleer 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"
|
|
```
|