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