From dd8553a8b4eb1f27d8813050863cef85e46b5f34 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 11 Apr 2026 09:10:47 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20full=20tenant=20cleanup=20on=20delete?= =?UTF-8?q?=20=E2=80=94=20Docker=20resources,=20PG=20schema,=20CH=20data?= =?UTF-8?q?=20(#55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DockerTenantProvisioner.remove() now cleans up all tenant Docker resources: containers (by cameleer.tenant label), env networks, tenant network, JAR volume. TenantDataCleanupService drops the tenant's PostgreSQL schema and deletes all ClickHouse data for GDPR compliance. Co-Authored-By: Claude Opus 4.6 (1M context) --- pom.xml | 8 ++ .../provisioning/DockerTenantProvisioner.java | 44 ++++++++- .../provisioning/ProvisioningProperties.java | 1 + .../TenantDataCleanupService.java | 89 +++++++++++++++++++ .../saas/vendor/VendorTenantService.java | 9 +- src/main/resources/application.yml | 1 + .../saas/portal/TenantPortalServiceTest.java | 2 +- .../saas/vendor/VendorTenantServiceTest.java | 10 ++- 8 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 src/main/java/net/siegeln/cameleer/saas/provisioning/TenantDataCleanupService.java diff --git a/pom.xml b/pom.xml index 4acdeb2..0469bd9 100644 --- a/pom.xml +++ b/pom.xml @@ -80,6 +80,14 @@ spring-boot-starter-actuator + + + com.clickhouse + clickhouse-jdbc + 0.9.7 + all + + com.github.docker-java diff --git a/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java b/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java index ab43101..b0a7cad 100644 --- a/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java +++ b/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java @@ -95,9 +95,49 @@ public class DockerTenantProvisioner implements TenantProvisioner { @Override public void remove(String slug) { - removeContainer(uiContainerName(slug)); - removeContainer(serverContainerName(slug)); + // 1. Remove ALL containers labeled for this tenant (app containers + server + UI) + try { + var containers = docker.listContainersCmd() + .withLabelFilter(List.of("cameleer.tenant=" + slug)) + .withShowAll(true) + .exec(); + for (var container : containers) { + try { + docker.removeContainerCmd(container.getId()).withForce(true).exec(); + log.info("Removed tenant container: {} ({})", container.getNames(), container.getId().substring(0, 12)); + } catch (NotFoundException ignored) {} + } + } catch (Exception e) { + log.warn("Failed to list/remove tenant containers for '{}': {}", slug, e.getMessage()); + // Fall back to named removal for server/UI + removeContainer(uiContainerName(slug)); + removeContainer(serverContainerName(slug)); + } + + // 2. Remove per-environment networks (cameleer-env-{slug}-*) + try { + String envNetPrefix = "cameleer-env-" + slug + "-"; + var networks = docker.listNetworksCmd().exec(); + for (var network : networks) { + if (network.getName().startsWith(envNetPrefix)) { + removeNetwork(network.getName()); + } + } + } catch (Exception e) { + log.warn("Failed to clean up env networks for '{}': {}", slug, e.getMessage()); + } + + // 3. Remove tenant network removeNetwork(tenantNetworkName(slug)); + + // 4. Remove JAR volume + try { + docker.removeVolumeCmd("cameleer-jars-" + slug).exec(); + log.info("Removed JAR volume: cameleer-jars-{}", slug); + } catch (NotFoundException ignored) { + } catch (Exception e) { + log.warn("Failed to remove JAR volume for '{}': {}", slug, e.getMessage()); + } } @Override diff --git a/src/main/java/net/siegeln/cameleer/saas/provisioning/ProvisioningProperties.java b/src/main/java/net/siegeln/cameleer/saas/provisioning/ProvisioningProperties.java index e01870d..62d77f4 100644 --- a/src/main/java/net/siegeln/cameleer/saas/provisioning/ProvisioningProperties.java +++ b/src/main/java/net/siegeln/cameleer/saas/provisioning/ProvisioningProperties.java @@ -11,6 +11,7 @@ public record ProvisioningProperties( String publicHost, String publicProtocol, String datasourceUrl, + String clickhouseUrl, String oidcIssuerUri, String oidcJwkSetUri, String corsOrigins diff --git a/src/main/java/net/siegeln/cameleer/saas/provisioning/TenantDataCleanupService.java b/src/main/java/net/siegeln/cameleer/saas/provisioning/TenantDataCleanupService.java new file mode 100644 index 0000000..c1a3d09 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/provisioning/TenantDataCleanupService.java @@ -0,0 +1,89 @@ +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; +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). + */ +@Service +public class TenantDataCleanupService { + + private static final Logger log = LoggerFactory.getLogger(TenantDataCleanupService.class); + + private final ProvisioningProperties props; + + public TenantDataCleanupService(ProvisioningProperties props) { + this.props = props; + } + + public void cleanup(String slug) { + dropPostgresSchema(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, "cameleer", "cameleer_dev"); + 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()) { + log.warn("No ClickHouse URL configured — skipping ClickHouse data cleanup"); + return; + } + + try (Connection conn = DriverManager.getConnection(url); + Statement stmt = conn.createStatement()) { + + // Find all tables with a tenant_id column + List tables = new ArrayList<>(); + try (ResultSet rs = stmt.executeQuery( + "SELECT DISTINCT table FROM system.columns " + + "WHERE database = currentDatabase() AND name = 'tenant_id'")) { + while (rs.next()) { + tables.add(rs.getString(1)); + } + } + + for (String table : tables) { + try { + stmt.execute("ALTER TABLE `" + table + "` DELETE WHERE tenant_id = '" + slug + "'"); + log.info("Deleted ClickHouse data for tenant '{}' from table '{}'", slug, table); + } catch (Exception e) { + log.warn("Failed to delete from ClickHouse table '{}' for tenant '{}': {}", + table, slug, e.getMessage()); + } + } + } catch (Exception e) { + log.warn("Failed to clean up ClickHouse data for tenant '{}': {}", slug, e.getMessage()); + } + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java index 6237dab..fd39639 100644 --- a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java +++ b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java @@ -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.TenantDataCleanupService; import net.siegeln.cameleer.saas.identity.ServerApiClient.ServerHealthResponse; import net.siegeln.cameleer.saas.license.LicenseEntity; import net.siegeln.cameleer.saas.license.LicenseService; @@ -45,6 +46,7 @@ public class VendorTenantService { private final LogtoConfig logtoConfig; private final AuditService auditService; private final ProvisioningProperties provisioningProps; + private final TenantDataCleanupService dataCleanupService; public VendorTenantService(TenantService tenantService, TenantRepository tenantRepository, @@ -54,7 +56,8 @@ public class VendorTenantService { LogtoManagementClient logtoClient, LogtoConfig logtoConfig, AuditService auditService, - ProvisioningProperties provisioningProps) { + ProvisioningProperties provisioningProps, + TenantDataCleanupService dataCleanupService) { this.tenantService = tenantService; this.tenantRepository = tenantRepository; this.licenseService = licenseService; @@ -64,6 +67,7 @@ public class VendorTenantService { this.logtoConfig = logtoConfig; this.auditService = auditService; this.provisioningProps = provisioningProps; + this.dataCleanupService = dataCleanupService; } @Transactional @@ -296,6 +300,9 @@ public class VendorTenantService { } } + // Erase tenant data from server databases (GDPR) + dataCleanupService.cleanup(tenant.getSlug()); + // Soft-delete tenant.setStatus(TenantStatus.DELETED); tenantRepository.save(tenant); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4df2043..0d39f4f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -49,6 +49,7 @@ cameleer: public-host: ${PUBLIC_HOST:localhost} public-protocol: ${PUBLIC_PROTOCOL:https} datasource-url: ${CAMELEER_SERVER_DB_URL:jdbc:postgresql://postgres:5432/cameleer3} + clickhouse-url: ${CLICKHOUSE_URL:jdbc:clickhouse://clickhouse:8123/cameleer} oidc-issuer-uri: ${PUBLIC_PROTOCOL:https}://${PUBLIC_HOST:localhost}/oidc oidc-jwk-set-uri: http://logto:3001/oidc/jwks cors-origins: ${PUBLIC_PROTOCOL:https}://${PUBLIC_HOST:localhost} diff --git a/src/test/java/net/siegeln/cameleer/saas/portal/TenantPortalServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/portal/TenantPortalServiceTest.java index 18466a3..4a86de9 100644 --- a/src/test/java/net/siegeln/cameleer/saas/portal/TenantPortalServiceTest.java +++ b/src/test/java/net/siegeln/cameleer/saas/portal/TenantPortalServiceTest.java @@ -47,7 +47,7 @@ class TenantPortalServiceTest { private TenantProvisioner tenantProvisioner; private final ProvisioningProperties provisioningProps = new ProvisioningProperties( - null, null, null, null, "test.example.com", "https", null, null, null, null); + null, null, null, null, "test.example.com", "https", null, null, null, null, null); private TenantPortalService tenantPortalService; diff --git a/src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantServiceTest.java index 96aa36c..ac8476c 100644 --- a/src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantServiceTest.java +++ b/src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantServiceTest.java @@ -5,6 +5,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.TenantDataCleanupService; import net.siegeln.cameleer.saas.license.LicenseEntity; import net.siegeln.cameleer.saas.license.LicenseService; import net.siegeln.cameleer.saas.provisioning.ProvisionResult; @@ -61,18 +62,21 @@ class VendorTenantServiceTest { @Mock private AuditService auditService; + @Mock + private TenantDataCleanupService dataCleanupService; + private VendorTenantService vendorTenantService; @BeforeEach void setUp() { var provisioningProps = new ProvisioningProperties( "img", "uiimg", "net", "traefik", "localhost", "https", - "jdbc:postgresql://pg:5432/db", "https://localhost/oidc", - "http://logto:3001/oidc/jwks", "https://localhost"); + "jdbc:postgresql://pg:5432/db", "jdbc:clickhouse://ch:8123/cameleer", + "https://localhost/oidc", "http://logto:3001/oidc/jwks", "https://localhost"); vendorTenantService = new VendorTenantService( tenantService, tenantRepository, licenseService, tenantProvisioner, serverApiClient, logtoClient, logtoConfig, - auditService, provisioningProps); + auditService, provisioningProps, dataCleanupService); } // --- Helpers ---