# 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 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- | 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" ```