From 295a185a034dcff1d3a1ad0330596fa7a09f6166 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:38:33 +0200 Subject: [PATCH] refactor(security): per-tenant JWT secret instead of shared global secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../saas/provisioning/DockerTenantProvisioner.java | 5 +---- .../saas/provisioning/ProvisioningProperties.java | 3 +-- .../saas/provisioning/TenantProvisionRequest.java | 3 ++- .../java/io/cameleer/saas/tenant/TenantEntity.java | 5 +++++ .../io/cameleer/saas/vendor/VendorTenantService.java | 11 +++++++++-- .../db/migration/V005__tenant_jwt_secret.sql | 1 + 6 files changed, 19 insertions(+), 9 deletions(-) create mode 100644 src/main/resources/db/migration/V005__tenant_jwt_secret.sql diff --git a/src/main/java/io/cameleer/saas/provisioning/DockerTenantProvisioner.java b/src/main/java/io/cameleer/saas/provisioning/DockerTenantProvisioner.java index 162c3df..083e201 100644 --- a/src/main/java/io/cameleer/saas/provisioning/DockerTenantProvisioner.java +++ b/src/main/java/io/cameleer/saas/provisioning/DockerTenantProvisioner.java @@ -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", diff --git a/src/main/java/io/cameleer/saas/provisioning/ProvisioningProperties.java b/src/main/java/io/cameleer/saas/provisioning/ProvisioningProperties.java index 6efeaa9..893b9f4 100644 --- a/src/main/java/io/cameleer/saas/provisioning/ProvisioningProperties.java +++ b/src/main/java/io/cameleer/saas/provisioning/ProvisioningProperties.java @@ -19,6 +19,5 @@ public record ProvisioningProperties( String clickhousePassword, String oidcIssuerUri, String oidcJwkSetUri, - String corsOrigins, - String jwtSecret + String corsOrigins ) {} diff --git a/src/main/java/io/cameleer/saas/provisioning/TenantProvisionRequest.java b/src/main/java/io/cameleer/saas/provisioning/TenantProvisionRequest.java index 04be916..9720784 100644 --- a/src/main/java/io/cameleer/saas/provisioning/TenantProvisionRequest.java +++ b/src/main/java/io/cameleer/saas/provisioning/TenantProvisionRequest.java @@ -7,5 +7,6 @@ public record TenantProvisionRequest( String slug, String tier, String licenseToken, - String dbPassword + String dbPassword, + String jwtSecret ) {} diff --git a/src/main/java/io/cameleer/saas/tenant/TenantEntity.java b/src/main/java/io/cameleer/saas/tenant/TenantEntity.java index 9d69d27..5b1f1c5 100644 --- a/src/main/java/io/cameleer/saas/tenant/TenantEntity.java +++ b/src/main/java/io/cameleer/saas/tenant/TenantEntity.java @@ -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; } diff --git a/src/main/java/io/cameleer/saas/vendor/VendorTenantService.java b/src/main/java/io/cameleer/saas/vendor/VendorTenantService.java index 8f65d7b..a0dc9c4 100644 --- a/src/main/java/io/cameleer/saas/vendor/VendorTenantService.java +++ b/src/main/java/io/cameleer/saas/vendor/VendorTenantService.java @@ -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); diff --git a/src/main/resources/db/migration/V005__tenant_jwt_secret.sql b/src/main/resources/db/migration/V005__tenant_jwt_secret.sql new file mode 100644 index 0000000..344d8d0 --- /dev/null +++ b/src/main/resources/db/migration/V005__tenant_jwt_secret.sql @@ -0,0 +1 @@ +ALTER TABLE tenants ADD COLUMN jwt_secret VARCHAR(255);