From a7196ff4c113a74ce7dbead3f08382bc228a0e4b Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 15 Apr 2026 00:11:34 +0200 Subject: [PATCH] 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) --- ...2026-04-15-per-tenant-pg-isolation-plan.md | 464 ++++++++++++++++++ 1 file changed, 464 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-15-per-tenant-pg-isolation-plan.md diff --git a/docs/superpowers/plans/2026-04-15-per-tenant-pg-isolation-plan.md b/docs/superpowers/plans/2026-04-15-per-tenant-pg-isolation-plan.md new file mode 100644 index 0000000..0508353 --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-per-tenant-pg-isolation-plan.md @@ -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_`) 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- | grep -E "DATASOURCE|currentSchema|ApplicationName" +``` + +Expected: URL contains `?currentSchema=tenant_&ApplicationName=tenant_`, username is `tenant_`. + +- [ ] **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" +```