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> <artifactId>spring-boot-starter-actuator</artifactId>
</dependency> </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) --> <!-- Docker Java (tenant provisioning) -->
<dependency> <dependency>
<groupId>com.github.docker-java</groupId> <groupId>com.github.docker-java</groupId>

View File

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

View File

@@ -11,6 +11,7 @@ public record ProvisioningProperties(
String publicHost, String publicHost,
String publicProtocol, String publicProtocol,
String datasourceUrl, String datasourceUrl,
String clickhouseUrl,
String oidcIssuerUri, String oidcIssuerUri,
String oidcJwkSetUri, String oidcJwkSetUri,
String corsOrigins 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.LogtoManagementClient;
import net.siegeln.cameleer.saas.identity.ServerApiClient; import net.siegeln.cameleer.saas.identity.ServerApiClient;
import net.siegeln.cameleer.saas.provisioning.ProvisioningProperties; 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.identity.ServerApiClient.ServerHealthResponse;
import net.siegeln.cameleer.saas.license.LicenseEntity; import net.siegeln.cameleer.saas.license.LicenseEntity;
import net.siegeln.cameleer.saas.license.LicenseService; import net.siegeln.cameleer.saas.license.LicenseService;
@@ -45,6 +46,7 @@ public class VendorTenantService {
private final LogtoConfig logtoConfig; private final LogtoConfig logtoConfig;
private final AuditService auditService; private final AuditService auditService;
private final ProvisioningProperties provisioningProps; private final ProvisioningProperties provisioningProps;
private final TenantDataCleanupService dataCleanupService;
public VendorTenantService(TenantService tenantService, public VendorTenantService(TenantService tenantService,
TenantRepository tenantRepository, TenantRepository tenantRepository,
@@ -54,7 +56,8 @@ public class VendorTenantService {
LogtoManagementClient logtoClient, LogtoManagementClient logtoClient,
LogtoConfig logtoConfig, LogtoConfig logtoConfig,
AuditService auditService, AuditService auditService,
ProvisioningProperties provisioningProps) { ProvisioningProperties provisioningProps,
TenantDataCleanupService dataCleanupService) {
this.tenantService = tenantService; this.tenantService = tenantService;
this.tenantRepository = tenantRepository; this.tenantRepository = tenantRepository;
this.licenseService = licenseService; this.licenseService = licenseService;
@@ -64,6 +67,7 @@ public class VendorTenantService {
this.logtoConfig = logtoConfig; this.logtoConfig = logtoConfig;
this.auditService = auditService; this.auditService = auditService;
this.provisioningProps = provisioningProps; this.provisioningProps = provisioningProps;
this.dataCleanupService = dataCleanupService;
} }
@Transactional @Transactional
@@ -296,6 +300,9 @@ public class VendorTenantService {
} }
} }
// Erase tenant data from server databases (GDPR)
dataCleanupService.cleanup(tenant.getSlug());
// Soft-delete // Soft-delete
tenant.setStatus(TenantStatus.DELETED); tenant.setStatus(TenantStatus.DELETED);
tenantRepository.save(tenant); tenantRepository.save(tenant);

View File

@@ -49,6 +49,7 @@ cameleer:
public-host: ${PUBLIC_HOST:localhost} public-host: ${PUBLIC_HOST:localhost}
public-protocol: ${PUBLIC_PROTOCOL:https} public-protocol: ${PUBLIC_PROTOCOL:https}
datasource-url: ${CAMELEER_SERVER_DB_URL:jdbc:postgresql://postgres:5432/cameleer3} 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-issuer-uri: ${PUBLIC_PROTOCOL:https}://${PUBLIC_HOST:localhost}/oidc
oidc-jwk-set-uri: http://logto:3001/oidc/jwks oidc-jwk-set-uri: http://logto:3001/oidc/jwks
cors-origins: ${PUBLIC_PROTOCOL:https}://${PUBLIC_HOST:localhost} cors-origins: ${PUBLIC_PROTOCOL:https}://${PUBLIC_HOST:localhost}

View File

@@ -47,7 +47,7 @@ class TenantPortalServiceTest {
private TenantProvisioner tenantProvisioner; private TenantProvisioner tenantProvisioner;
private final ProvisioningProperties provisioningProps = new ProvisioningProperties( 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; 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.LogtoManagementClient;
import net.siegeln.cameleer.saas.identity.ServerApiClient; import net.siegeln.cameleer.saas.identity.ServerApiClient;
import net.siegeln.cameleer.saas.provisioning.ProvisioningProperties; 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.LicenseEntity;
import net.siegeln.cameleer.saas.license.LicenseService; import net.siegeln.cameleer.saas.license.LicenseService;
import net.siegeln.cameleer.saas.provisioning.ProvisionResult; import net.siegeln.cameleer.saas.provisioning.ProvisionResult;
@@ -61,18 +62,21 @@ class VendorTenantServiceTest {
@Mock @Mock
private AuditService auditService; private AuditService auditService;
@Mock
private TenantDataCleanupService dataCleanupService;
private VendorTenantService vendorTenantService; private VendorTenantService vendorTenantService;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
var provisioningProps = new ProvisioningProperties( var provisioningProps = new ProvisioningProperties(
"img", "uiimg", "net", "traefik", "localhost", "https", "img", "uiimg", "net", "traefik", "localhost", "https",
"jdbc:postgresql://pg:5432/db", "https://localhost/oidc", "jdbc:postgresql://pg:5432/db", "jdbc:clickhouse://ch:8123/cameleer",
"http://logto:3001/oidc/jwks", "https://localhost"); "https://localhost/oidc", "http://logto:3001/oidc/jwks", "https://localhost");
vendorTenantService = new VendorTenantService( vendorTenantService = new VendorTenantService(
tenantService, tenantRepository, licenseService, tenantService, tenantRepository, licenseService,
tenantProvisioner, serverApiClient, logtoClient, logtoConfig, tenantProvisioner, serverApiClient, logtoClient, logtoConfig,
auditService, provisioningProps); auditService, provisioningProps, dataCleanupService);
} }
// --- Helpers --- // --- Helpers ---