Compare commits
10 Commits
91e93696ed
...
24a443ef30
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24a443ef30 | ||
|
|
d7eb700860 | ||
|
|
c1458e4995 | ||
|
|
b79a7fe405 | ||
|
|
6d6c1f3562 | ||
|
|
0e3f383cf4 | ||
|
|
cd6dd1e5af | ||
|
|
dfa2a6bfa2 | ||
|
|
a7196ff4c1 | ||
|
|
17c6723f7e |
@@ -0,0 +1,464 @@
|
||||
# Per-Tenant PostgreSQL Isolation Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Give each tenant its own PostgreSQL user and schema so tenant servers can only access their own data at the database level.
|
||||
|
||||
**Architecture:** During provisioning, create a dedicated PG user (`tenant_<slug>`) with a matching schema. Pass per-tenant credentials and `currentSchema`/`ApplicationName` JDBC parameters to the server container. On delete, drop both schema and user. Existing tenants without `dbPassword` fall back to shared credentials for backwards compatibility.
|
||||
|
||||
**Tech Stack:** Java 21, Spring Boot 3.4, Flyway, PostgreSQL 16, Docker Java API
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-15-per-tenant-pg-isolation-design.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Flyway Migration — add `db_password` column
|
||||
|
||||
**Files:**
|
||||
- Create: `src/main/resources/db/migration/V015__add_tenant_db_password.sql`
|
||||
|
||||
- [ ] **Step 1: Create migration file**
|
||||
|
||||
```sql
|
||||
ALTER TABLE tenants ADD COLUMN db_password VARCHAR(255);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify migration applies**
|
||||
|
||||
Run: `mvn flyway:info -pl .` or start the app and check logs for `V015__add_tenant_db_password` in Flyway output.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/resources/db/migration/V015__add_tenant_db_password.sql
|
||||
git commit -m "feat: add db_password column to tenants table (V015)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: TenantEntity — add `dbPassword` field
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/tenant/TenantEntity.java`
|
||||
|
||||
- [ ] **Step 1: Add field and accessors**
|
||||
|
||||
After the `provisionError` field (line 59), add:
|
||||
|
||||
```java
|
||||
@Column(name = "db_password")
|
||||
private String dbPassword;
|
||||
```
|
||||
|
||||
After the `setProvisionError` method (line 102), add:
|
||||
|
||||
```java
|
||||
public String getDbPassword() { return dbPassword; }
|
||||
public void setDbPassword(String dbPassword) { this.dbPassword = dbPassword; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/java/net/siegeln/cameleer/saas/tenant/TenantEntity.java
|
||||
git commit -m "feat: add dbPassword field to TenantEntity"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Create `TenantDatabaseService`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/main/java/net/siegeln/cameleer/saas/provisioning/TenantDatabaseService.java`
|
||||
|
||||
- [ ] **Step 1: Implement the service**
|
||||
|
||||
```java
|
||||
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;
|
||||
|
||||
/**
|
||||
* Creates and drops per-tenant PostgreSQL users and schemas
|
||||
* on the shared cameleer3 database for DB-level tenant isolation.
|
||||
*/
|
||||
@Service
|
||||
public class TenantDatabaseService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TenantDatabaseService.class);
|
||||
|
||||
private final ProvisioningProperties props;
|
||||
|
||||
public TenantDatabaseService(ProvisioningProperties props) {
|
||||
this.props = props;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a dedicated PG user and schema for a tenant.
|
||||
* Idempotent — skips if user/schema already exist.
|
||||
*/
|
||||
public void createTenantDatabase(String slug, String password) {
|
||||
validateSlug(slug);
|
||||
|
||||
String url = props.datasourceUrl();
|
||||
if (url == null || url.isBlank()) {
|
||||
log.warn("No datasource URL configured — skipping tenant DB setup");
|
||||
return;
|
||||
}
|
||||
|
||||
String user = "tenant_" + slug;
|
||||
String schema = "tenant_" + slug;
|
||||
|
||||
try (Connection conn = DriverManager.getConnection(url, props.datasourceUsername(), props.datasourcePassword());
|
||||
Statement stmt = conn.createStatement()) {
|
||||
|
||||
// Create user if not exists
|
||||
boolean userExists;
|
||||
try (ResultSet rs = stmt.executeQuery(
|
||||
"SELECT 1 FROM pg_roles WHERE rolname = '" + user + "'")) {
|
||||
userExists = rs.next();
|
||||
}
|
||||
if (!userExists) {
|
||||
stmt.execute("CREATE USER \"" + user + "\" WITH PASSWORD '" + escapePassword(password) + "'");
|
||||
log.info("Created PostgreSQL user: {}", user);
|
||||
} else {
|
||||
// Update password on re-provision
|
||||
stmt.execute("ALTER USER \"" + user + "\" WITH PASSWORD '" + escapePassword(password) + "'");
|
||||
log.info("Updated password for existing PostgreSQL user: {}", user);
|
||||
}
|
||||
|
||||
// Create schema if not exists
|
||||
boolean schemaExists;
|
||||
try (ResultSet rs = stmt.executeQuery(
|
||||
"SELECT 1 FROM information_schema.schemata WHERE schema_name = '" + schema + "'")) {
|
||||
schemaExists = rs.next();
|
||||
}
|
||||
if (!schemaExists) {
|
||||
stmt.execute("CREATE SCHEMA \"" + schema + "\" AUTHORIZATION \"" + user + "\"");
|
||||
log.info("Created PostgreSQL schema: {}", schema);
|
||||
} else {
|
||||
// Ensure ownership is correct
|
||||
stmt.execute("ALTER SCHEMA \"" + schema + "\" OWNER TO \"" + user + "\"");
|
||||
log.info("Schema {} already exists — ensured ownership", schema);
|
||||
}
|
||||
|
||||
// Revoke access to public schema
|
||||
stmt.execute("REVOKE ALL ON SCHEMA public FROM \"" + user + "\"");
|
||||
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to create tenant database for '" + slug + "': " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop tenant schema (CASCADE) and user. Idempotent.
|
||||
*/
|
||||
public void dropTenantDatabase(String slug) {
|
||||
validateSlug(slug);
|
||||
|
||||
String url = props.datasourceUrl();
|
||||
if (url == null || url.isBlank()) {
|
||||
log.warn("No datasource URL configured — skipping tenant DB cleanup");
|
||||
return;
|
||||
}
|
||||
|
||||
String user = "tenant_" + slug;
|
||||
String schema = "tenant_" + slug;
|
||||
|
||||
try (Connection conn = DriverManager.getConnection(url, props.datasourceUsername(), props.datasourcePassword());
|
||||
Statement stmt = conn.createStatement()) {
|
||||
stmt.execute("DROP SCHEMA IF EXISTS \"" + schema + "\" CASCADE");
|
||||
log.info("Dropped PostgreSQL schema: {}", schema);
|
||||
|
||||
stmt.execute("DROP USER IF EXISTS \"" + user + "\"");
|
||||
log.info("Dropped PostgreSQL user: {}", user);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to drop tenant database for '{}': {}", slug, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void validateSlug(String slug) {
|
||||
if (slug == null || !slug.matches("^[a-z0-9-]+$")) {
|
||||
throw new IllegalArgumentException("Invalid tenant slug: " + slug);
|
||||
}
|
||||
}
|
||||
|
||||
private String escapePassword(String password) {
|
||||
return password.replace("'", "''");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/java/net/siegeln/cameleer/saas/provisioning/TenantDatabaseService.java
|
||||
git commit -m "feat: add TenantDatabaseService for per-tenant PG user+schema"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add `dbPassword` to `TenantProvisionRequest`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/provisioning/TenantProvisionRequest.java`
|
||||
|
||||
- [ ] **Step 1: Add field to record**
|
||||
|
||||
Replace the entire record with:
|
||||
|
||||
```java
|
||||
package net.siegeln.cameleer.saas.provisioning;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record TenantProvisionRequest(
|
||||
UUID tenantId,
|
||||
String slug,
|
||||
String tier,
|
||||
String licenseToken,
|
||||
String dbPassword
|
||||
) {}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/java/net/siegeln/cameleer/saas/provisioning/TenantProvisionRequest.java
|
||||
git commit -m "feat: add dbPassword to TenantProvisionRequest"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Update `DockerTenantProvisioner` — per-tenant JDBC URL
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java:197-200`
|
||||
|
||||
- [ ] **Step 1: Replace shared credentials with per-tenant credentials**
|
||||
|
||||
In `createServerContainer()` (line 197-200), replace:
|
||||
|
||||
```java
|
||||
var env = new java.util.ArrayList<>(List.of(
|
||||
"SPRING_DATASOURCE_URL=" + props.datasourceUrl(),
|
||||
"SPRING_DATASOURCE_USERNAME=" + props.datasourceUsername(),
|
||||
"SPRING_DATASOURCE_PASSWORD=" + props.datasourcePassword(),
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```java
|
||||
// Per-tenant DB isolation: dedicated user+schema when dbPassword is set,
|
||||
// shared credentials for backwards compatibility with pre-isolation tenants.
|
||||
String dsUrl;
|
||||
String dsUser;
|
||||
String dsPass;
|
||||
if (req.dbPassword() != null) {
|
||||
dsUrl = props.datasourceUrl() + "?currentSchema=tenant_" + slug + "&ApplicationName=tenant_" + slug;
|
||||
dsUser = "tenant_" + slug;
|
||||
dsPass = req.dbPassword();
|
||||
} else {
|
||||
dsUrl = props.datasourceUrl();
|
||||
dsUser = props.datasourceUsername();
|
||||
dsPass = props.datasourcePassword();
|
||||
}
|
||||
var env = new java.util.ArrayList<>(List.of(
|
||||
"SPRING_DATASOURCE_URL=" + dsUrl,
|
||||
"SPRING_DATASOURCE_USERNAME=" + dsUser,
|
||||
"SPRING_DATASOURCE_PASSWORD=" + dsPass,
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java
|
||||
git commit -m "feat: construct per-tenant JDBC URL with currentSchema and ApplicationName"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Update `VendorTenantService` — provisioning and delete flows
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java`
|
||||
|
||||
- [ ] **Step 1: Inject `TenantDatabaseService`**
|
||||
|
||||
Add to the constructor and field declarations:
|
||||
|
||||
```java
|
||||
private final TenantDatabaseService tenantDatabaseService;
|
||||
```
|
||||
|
||||
Add to the constructor parameter list and assignment. (Follow the existing pattern of other injected services.)
|
||||
|
||||
- [ ] **Step 2: Update `provisionAsync()` — create DB before containers**
|
||||
|
||||
In `provisionAsync()` (around line 120), add DB creation before the provision call. Replace:
|
||||
|
||||
```java
|
||||
var provisionRequest = new TenantProvisionRequest(tenantId, slug, tier, licenseToken);
|
||||
ProvisionResult result = tenantProvisioner.provision(provisionRequest);
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```java
|
||||
// Create per-tenant PG user + schema
|
||||
String dbPassword = UUID.randomUUID().toString().replace("-", "")
|
||||
+ UUID.randomUUID().toString().replace("-", "").substring(0, 8);
|
||||
try {
|
||||
tenantDatabaseService.createTenantDatabase(slug, dbPassword);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to create tenant database for {}: {}", slug, e.getMessage(), e);
|
||||
tenantRepository.findById(tenantId).ifPresent(t -> {
|
||||
t.setProvisionError("Database setup failed: " + e.getMessage());
|
||||
tenantRepository.save(t);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Store DB password on entity
|
||||
TenantEntity tenantForDb = tenantRepository.findById(tenantId).orElse(null);
|
||||
if (tenantForDb == null) {
|
||||
log.error("Tenant {} disappeared during provisioning", slug);
|
||||
return;
|
||||
}
|
||||
tenantForDb.setDbPassword(dbPassword);
|
||||
tenantRepository.save(tenantForDb);
|
||||
|
||||
var provisionRequest = new TenantProvisionRequest(tenantId, slug, tier, licenseToken, dbPassword);
|
||||
ProvisionResult result = tenantProvisioner.provision(provisionRequest);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update the existing `TenantProvisionRequest` constructor call in upgrade flow**
|
||||
|
||||
Search for any other `new TenantProvisionRequest(...)` calls. The `upgradeServer` method (or re-provision after upgrade) also creates a provision request. Update it to pass `dbPassword` from the entity:
|
||||
|
||||
```java
|
||||
TenantEntity tenant = ...;
|
||||
var provisionRequest = new TenantProvisionRequest(
|
||||
tenant.getId(), tenant.getSlug(), tenant.getTier().name(),
|
||||
licenseToken, tenant.getDbPassword());
|
||||
```
|
||||
|
||||
If the tenant has `dbPassword == null` (pre-existing), this is fine — Task 5 handles the null fallback.
|
||||
|
||||
- [ ] **Step 4: Update `delete()` — use TenantDatabaseService**
|
||||
|
||||
In `delete()` (around line 306), replace:
|
||||
|
||||
```java
|
||||
// Erase tenant data from server databases (GDPR)
|
||||
dataCleanupService.cleanup(tenant.getSlug());
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```java
|
||||
// Drop per-tenant PG schema + user
|
||||
tenantDatabaseService.dropTenantDatabase(tenant.getSlug());
|
||||
|
||||
// Erase ClickHouse data (GDPR)
|
||||
dataCleanupService.cleanupClickHouse(tenant.getSlug());
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java
|
||||
git commit -m "feat: create per-tenant PG database during provisioning, drop on delete"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Refactor `TenantDataCleanupService` — ClickHouse only
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/java/net/siegeln/cameleer/saas/provisioning/TenantDataCleanupService.java`
|
||||
|
||||
- [ ] **Step 1: Remove PG logic, rename public method**
|
||||
|
||||
Remove the `dropPostgresSchema()` method and the `cleanup()` method. Replace with a single public method:
|
||||
|
||||
```java
|
||||
/**
|
||||
* Deletes tenant data from ClickHouse tables (GDPR data erasure).
|
||||
* PostgreSQL cleanup is handled by TenantDatabaseService.
|
||||
*/
|
||||
public void cleanupClickHouse(String slug) {
|
||||
deleteClickHouseData(slug);
|
||||
}
|
||||
```
|
||||
|
||||
Remove the `dropPostgresSchema()` private method entirely. Keep `deleteClickHouseData()` unchanged.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/java/net/siegeln/cameleer/saas/provisioning/TenantDataCleanupService.java
|
||||
git commit -m "refactor: move PG cleanup to TenantDatabaseService, keep only ClickHouse"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Verify end-to-end
|
||||
|
||||
- [ ] **Step 1: Build**
|
||||
|
||||
```bash
|
||||
mvn compile -pl .
|
||||
```
|
||||
|
||||
Verify no compilation errors.
|
||||
|
||||
- [ ] **Step 2: Deploy and test tenant creation**
|
||||
|
||||
Deploy the updated SaaS image. Create a new tenant via the UI. Verify in PostgreSQL:
|
||||
|
||||
```sql
|
||||
-- Should show the new tenant user
|
||||
SELECT rolname FROM pg_roles WHERE rolname LIKE 'tenant_%';
|
||||
|
||||
-- Should show the new tenant schema
|
||||
SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant_%';
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify server container env vars**
|
||||
|
||||
```bash
|
||||
docker inspect cameleer-server-<slug> | grep -E "DATASOURCE|currentSchema|ApplicationName"
|
||||
```
|
||||
|
||||
Expected: URL contains `?currentSchema=tenant_<slug>&ApplicationName=tenant_<slug>`, username is `tenant_<slug>`.
|
||||
|
||||
- [ ] **Step 4: Verify Infrastructure page**
|
||||
|
||||
Navigate to Vendor > Infrastructure. The PostgreSQL card should now show the tenant schema with size/tables/rows.
|
||||
|
||||
- [ ] **Step 5: Test tenant deletion**
|
||||
|
||||
Delete the tenant. Verify:
|
||||
|
||||
```sql
|
||||
-- User should be gone
|
||||
SELECT rolname FROM pg_roles WHERE rolname LIKE 'tenant_%';
|
||||
|
||||
-- Schema should be gone
|
||||
SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant_%';
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit all remaining changes**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat: per-tenant PostgreSQL isolation — complete implementation"
|
||||
```
|
||||
@@ -0,0 +1,147 @@
|
||||
# Per-Tenant PostgreSQL Isolation
|
||||
|
||||
**Date:** 2026-04-15
|
||||
**Status:** Approved
|
||||
|
||||
## Context
|
||||
|
||||
The cameleer3-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 `cameleer3` 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 `cameleer3` database, `public` schema.
|
||||
- No per-tenant schemas exist — the server's Flyway runs in `public`.
|
||||
- `TenantDataCleanupService` already attempts `DROP SCHEMA tenant_<slug>` on delete (no-op today since schemas don't exist).
|
||||
- Standalone mode sets `currentSchema=tenant_default` in 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 public` prevents cross-tenant access.
|
||||
- The server's Flyway runs inside `tenant_<slug>` via the `currentSchema` JDBC parameter.
|
||||
- `ApplicationName=tenant_<slug>` scopes `pg_stat_activity` visibility per the server team's convention.
|
||||
- On tenant delete, both schema and user are dropped.
|
||||
|
||||
## New Component: `TenantDatabaseService`
|
||||
|
||||
A focused service with two methods:
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class TenantDatabaseService {
|
||||
void createTenantDatabase(String slug, String password);
|
||||
void dropTenantDatabase(String slug);
|
||||
}
|
||||
```
|
||||
|
||||
### `createTenantDatabase(slug, password)`
|
||||
|
||||
Connects to `cameleer3` using the admin PG credentials from `ProvisioningProperties`. Executes:
|
||||
|
||||
1. Validate slug against `^[a-z0-9-]+$` (reject unexpected characters).
|
||||
2. `CREATE USER "tenant_<slug>" WITH PASSWORD '<password>'` (skip if user already exists — idempotent for re-provisioning).
|
||||
3. `CREATE SCHEMA "tenant_<slug>" AUTHORIZATION "tenant_<slug>"` (skip if schema already exists).
|
||||
4. `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)`
|
||||
|
||||
1. `DROP SCHEMA IF EXISTS "tenant_<slug>" CASCADE`
|
||||
2. `DROP 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`
|
||||
|
||||
```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/cameleer3?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 |
|
||||
@@ -194,10 +194,24 @@ public class DockerTenantProvisioner implements TenantProvisioner {
|
||||
labels.put("prometheus.path", "/api/v1/prometheus");
|
||||
labels.put("prometheus.port", "8081");
|
||||
|
||||
// Per-tenant DB isolation: dedicated user+schema when dbPassword is set,
|
||||
// shared credentials for backwards compatibility with pre-isolation tenants.
|
||||
String dsUrl;
|
||||
String dsUser;
|
||||
String dsPass;
|
||||
if (req.dbPassword() != null) {
|
||||
dsUrl = props.datasourceUrl() + "?currentSchema=tenant_" + slug + "&ApplicationName=tenant_" + slug;
|
||||
dsUser = "tenant_" + slug;
|
||||
dsPass = req.dbPassword();
|
||||
} else {
|
||||
dsUrl = props.datasourceUrl();
|
||||
dsUser = props.datasourceUsername();
|
||||
dsPass = props.datasourcePassword();
|
||||
}
|
||||
var env = new java.util.ArrayList<>(List.of(
|
||||
"SPRING_DATASOURCE_URL=" + props.datasourceUrl(),
|
||||
"SPRING_DATASOURCE_USERNAME=" + props.datasourceUsername(),
|
||||
"SPRING_DATASOURCE_PASSWORD=" + props.datasourcePassword(),
|
||||
"SPRING_DATASOURCE_URL=" + dsUrl,
|
||||
"SPRING_DATASOURCE_USERNAME=" + dsUser,
|
||||
"SPRING_DATASOURCE_PASSWORD=" + dsPass,
|
||||
"CAMELEER_SERVER_CLICKHOUSE_URL=jdbc:clickhouse://cameleer-clickhouse:8123/cameleer",
|
||||
"CAMELEER_SERVER_CLICKHOUSE_USERNAME=" + props.clickhouseUser(),
|
||||
"CAMELEER_SERVER_CLICKHOUSE_PASSWORD=" + props.clickhousePassword(),
|
||||
|
||||
@@ -12,8 +12,8 @@ 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).
|
||||
* Deletes tenant data from ClickHouse tables when a tenant is deleted
|
||||
* (GDPR data erasure). PostgreSQL cleanup is handled by TenantDatabaseService.
|
||||
*/
|
||||
@Service
|
||||
public class TenantDataCleanupService {
|
||||
@@ -26,33 +26,14 @@ public class TenantDataCleanupService {
|
||||
this.props = props;
|
||||
}
|
||||
|
||||
public void cleanup(String slug) {
|
||||
dropPostgresSchema(slug);
|
||||
/**
|
||||
* Deletes tenant data from ClickHouse tables (GDPR data erasure).
|
||||
* PostgreSQL cleanup is handled by TenantDatabaseService.
|
||||
*/
|
||||
public void cleanupClickHouse(String 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, props.datasourceUsername(), props.datasourcePassword());
|
||||
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()) {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
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;
|
||||
|
||||
@Service
|
||||
public class TenantDatabaseService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TenantDatabaseService.class);
|
||||
|
||||
private final ProvisioningProperties props;
|
||||
|
||||
public TenantDatabaseService(ProvisioningProperties props) {
|
||||
this.props = props;
|
||||
}
|
||||
|
||||
public void createTenantDatabase(String slug, String password) {
|
||||
validateSlug(slug);
|
||||
|
||||
String url = props.datasourceUrl();
|
||||
if (url == null || url.isBlank()) {
|
||||
log.warn("No datasource URL configured — skipping tenant DB setup");
|
||||
return;
|
||||
}
|
||||
|
||||
String user = "tenant_" + slug;
|
||||
String schema = "tenant_" + slug;
|
||||
|
||||
try (Connection conn = DriverManager.getConnection(url, props.datasourceUsername(), props.datasourcePassword());
|
||||
Statement stmt = conn.createStatement()) {
|
||||
|
||||
boolean userExists;
|
||||
try (ResultSet rs = stmt.executeQuery(
|
||||
"SELECT 1 FROM pg_roles WHERE rolname = '" + user + "'")) {
|
||||
userExists = rs.next();
|
||||
}
|
||||
if (!userExists) {
|
||||
stmt.execute("CREATE USER \"" + user + "\" WITH PASSWORD '" + escapePassword(password) + "'");
|
||||
log.info("Created PostgreSQL user: {}", user);
|
||||
} else {
|
||||
stmt.execute("ALTER USER \"" + user + "\" WITH PASSWORD '" + escapePassword(password) + "'");
|
||||
log.info("Updated password for existing PostgreSQL user: {}", user);
|
||||
}
|
||||
|
||||
boolean schemaExists;
|
||||
try (ResultSet rs = stmt.executeQuery(
|
||||
"SELECT 1 FROM information_schema.schemata WHERE schema_name = '" + schema + "'")) {
|
||||
schemaExists = rs.next();
|
||||
}
|
||||
if (!schemaExists) {
|
||||
stmt.execute("CREATE SCHEMA \"" + schema + "\" AUTHORIZATION \"" + user + "\"");
|
||||
log.info("Created PostgreSQL schema: {}", schema);
|
||||
} else {
|
||||
stmt.execute("ALTER SCHEMA \"" + schema + "\" OWNER TO \"" + user + "\"");
|
||||
log.info("Schema {} already exists — ensured ownership", schema);
|
||||
}
|
||||
|
||||
stmt.execute("REVOKE ALL ON SCHEMA public FROM \"" + user + "\"");
|
||||
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to create tenant database for '" + slug + "': " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public void dropTenantDatabase(String slug) {
|
||||
validateSlug(slug);
|
||||
|
||||
String url = props.datasourceUrl();
|
||||
if (url == null || url.isBlank()) {
|
||||
log.warn("No datasource URL configured — skipping tenant DB cleanup");
|
||||
return;
|
||||
}
|
||||
|
||||
String user = "tenant_" + slug;
|
||||
String schema = "tenant_" + slug;
|
||||
|
||||
try (Connection conn = DriverManager.getConnection(url, props.datasourceUsername(), props.datasourcePassword());
|
||||
Statement stmt = conn.createStatement()) {
|
||||
stmt.execute("DROP SCHEMA IF EXISTS \"" + schema + "\" CASCADE");
|
||||
log.info("Dropped PostgreSQL schema: {}", schema);
|
||||
|
||||
stmt.execute("DROP USER IF EXISTS \"" + user + "\"");
|
||||
log.info("Dropped PostgreSQL user: {}", user);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to drop tenant database for '{}': {}", slug, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void validateSlug(String slug) {
|
||||
if (slug == null || !slug.matches("^[a-z0-9-]+$")) {
|
||||
throw new IllegalArgumentException("Invalid tenant slug: " + slug);
|
||||
}
|
||||
}
|
||||
|
||||
private String escapePassword(String password) {
|
||||
return password.replace("'", "''");
|
||||
}
|
||||
}
|
||||
@@ -6,5 +6,6 @@ public record TenantProvisionRequest(
|
||||
UUID tenantId,
|
||||
String slug,
|
||||
String tier,
|
||||
String licenseToken
|
||||
String licenseToken,
|
||||
String dbPassword
|
||||
) {}
|
||||
|
||||
@@ -58,6 +58,9 @@ public class TenantEntity {
|
||||
@Column(name = "provision_error", columnDefinition = "TEXT")
|
||||
private String provisionError;
|
||||
|
||||
@Column(name = "db_password")
|
||||
private String dbPassword;
|
||||
|
||||
@Column(name = "ca_applied_at")
|
||||
private Instant caAppliedAt;
|
||||
|
||||
@@ -100,6 +103,8 @@ public class TenantEntity {
|
||||
public void setServerEndpoint(String serverEndpoint) { this.serverEndpoint = serverEndpoint; }
|
||||
public String getProvisionError() { return provisionError; }
|
||||
public void setProvisionError(String provisionError) { this.provisionError = provisionError; }
|
||||
public String getDbPassword() { return dbPassword; }
|
||||
public void setDbPassword(String dbPassword) { this.dbPassword = dbPassword; }
|
||||
public Instant getCaAppliedAt() { return caAppliedAt; }
|
||||
public void setCaAppliedAt(Instant caAppliedAt) { this.caAppliedAt = caAppliedAt; }
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
|
||||
@@ -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.TenantDatabaseService;
|
||||
import net.siegeln.cameleer.saas.provisioning.TenantDataCleanupService;
|
||||
import net.siegeln.cameleer.saas.identity.ServerApiClient.ServerHealthResponse;
|
||||
import net.siegeln.cameleer.saas.license.LicenseEntity;
|
||||
@@ -47,6 +48,7 @@ public class VendorTenantService {
|
||||
private final AuditService auditService;
|
||||
private final ProvisioningProperties provisioningProps;
|
||||
private final TenantDataCleanupService dataCleanupService;
|
||||
private final TenantDatabaseService tenantDatabaseService;
|
||||
|
||||
public VendorTenantService(TenantService tenantService,
|
||||
TenantRepository tenantRepository,
|
||||
@@ -57,7 +59,8 @@ public class VendorTenantService {
|
||||
LogtoConfig logtoConfig,
|
||||
AuditService auditService,
|
||||
ProvisioningProperties provisioningProps,
|
||||
TenantDataCleanupService dataCleanupService) {
|
||||
TenantDataCleanupService dataCleanupService,
|
||||
TenantDatabaseService tenantDatabaseService) {
|
||||
this.tenantService = tenantService;
|
||||
this.tenantRepository = tenantRepository;
|
||||
this.licenseService = licenseService;
|
||||
@@ -68,6 +71,7 @@ public class VendorTenantService {
|
||||
this.auditService = auditService;
|
||||
this.provisioningProps = provisioningProps;
|
||||
this.dataCleanupService = dataCleanupService;
|
||||
this.tenantDatabaseService = tenantDatabaseService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -119,7 +123,30 @@ public class VendorTenantService {
|
||||
@Async
|
||||
public void provisionAsync(UUID tenantId, String slug, String tier, String licenseToken, UUID actorId) {
|
||||
try {
|
||||
var provisionRequest = new TenantProvisionRequest(tenantId, slug, tier, licenseToken);
|
||||
// Create per-tenant PG user + schema
|
||||
String dbPassword = java.util.UUID.randomUUID().toString().replace("-", "")
|
||||
+ java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 8);
|
||||
try {
|
||||
tenantDatabaseService.createTenantDatabase(slug, dbPassword);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to create tenant database for {}: {}", slug, e.getMessage(), e);
|
||||
tenantRepository.findById(tenantId).ifPresent(t -> {
|
||||
t.setProvisionError("Database setup failed: " + e.getMessage());
|
||||
tenantRepository.save(t);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Store DB password on entity
|
||||
TenantEntity tenantForDb = tenantRepository.findById(tenantId).orElse(null);
|
||||
if (tenantForDb == null) {
|
||||
log.error("Tenant {} disappeared during provisioning", slug);
|
||||
return;
|
||||
}
|
||||
tenantForDb.setDbPassword(dbPassword);
|
||||
tenantRepository.save(tenantForDb);
|
||||
|
||||
var provisionRequest = new TenantProvisionRequest(tenantId, slug, tier, licenseToken, dbPassword);
|
||||
ProvisionResult result = tenantProvisioner.provision(provisionRequest);
|
||||
|
||||
TenantEntity tenant = tenantRepository.findById(tenantId).orElse(null);
|
||||
@@ -302,8 +329,15 @@ public class VendorTenantService {
|
||||
}
|
||||
}
|
||||
|
||||
// Erase tenant data from server databases (GDPR)
|
||||
dataCleanupService.cleanup(tenant.getSlug());
|
||||
// Drop per-tenant PG user + database
|
||||
try {
|
||||
tenantDatabaseService.dropTenantDatabase(tenant.getSlug());
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to drop tenant database for {}: {}", tenant.getSlug(), e.getMessage());
|
||||
}
|
||||
|
||||
// Erase tenant data from ClickHouse (GDPR)
|
||||
dataCleanupService.cleanupClickHouse(tenant.getSlug());
|
||||
|
||||
// Soft-delete
|
||||
tenant.setStatus(TenantStatus.DELETED);
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
CREATE TABLE tenants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
slug VARCHAR(100) NOT NULL UNIQUE,
|
||||
tier VARCHAR(20) NOT NULL DEFAULT 'LOW',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PROVISIONING',
|
||||
logto_org_id VARCHAR(255),
|
||||
stripe_customer_id VARCHAR(255),
|
||||
stripe_subscription_id VARCHAR(255),
|
||||
settings JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tenants_slug ON tenants (slug);
|
||||
CREATE INDEX idx_tenants_status ON tenants (status);
|
||||
CREATE INDEX idx_tenants_logto_org_id ON tenants (logto_org_id);
|
||||
95
src/main/resources/db/migration/V001__init.sql
Normal file
95
src/main/resources/db/migration/V001__init.sql
Normal file
@@ -0,0 +1,95 @@
|
||||
-- Cameleer SaaS schema baseline
|
||||
-- Consolidated from V001-V015
|
||||
|
||||
-- Tenants
|
||||
CREATE TABLE tenants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
slug VARCHAR(100) NOT NULL,
|
||||
tier VARCHAR(20) NOT NULL DEFAULT 'LOW',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PROVISIONING',
|
||||
logto_org_id VARCHAR(255),
|
||||
stripe_customer_id VARCHAR(255),
|
||||
stripe_subscription_id VARCHAR(255),
|
||||
settings JSONB NOT NULL DEFAULT '{}',
|
||||
server_endpoint VARCHAR(512),
|
||||
provision_error TEXT,
|
||||
db_password VARCHAR(255),
|
||||
ca_applied_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX tenants_slug_active_key ON tenants (slug) WHERE status != 'DELETED';
|
||||
CREATE INDEX idx_tenants_status ON tenants (status);
|
||||
CREATE INDEX idx_tenants_logto_org_id ON tenants (logto_org_id);
|
||||
|
||||
-- Licenses
|
||||
CREATE TABLE licenses (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
tier VARCHAR(20) NOT NULL,
|
||||
features JSONB NOT NULL DEFAULT '{}',
|
||||
limits JSONB NOT NULL DEFAULT '{}',
|
||||
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
token TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_licenses_tenant_id ON licenses (tenant_id);
|
||||
CREATE INDEX idx_licenses_expires_at ON licenses (expires_at);
|
||||
|
||||
-- Audit log
|
||||
CREATE TABLE audit_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
actor_id UUID,
|
||||
actor_email VARCHAR(255),
|
||||
tenant_id UUID,
|
||||
action VARCHAR(100) NOT NULL,
|
||||
resource VARCHAR(500),
|
||||
environment VARCHAR(50),
|
||||
source_ip VARCHAR(45),
|
||||
result VARCHAR(20) NOT NULL DEFAULT 'SUCCESS',
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_audit_log_tenant ON audit_log (tenant_id, created_at DESC);
|
||||
CREATE INDEX idx_audit_log_actor ON audit_log (actor_id, created_at DESC);
|
||||
CREATE INDEX idx_audit_log_action ON audit_log (action, created_at DESC);
|
||||
|
||||
-- Platform TLS certificates
|
||||
CREATE TABLE certificates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
status VARCHAR(10) NOT NULL CHECK (status IN ('ACTIVE', 'STAGED', 'ARCHIVED')),
|
||||
subject VARCHAR(500),
|
||||
issuer VARCHAR(500),
|
||||
not_before TIMESTAMPTZ,
|
||||
not_after TIMESTAMPTZ,
|
||||
fingerprint VARCHAR(128),
|
||||
has_ca BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
self_signed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
uploaded_by UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
activated_at TIMESTAMPTZ,
|
||||
archived_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Per-tenant CA certificates
|
||||
CREATE TABLE tenant_ca_certs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
status VARCHAR(10) NOT NULL CHECK (status IN ('ACTIVE', 'STAGED')),
|
||||
label VARCHAR(200),
|
||||
subject VARCHAR(500),
|
||||
issuer VARCHAR(500),
|
||||
fingerprint VARCHAR(128),
|
||||
not_before TIMESTAMPTZ,
|
||||
not_after TIMESTAMPTZ,
|
||||
cert_pem TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tenant_ca_certs_tenant ON tenant_ca_certs(tenant_id);
|
||||
@@ -1,15 +0,0 @@
|
||||
CREATE TABLE licenses (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
tier VARCHAR(20) NOT NULL,
|
||||
features JSONB NOT NULL DEFAULT '{}',
|
||||
limits JSONB NOT NULL DEFAULT '{}',
|
||||
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
token TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_licenses_tenant_id ON licenses (tenant_id);
|
||||
CREATE INDEX idx_licenses_expires_at ON licenses (expires_at);
|
||||
@@ -1,12 +0,0 @@
|
||||
CREATE TABLE environments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
slug VARCHAR(100) NOT NULL,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(tenant_id, slug)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_environments_tenant_id ON environments(tenant_id);
|
||||
@@ -1,12 +0,0 @@
|
||||
CREATE TABLE api_keys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
environment_id UUID NOT NULL REFERENCES environments(id) ON DELETE CASCADE,
|
||||
key_hash VARCHAR(64) NOT NULL,
|
||||
key_prefix VARCHAR(12) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
revoked_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_api_keys_env ON api_keys(environment_id);
|
||||
CREATE INDEX idx_api_keys_hash ON api_keys(key_hash);
|
||||
@@ -1,18 +0,0 @@
|
||||
CREATE TABLE apps (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
environment_id UUID NOT NULL REFERENCES environments(id) ON DELETE CASCADE,
|
||||
slug VARCHAR(100) NOT NULL,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
jar_storage_path VARCHAR(500),
|
||||
jar_checksum VARCHAR(64),
|
||||
jar_original_filename VARCHAR(255),
|
||||
jar_size_bytes BIGINT,
|
||||
exposed_port INTEGER,
|
||||
current_deployment_id UUID,
|
||||
previous_deployment_id UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(environment_id, slug)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_apps_environment_id ON apps(environment_id);
|
||||
@@ -1,16 +0,0 @@
|
||||
CREATE TABLE deployments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL,
|
||||
image_ref VARCHAR(500) NOT NULL,
|
||||
desired_status VARCHAR(20) NOT NULL DEFAULT 'RUNNING',
|
||||
observed_status VARCHAR(20) NOT NULL DEFAULT 'BUILDING',
|
||||
orchestrator_metadata JSONB DEFAULT '{}',
|
||||
error_message TEXT,
|
||||
deployed_at TIMESTAMPTZ,
|
||||
stopped_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(app_id, version)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_deployments_app_id ON deployments(app_id);
|
||||
@@ -1,17 +0,0 @@
|
||||
CREATE TABLE audit_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
actor_id UUID,
|
||||
actor_email VARCHAR(255),
|
||||
tenant_id UUID,
|
||||
action VARCHAR(100) NOT NULL,
|
||||
resource VARCHAR(500),
|
||||
environment VARCHAR(50),
|
||||
source_ip VARCHAR(45),
|
||||
result VARCHAR(20) NOT NULL DEFAULT 'SUCCESS',
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_audit_log_tenant ON audit_log (tenant_id, created_at DESC);
|
||||
CREATE INDEX idx_audit_log_actor ON audit_log (actor_id, created_at DESC);
|
||||
CREATE INDEX idx_audit_log_action ON audit_log (action, created_at DESC);
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE apps ADD COLUMN memory_limit VARCHAR(20);
|
||||
ALTER TABLE apps ADD COLUMN cpu_shares INTEGER;
|
||||
@@ -1,7 +0,0 @@
|
||||
-- V010__drop_migrated_tables.sql
|
||||
-- Drop tables that have been migrated to cameleer3-server
|
||||
|
||||
DROP TABLE IF EXISTS deployments CASCADE;
|
||||
DROP TABLE IF EXISTS apps CASCADE;
|
||||
DROP TABLE IF EXISTS environments CASCADE;
|
||||
DROP TABLE IF EXISTS api_keys CASCADE;
|
||||
@@ -1,3 +0,0 @@
|
||||
-- V011__add_provisioning_fields.sql
|
||||
ALTER TABLE tenants ADD COLUMN server_endpoint VARCHAR(512);
|
||||
ALTER TABLE tenants ADD COLUMN provision_error TEXT;
|
||||
@@ -1,19 +0,0 @@
|
||||
-- Certificate management: track platform TLS certs and CA bundles
|
||||
CREATE TABLE certificates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
status VARCHAR(10) NOT NULL CHECK (status IN ('ACTIVE', 'STAGED', 'ARCHIVED')),
|
||||
subject VARCHAR(500),
|
||||
issuer VARCHAR(500),
|
||||
not_before TIMESTAMPTZ,
|
||||
not_after TIMESTAMPTZ,
|
||||
fingerprint VARCHAR(128),
|
||||
has_ca BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
self_signed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
uploaded_by UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
activated_at TIMESTAMPTZ,
|
||||
archived_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Track when each tenant last picked up the CA bundle
|
||||
ALTER TABLE tenants ADD COLUMN ca_applied_at TIMESTAMPTZ;
|
||||
@@ -1,16 +0,0 @@
|
||||
-- Per-tenant CA certificates for enterprise SSO trust
|
||||
CREATE TABLE tenant_ca_certs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
status VARCHAR(10) NOT NULL CHECK (status IN ('ACTIVE', 'STAGED')),
|
||||
label VARCHAR(200),
|
||||
subject VARCHAR(500),
|
||||
issuer VARCHAR(500),
|
||||
fingerprint VARCHAR(128),
|
||||
not_before TIMESTAMPTZ,
|
||||
not_after TIMESTAMPTZ,
|
||||
cert_pem TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tenant_ca_certs_tenant ON tenant_ca_certs(tenant_id);
|
||||
@@ -1,5 +0,0 @@
|
||||
-- Replace absolute unique constraint on slug with partial unique index
|
||||
-- that excludes DELETED tenants, allowing slug reuse after soft-delete.
|
||||
ALTER TABLE tenants DROP CONSTRAINT tenants_slug_key;
|
||||
DROP INDEX IF EXISTS idx_tenants_slug;
|
||||
CREATE UNIQUE INDEX tenants_slug_active_key ON tenants (slug) WHERE status != 'DELETED';
|
||||
Reference in New Issue
Block a user