refactor(security): per-tenant JWT secret instead of shared global secret
All checks were successful
CI / build (push) Successful in 2m17s
CI / docker (push) Successful in 1m49s

Generate a unique JWT secret per tenant at provision time, stored on
TenantEntity (same pattern as dbPassword). On upgrade, the existing
secret is reused so agent tokens survive container recreation.

- V005 migration: add jwt_secret column to tenants table
- TenantEntity: add jwtSecret field
- TenantProvisionRequest: add jwtSecret field
- VendorTenantService: generate secret in provisionAsync(), reuse on upgrade
- DockerTenantProvisioner: read from req.jwtSecret() not props
- ProvisioningProperties: remove jwtSecret (no longer global config)

Installer team: CAMELEER_SERVER_SECURITY_JWTSECRET and
CAMELEER_SAAS_PROVISIONING_JWTSECRET can be removed from compose
templates and .env — no longer consumed by the SaaS app.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-29 09:38:33 +02:00
parent 529028f0c3
commit 295a185a03
6 changed files with 19 additions and 9 deletions

View File

@@ -34,9 +34,6 @@ public class DockerTenantProvisioner implements TenantProvisioner {
.responseTimeout(Duration.ofSeconds(30))
.build();
this.docker = DockerClientImpl.getInstance(config, httpClient);
if (props.jwtSecret() == null || props.jwtSecret().isBlank()) {
log.warn("CAMELEER_SAAS_PROVISIONING_JWTSECRET is not set — provisioned servers will fail to start");
}
}
@Override
@@ -223,7 +220,7 @@ public class DockerTenantProvisioner implements TenantProvisioner {
"CAMELEER_SERVER_CLICKHOUSE_PASSWORD=" + props.clickhousePassword(),
"CAMELEER_SERVER_TENANT_ID=" + slug,
"CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKEN=" + req.licenseToken(),
"CAMELEER_SERVER_SECURITY_JWTSECRET=" + props.jwtSecret(),
"CAMELEER_SERVER_SECURITY_JWTSECRET=" + req.jwtSecret(),
"CAMELEER_SERVER_SECURITY_OIDC_ISSUERURI=" + props.oidcIssuerUri(),
"CAMELEER_SERVER_SECURITY_OIDC_JWKSETURI=" + props.oidcJwkSetUri(),
"CAMELEER_SERVER_SECURITY_OIDC_AUDIENCE=https://api.cameleer.local",

View File

@@ -19,6 +19,5 @@ public record ProvisioningProperties(
String clickhousePassword,
String oidcIssuerUri,
String oidcJwkSetUri,
String corsOrigins,
String jwtSecret
String corsOrigins
) {}

View File

@@ -7,5 +7,6 @@ public record TenantProvisionRequest(
String slug,
String tier,
String licenseToken,
String dbPassword
String dbPassword,
String jwtSecret
) {}

View File

@@ -61,6 +61,9 @@ public class TenantEntity {
@Column(name = "db_password")
private String dbPassword;
@Column(name = "jwt_secret")
private String jwtSecret;
@Column(name = "admin_email")
private String adminEmail;
@@ -108,6 +111,8 @@ public class TenantEntity {
public void setProvisionError(String provisionError) { this.provisionError = provisionError; }
public String getDbPassword() { return dbPassword; }
public void setDbPassword(String dbPassword) { this.dbPassword = dbPassword; }
public String getJwtSecret() { return jwtSecret; }
public void setJwtSecret(String jwtSecret) { this.jwtSecret = jwtSecret; }
public String getAdminEmail() { return adminEmail; }
public void setAdminEmail(String adminEmail) { this.adminEmail = adminEmail; }
public Instant getCaAppliedAt() { return caAppliedAt; }

View File

@@ -190,16 +190,23 @@ public class VendorTenantService {
return;
}
// Store DB password on entity
// Store DB password + generate JWT secret on entity
TenantEntity tenantForDb = tenantRepository.findById(tenantId).orElse(null);
if (tenantForDb == null) {
log.error("Tenant {} disappeared during provisioning", slug);
return;
}
tenantForDb.setDbPassword(dbPassword);
// Reuse existing JWT secret on upgrade, generate on first provision
String jwtSecret = tenantForDb.getJwtSecret();
if (jwtSecret == null || jwtSecret.isBlank()) {
jwtSecret = java.util.UUID.randomUUID().toString().replace("-", "")
+ java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 8);
tenantForDb.setJwtSecret(jwtSecret);
}
tenantRepository.save(tenantForDb);
var provisionRequest = new TenantProvisionRequest(tenantId, slug, tier, licenseToken, dbPassword);
var provisionRequest = new TenantProvisionRequest(tenantId, slug, tier, licenseToken, dbPassword, jwtSecret);
ProvisionResult result = tenantProvisioner.provision(provisionRequest);
TenantEntity tenant = tenantRepository.findById(tenantId).orElse(null);

View File

@@ -0,0 +1 @@
ALTER TABLE tenants ADD COLUMN jwt_secret VARCHAR(255);