feat: full tenant cleanup on delete — Docker resources, PG schema, CH data (#55)
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:
8
pom.xml
8
pom.xml
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,6 +11,7 @@ public record ProvisioningProperties(
|
||||
String publicHost,
|
||||
String publicProtocol,
|
||||
String datasourceUrl,
|
||||
String clickhouseUrl,
|
||||
String oidcIssuerUri,
|
||||
String oidcJwkSetUri,
|
||||
String corsOrigins
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
Reference in New Issue
Block a user