feat: full tenant cleanup on delete — Docker resources, PG schema, CH data (#55)
All checks were successful
CI / build (push) Successful in 2m23s
CI / docker (push) Successful in 1m6s

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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-11 09:10:47 +02:00
parent 3284304c1f
commit dd8553a8b4
8 changed files with 157 additions and 7 deletions

View File

@@ -80,6 +80,14 @@
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- ClickHouse JDBC (tenant data cleanup on delete) -->
<dependency>
<groupId>com.clickhouse</groupId>
<artifactId>clickhouse-jdbc</artifactId>
<version>0.9.7</version>
<classifier>all</classifier>
</dependency>
<!-- Docker Java (tenant provisioning) -->
<dependency>
<groupId>com.github.docker-java</groupId>

View File

@@ -95,9 +95,49 @@ public class DockerTenantProvisioner implements TenantProvisioner {
@Override
public void remove(String 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

View File

@@ -11,6 +11,7 @@ public record ProvisioningProperties(
String publicHost,
String publicProtocol,
String datasourceUrl,
String clickhouseUrl,
String oidcIssuerUri,
String oidcJwkSetUri,
String corsOrigins

View File

@@ -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<String> 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());
}
}
}

View File

@@ -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);

View File

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

View File

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

View File

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