Rename Java packages from net.siegeln.cameleer3 to net.siegeln.cameleer, update all references in workflows, Docker configs, docs, and bootstrap. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6.4 KiB
Per-Tenant PostgreSQL Isolation
Date: 2026-04-15 Status: Approved
Context
The cameleer-server team introduced currentSchema and ApplicationName JDBC parameters (commit 7a63135) to scope admin diagnostic queries to a single tenant's connections. Previously, all tenant servers shared one PostgreSQL user and connected to the cameleer database without schema isolation — a tenant's server could theoretically see SQL text from other tenants via pg_stat_activity.
This spec adds per-tenant PostgreSQL users and schemas so each tenant server can only access its own data at the database level.
Architecture
Current State
- All tenant servers connect as the shared admin PG user to
cameleerdatabase,publicschema. - No per-tenant schemas exist — the server's Flyway runs in
public. TenantDataCleanupServicealready attemptsDROP SCHEMA tenant_<slug>on delete (no-op today since schemas don't exist).- Standalone mode sets
currentSchema=tenant_defaultin the compose file and is unaffected by this change.
Target State
- Each tenant gets a dedicated PG user (
tenant_<slug>) and schema (tenant_<slug>). - The tenant user owns only its schema.
REVOKE ALL ON SCHEMA publicprevents cross-tenant access. - The server's Flyway runs inside
tenant_<slug>via thecurrentSchemaJDBC parameter. ApplicationName=tenant_<slug>scopespg_stat_activityvisibility per the server team's convention.- On tenant delete, both schema and user are dropped.
New Component: TenantDatabaseService
A focused service with two methods:
@Service
public class TenantDatabaseService {
void createTenantDatabase(String slug, String password);
void dropTenantDatabase(String slug);
}
createTenantDatabase(slug, password)
Connects to cameleer using the admin PG credentials from ProvisioningProperties. Executes:
- Validate slug against
^[a-z0-9-]+$(reject unexpected characters). CREATE USER "tenant_<slug>" WITH PASSWORD '<password>'(skip if user already exists — idempotent for re-provisioning).CREATE SCHEMA "tenant_<slug>" AUTHORIZATION "tenant_<slug>"(skip if schema already exists).REVOKE ALL ON SCHEMA public FROM "tenant_<slug>".
All identifiers are double-quoted. The password is a 32-character random alphanumeric string generated by the same SecureRandom utility used for other credential generation.
dropTenantDatabase(slug)
DROP SCHEMA IF EXISTS "tenant_<slug>" CASCADEDROP USER IF EXISTS "tenant_<slug>"
Schema must be dropped first (with CASCADE) because PG won't drop a user that owns objects.
Entity Change
New Flyway migration: V014__add_tenant_db_password.sql
ALTER TABLE tenants ADD COLUMN db_password VARCHAR(255);
Nullable — existing tenants won't have it. Code checks for null and falls back to shared credentials for backwards compatibility.
TenantEntity: new dbPassword field with JPA @Column mapping.
Provisioning Flow Changes
VendorTenantService.provisionAsync() — new steps before container creation
1. Generate 32-char random password
2. tenantDatabaseService.createTenantDatabase(slug, password)
3. entity.setDbPassword(password)
4. tenantRepository.save(entity)
5. tenantProvisioner.provision(request) ← request now includes dbPassword
6. ... rest unchanged (health check, license push, OIDC push)
If step 2 fails, provisioning aborts with a stored error — no orphaned containers.
DockerTenantProvisioner — JDBC URL construction
The ProvisionRequest record gains dbPassword field.
When dbPassword is present (new tenants):
SPRING_DATASOURCE_URL=jdbc:postgresql://cameleer-postgres:5432/cameleer?currentSchema=tenant_<slug>&ApplicationName=tenant_<slug>
SPRING_DATASOURCE_USERNAME=tenant_<slug>
SPRING_DATASOURCE_PASSWORD=<generated>
When dbPassword is null (pre-existing tenants, backwards compat):
SPRING_DATASOURCE_URL=<props.datasourceUrl()> (no currentSchema/ApplicationName)
SPRING_DATASOURCE_USERNAME=<props.datasourceUsername()>
SPRING_DATASOURCE_PASSWORD=<props.datasourcePassword()>
Server restart/upgrade re-creates containers via provisionAsync(), which re-reads dbPassword from the entity. Restarting an upgraded tenant picks up isolated credentials automatically.
Delete Flow Changes
VendorTenantService.delete()
1. tenantProvisioner.remove(slug) ← existing
2. licenseService.revokeLicense(...) ← existing
3. logtoClient.deleteOrganization(...) ← existing
4. tenantDatabaseService.dropTenantDatabase(slug) ← replaces TenantDataCleanupService PG logic
5. dataCleanupService.cleanupClickHouse(slug) ← ClickHouse cleanup stays separate
6. entity.setStatus(DELETED) ← existing
TenantDataCleanupService loses its PostgreSQL cleanup responsibility (delegated to TenantDatabaseService). It keeps only the ClickHouse cleanup. Rename method to cleanupClickHouse(slug) for clarity.
Backwards Compatibility
| Scenario | Behavior |
|---|---|
| Standalone mode | Unaffected. Server is in compose, not provisioned by SaaS. Defaults to tenant_default. |
| Existing SaaS tenants (dbPassword=null) | Shared credentials, no currentSchema. Same as before. |
| Existing tenants after restart/upgrade | Still use shared credentials until re-provisioned with new code. |
| New tenants | Isolated user+schema+JDBC URL. Full isolation. |
| Delete of pre-existing tenant | DROP USER IF EXISTS is a no-op (user doesn't exist). Schema drop unchanged. |
InfrastructureService
No changes needed. Already queries information_schema.schemata WHERE schema_name LIKE 'tenant_%'. With per-tenant schemas now created, the PostgreSQL tenant table on the Infrastructure page will populate automatically.
Files Changed
| File | Change |
|---|---|
TenantDatabaseService.java |
New — create/drop PG user+schema |
TenantEntity.java |
Add dbPassword field |
V014__add_tenant_db_password.sql |
New — nullable column |
VendorTenantService.java |
Call createTenantDatabase in provision, dropTenantDatabase in delete |
DockerTenantProvisioner.java |
Construct per-tenant JDBC URL, username, password |
ProvisionRequest record |
Add dbPassword field |
TenantDataCleanupService.java |
Remove PG logic, keep ClickHouse only, rename method |