Files
cameleer-saas/docs/superpowers/plans/2026-04-15-per-tenant-pg-isolation-plan.md
hsiegeln 63c194dab7
Some checks failed
CI / build (push) Failing after 18s
CI / docker (push) Has been skipped
chore: rename cameleer3 to cameleer
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>
2026-04-15 15:28:44 +02:00

14 KiB

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

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
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:

@Column(name = "db_password")
private String dbPassword;

After the setProvisionError method (line 102), add:

public String getDbPassword() { return dbPassword; }
public void setDbPassword(String dbPassword) { this.dbPassword = dbPassword; }
  • Step 2: Commit
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

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
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:

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
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:

var env = new java.util.ArrayList<>(List.of(
    "SPRING_DATASOURCE_URL=" + props.datasourceUrl(),
    "SPRING_DATASOURCE_USERNAME=" + props.datasourceUsername(),
    "SPRING_DATASOURCE_PASSWORD=" + props.datasourcePassword(),

With:

// 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
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:

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:

var provisionRequest = new TenantProvisionRequest(tenantId, slug, tier, licenseToken);
ProvisionResult result = tenantProvisioner.provision(provisionRequest);

With:

// 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:

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:

// Erase tenant data from server databases (GDPR)
dataCleanupService.cleanup(tenant.getSlug());

With:

// Drop per-tenant PG schema + user
tenantDatabaseService.dropTenantDatabase(tenant.getSlug());

// Erase ClickHouse data (GDPR)
dataCleanupService.cleanupClickHouse(tenant.getSlug());
  • Step 5: Commit
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:

/**
 * 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
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
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:

-- 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
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:

-- 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
git add -A
git commit -m "feat: per-tenant PostgreSQL isolation — complete implementation"