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>
|
<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>
|
||||||
|
|||||||
@@ -95,9 +95,49 @@ public class DockerTenantProvisioner implements TenantProvisioner {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void remove(String slug) {
|
public void remove(String slug) {
|
||||||
removeContainer(uiContainerName(slug));
|
// 1. Remove ALL containers labeled for this tenant (app containers + server + UI)
|
||||||
removeContainer(serverContainerName(slug));
|
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));
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.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);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|||||||
Reference in New Issue
Block a user