feat!: scope per-app config and settings by environment
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m27s
CI / docker (push) Successful in 1m10s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 1m40s
SonarQube / sonarqube (push) Successful in 4m29s

BREAKING: wipe dev PostgreSQL before deploying — V1 checksum changes.
Agents must now send environmentId on registration (400 if missing).

Two tables previously keyed on app name alone caused cross-environment
data bleed: writing config for (app=X, env=dev) would overwrite the row
used by (app=X, env=prod) agents, and agent startup fetches ignored env
entirely.

- V1 schema: application_config and app_settings are now PK (app, env).
- Repositories: env-keyed finders/saves; env is the authoritative column,
  stamped on the stored JSON so the row agrees with itself.
- ApplicationConfigController.getConfig is dual-mode — AGENT role uses
  JWT env claim (agents cannot spoof env); non-agent callers provide env
  via ?environment= query param.
- AppSettingsController endpoints now require ?environment=.
- SensitiveKeysAdminController fan-out iterates (app, env) slices so each
  env gets its own merged keys.
- DiagramController ingestion stamps env on TaggedDiagram; ClickHouse
  route_diagrams INSERT + findProcessorRouteMapping are env-scoped.
- AgentRegistrationController: environmentId is required on register;
  removed all "default" fallbacks from register/refresh/heartbeat auto-heal.
- UI hooks (useApplicationConfig, useProcessorRouteMapping, useAppSettings,
  useAllAppSettings, useUpdateAppSettings) take env, wired to
  useEnvironmentStore at all call sites.
- New ConfigEnvIsolationIT covers env-isolation for both repositories.

Plan in docs/superpowers/plans/2026-04-16-environment-scoping.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-16 22:25:21 +02:00
parent c272ac6c24
commit 9b1ef51d77
33 changed files with 573 additions and 193 deletions

View File

@@ -117,9 +117,15 @@ public class AgentRegistrationController {
if (request.instanceId() == null || request.instanceId().isBlank()) {
return ResponseEntity.badRequest().build();
}
if (request.environmentId() == null || request.environmentId().isBlank()) {
String remote = httpRequest.getRemoteAddr();
log.warn("Agent registration rejected (no environmentId): instanceId={} remote={}",
request.instanceId(), remote);
return ResponseEntity.badRequest().build();
}
String application = request.applicationId() != null ? request.applicationId() : "default";
String environmentId = request.environmentId() != null ? request.environmentId() : "default";
String environmentId = request.environmentId();
List<String> routeIds = request.routeIds() != null ? request.routeIds() : List.of();
var capabilities = request.capabilities() != null ? request.capabilities() : Collections.<String, Object>emptyMap();
@@ -206,9 +212,15 @@ public class AgentRegistrationController {
List<String> roles = result.roles().isEmpty()
? List.of("AGENT") : result.roles();
String application = result.application() != null ? result.application() : "default";
String environment = result.environment();
// Try to get application + environment from registry (agent may not be registered after server restart)
String environment = result.environment() != null ? result.environment() : "default";
// Refresh-token env claim is required — agents without env shouldn't have gotten a token in the first place.
if (environment == null || environment.isBlank()) {
log.warn("Refresh token has no environment claim: agentId={}", agentId);
return ResponseEntity.status(401).build();
}
// Prefer registry if agent is still registered (covers env edits on re-registration)
AgentInfo agent = registryService.findById(agentId);
if (agent != null) {
application = agent.applicationId();
@@ -242,9 +254,14 @@ public class AgentRegistrationController {
JwtAuthenticationFilter.JWT_RESULT_ATTR);
if (jwtResult != null) {
String application = jwtResult.application() != null ? jwtResult.application() : "default";
// Prefer environment from heartbeat body (most current), fall back to JWT claim
String env = heartbeatEnv != null ? heartbeatEnv
: jwtResult.environment() != null ? jwtResult.environment() : "default";
// Env: prefer heartbeat body (current), else JWT claim. No silent default.
String env = (heartbeatEnv != null && !heartbeatEnv.isBlank())
? heartbeatEnv
: jwtResult.environment();
if (env == null || env.isBlank()) {
log.warn("Heartbeat auto-heal rejected (no environment on JWT or body): agentId={}", id);
return ResponseEntity.status(400).build();
}
Map<String, Object> caps = capabilities != null ? capabilities : Map.of();
List<String> healRouteIds = routeIds != null ? routeIds : List.of();
registryService.register(id, id, application, env, "unknown",

View File

@@ -19,6 +19,7 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@@ -40,21 +41,24 @@ public class AppSettingsController {
}
@GetMapping
@Operation(summary = "List all application settings")
public ResponseEntity<List<AppSettings>> getAll() {
return ResponseEntity.ok(repository.findAll());
@Operation(summary = "List application settings in an environment")
public ResponseEntity<List<AppSettings>> getAll(@RequestParam String environment) {
return ResponseEntity.ok(repository.findByEnvironment(environment));
}
@GetMapping("/{appId}")
@Operation(summary = "Get settings for a specific application (returns defaults if not configured)")
public ResponseEntity<AppSettings> getByAppId(@PathVariable String appId) {
AppSettings settings = repository.findByApplicationId(appId).orElse(AppSettings.defaults(appId));
@Operation(summary = "Get settings for an application in an environment (returns defaults if not configured)")
public ResponseEntity<AppSettings> getByAppId(@PathVariable String appId,
@RequestParam String environment) {
AppSettings settings = repository.findByApplicationAndEnvironment(appId, environment)
.orElse(AppSettings.defaults(appId, environment));
return ResponseEntity.ok(settings);
}
@PutMapping("/{appId}")
@Operation(summary = "Create or update settings for an application")
@Operation(summary = "Create or update settings for an application in an environment")
public ResponseEntity<AppSettings> update(@PathVariable String appId,
@RequestParam String environment,
@Valid @RequestBody AppSettingsRequest request,
HttpServletRequest httpRequest) {
List<String> errors = request.validate();
@@ -62,18 +66,20 @@ public class AppSettingsController {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, String.join("; ", errors));
}
AppSettings saved = repository.save(request.toSettings(appId));
AppSettings saved = repository.save(request.toSettings(appId, environment));
auditService.log("update_app_settings", AuditCategory.CONFIG, appId,
Map.of("settings", saved), AuditResult.SUCCESS, httpRequest);
Map.of("environment", environment, "settings", saved), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(saved);
}
@DeleteMapping("/{appId}")
@Operation(summary = "Delete application settings (reverts to defaults)")
public ResponseEntity<Void> delete(@PathVariable String appId, HttpServletRequest httpRequest) {
repository.delete(appId);
@Operation(summary = "Delete application settings for an environment (reverts to defaults)")
public ResponseEntity<Void> delete(@PathVariable String appId,
@RequestParam String environment,
HttpServletRequest httpRequest) {
repository.delete(appId, environment);
auditService.log("delete_app_settings", AuditCategory.CONFIG, appId,
Map.of(), AuditResult.SUCCESS, httpRequest);
Map.of("environment", environment), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.noContent().build();
}
}

View File

@@ -6,7 +6,9 @@ import com.cameleer.server.app.dto.CommandGroupResponse;
import com.cameleer.server.app.dto.ConfigUpdateResponse;
import com.cameleer.server.app.dto.TestExpressionRequest;
import com.cameleer.server.app.dto.TestExpressionResponse;
import com.cameleer.server.app.security.JwtAuthenticationFilter;
import com.cameleer.server.app.storage.PostgresApplicationConfigRepository;
import com.cameleer.server.core.security.JwtService.JwtValidationResult;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
@@ -73,23 +75,38 @@ public class ApplicationConfigController {
}
@GetMapping
@Operation(summary = "List all application configs",
description = "Returns stored configurations for all applications")
@Operation(summary = "List application configs in an environment",
description = "Returns stored configurations for all applications in the given environment")
@ApiResponse(responseCode = "200", description = "Configs returned")
public ResponseEntity<List<ApplicationConfig>> listConfigs(HttpServletRequest httpRequest) {
auditService.log("view_app_configs", AuditCategory.CONFIG, null, null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(configRepository.findAll());
public ResponseEntity<List<ApplicationConfig>> listConfigs(@RequestParam String environment,
HttpServletRequest httpRequest) {
auditService.log("view_app_configs", AuditCategory.CONFIG, null,
Map.of("environment", environment), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(configRepository.findByEnvironment(environment));
}
@GetMapping("/{application}")
@Operation(summary = "Get application config",
description = "Returns the current configuration for an application with merged sensitive keys.")
@Operation(summary = "Get application config for an environment",
description = "For agents: environment is taken from the JWT env claim; the query param is ignored. "
+ "For UI/admin callers: environment must be provided via the `environment` query param. "
+ "Returns 404 if the environment cannot be resolved. Includes merged sensitive keys.")
@ApiResponse(responseCode = "200", description = "Config returned")
@ApiResponse(responseCode = "404", description = "Environment could not be resolved")
public ResponseEntity<AppConfigResponse> getConfig(@PathVariable String application,
@RequestParam(required = false) String environment,
Authentication auth,
HttpServletRequest httpRequest) {
auditService.log("view_app_config", AuditCategory.CONFIG, application, null, AuditResult.SUCCESS, httpRequest);
ApplicationConfig config = configRepository.findByApplication(application)
.orElse(defaultConfig(application));
String resolved = resolveEnvironmentForRead(auth, httpRequest, environment);
if (resolved == null || resolved.isBlank()) {
auditService.log("view_app_config", AuditCategory.CONFIG, application,
Map.of("reason", "missing_environment"), AuditResult.FAILURE, httpRequest);
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
auditService.log("view_app_config", AuditCategory.CONFIG, application,
Map.of("environment", resolved), AuditResult.SUCCESS, httpRequest);
ApplicationConfig config = configRepository.findByApplicationAndEnvironment(application, resolved)
.orElse(defaultConfig(application, resolved));
List<String> globalKeys = sensitiveKeysRepository.find()
.map(SensitiveKeysConfig::keys)
@@ -99,19 +116,36 @@ public class ApplicationConfigController {
return ResponseEntity.ok(new AppConfigResponse(config, globalKeys, merged));
}
/**
* Agents identify themselves via AGENT role and a real JWT env claim — use that,
* ignoring any query param (agents can't spoof env). Non-agent callers (admin UI)
* must pass the env explicitly; their JWT env claim is a placeholder and not
* authoritative.
*/
private String resolveEnvironmentForRead(Authentication auth,
HttpServletRequest request,
String queryEnvironment) {
boolean isAgent = auth != null && auth.getAuthorities().stream()
.anyMatch(a -> "ROLE_AGENT".equals(a.getAuthority()));
if (isAgent) {
return environmentFromJwt(request);
}
return queryEnvironment;
}
@PutMapping("/{application}")
@Operation(summary = "Update application config",
description = "Saves config and pushes CONFIG_UPDATE to all LIVE agents of this application")
@Operation(summary = "Update application config for an environment",
description = "Saves config and pushes CONFIG_UPDATE to LIVE agents of this application in the given environment")
@ApiResponse(responseCode = "200", description = "Config saved and pushed")
public ResponseEntity<ConfigUpdateResponse> updateConfig(@PathVariable String application,
@RequestParam(required = false) String environment,
@RequestParam String environment,
@RequestBody ApplicationConfig config,
Authentication auth,
HttpServletRequest httpRequest) {
String updatedBy = auth != null ? auth.getName() : "system";
config.setApplication(application);
ApplicationConfig saved = configRepository.save(application, config, updatedBy);
ApplicationConfig saved = configRepository.save(application, environment, config, updatedBy);
// Merge global + per-app sensitive keys for the SSE push payload
List<String> globalKeys = sensitiveKeysRepository.find()
@@ -126,7 +160,8 @@ public class ApplicationConfigController {
saved.getVersion(), application, pushResult.total(), pushResult.responded());
auditService.log("update_app_config", AuditCategory.CONFIG, application,
Map.of("version", saved.getVersion(), "agentsPushed", pushResult.total(),
Map.of("environment", environment, "version", saved.getVersion(),
"agentsPushed", pushResult.total(),
"responded", pushResult.responded(), "timedOut", pushResult.timedOut().size()),
AuditResult.SUCCESS, httpRequest);
@@ -134,30 +169,27 @@ public class ApplicationConfigController {
}
@GetMapping("/{application}/processor-routes")
@Operation(summary = "Get processor to route mapping",
description = "Returns a map of processorId → routeId for all processors seen in this application")
@Operation(summary = "Get processor to route mapping for an environment",
description = "Returns a map of processorId → routeId for all processors seen in this application + environment")
@ApiResponse(responseCode = "200", description = "Mapping returned")
public ResponseEntity<Map<String, String>> getProcessorRouteMapping(@PathVariable String application) {
return ResponseEntity.ok(diagramStore.findProcessorRouteMapping(application));
public ResponseEntity<Map<String, String>> getProcessorRouteMapping(@PathVariable String application,
@RequestParam String environment) {
return ResponseEntity.ok(diagramStore.findProcessorRouteMapping(application, environment));
}
@PostMapping("/{application}/test-expression")
@Operation(summary = "Test a tap expression against sample data via a live agent")
@Operation(summary = "Test a tap expression against sample data via a live agent in an environment")
@ApiResponse(responseCode = "200", description = "Expression evaluated successfully")
@ApiResponse(responseCode = "404", description = "No live agent available for this application")
@ApiResponse(responseCode = "404", description = "No live agent available for this application in this environment")
@ApiResponse(responseCode = "504", description = "Agent did not respond in time")
public ResponseEntity<TestExpressionResponse> testExpression(
@PathVariable String application,
@RequestParam(required = false) String environment,
@RequestParam String environment,
@RequestBody TestExpressionRequest request) {
// Find a LIVE agent for this application, optionally filtered by environment
var candidates = registryService.findAll().stream()
.filter(a -> application.equals(a.applicationId()))
.filter(a -> a.state() == AgentState.LIVE);
if (environment != null) {
candidates = candidates.filter(a -> environment.equals(a.environmentId()));
}
AgentInfo agent = candidates.findFirst().orElse(null);
AgentInfo agent = registryService.findByApplicationAndEnvironment(application, environment).stream()
.filter(a -> a.state() == AgentState.LIVE)
.findFirst()
.orElse(null);
if (agent == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
@@ -201,6 +233,19 @@ public class ApplicationConfigController {
}
}
/**
* Reads the {@code env} claim from the caller's validated JWT (populated by
* {@link JwtAuthenticationFilter}). Returns null if no internal JWT was seen
* on this request, or the token has no env claim.
*/
private static String environmentFromJwt(HttpServletRequest request) {
Object attr = request.getAttribute(JwtAuthenticationFilter.JWT_RESULT_ATTR);
if (attr instanceof JwtValidationResult result) {
return result.environment();
}
return null;
}
/**
* Extracts sensitiveKeys from ApplicationConfig via JsonNode to avoid compile-time
* dependency on getSensitiveKeys() which may not be in the published cameleer-common jar yet.
@@ -271,9 +316,10 @@ public class ApplicationConfigController {
return new CommandGroupResponse(allSuccess, futures.size(), responses.size(), responses, timedOut);
}
private static ApplicationConfig defaultConfig(String application) {
private static ApplicationConfig defaultConfig(String application, String environment) {
ApplicationConfig config = new ApplicationConfig();
config.setApplication(application);
config.setEnvironment(environment);
config.setVersion(0);
config.setMetricsEnabled(true);
config.setSamplingRate(1.0);

View File

@@ -50,11 +50,13 @@ public class DiagramController {
@ApiResponse(responseCode = "202", description = "Data accepted for processing")
public ResponseEntity<Void> ingestDiagrams(@RequestBody String body) throws JsonProcessingException {
String instanceId = extractAgentId();
String applicationId = resolveApplicationId(instanceId);
AgentInfo agent = registryService.findById(instanceId);
String applicationId = agent != null ? agent.applicationId() : "";
String environment = agent != null ? agent.environmentId() : "";
List<RouteGraph> graphs = parsePayload(body);
for (RouteGraph graph : graphs) {
ingestionService.ingestDiagram(new TaggedDiagram(instanceId, applicationId, graph));
ingestionService.ingestDiagram(new TaggedDiagram(instanceId, applicationId, environment, graph));
}
return ResponseEntity.accepted().build();
@@ -65,11 +67,6 @@ public class DiagramController {
return auth != null ? auth.getName() : "";
}
private String resolveApplicationId(String instanceId) {
AgentInfo agent = registryService.findById(instanceId);
return agent != null ? agent.applicationId() : "";
}
private List<RouteGraph> parsePayload(String body) throws JsonProcessingException {
String trimmed = body.strip();
if (trimmed.startsWith("[")) {

View File

@@ -119,8 +119,10 @@ public class RouteMetricsController {
if (!metrics.isEmpty()) {
// Determine SLA threshold (per-app or default)
String effectiveAppId = appId != null ? appId : (metrics.isEmpty() ? null : metrics.get(0).appId());
int threshold = appSettingsRepository.findByApplicationId(effectiveAppId != null ? effectiveAppId : "")
.map(AppSettings::slaThresholdMs).orElse(300);
int threshold = (effectiveAppId != null && environment != null && !environment.isBlank())
? appSettingsRepository.findByApplicationAndEnvironment(effectiveAppId, environment)
.map(AppSettings::slaThresholdMs).orElse(300)
: 300;
Map<String, long[]> slaCounts = statsStore.slaCountsByRoute(fromInstant, toInstant,
effectiveAppId, threshold, environment);

View File

@@ -102,10 +102,12 @@ public class SearchController {
stats = searchService.statsForRoute(from, end, routeId, application, environment);
}
// Enrich with SLA compliance
int threshold = appSettingsRepository
.findByApplicationId(application != null ? application : "")
.map(AppSettings::slaThresholdMs).orElse(300);
// Enrich with SLA compliance (per-env threshold when both app and env are specified)
int threshold = (application != null && !application.isBlank()
&& environment != null && !environment.isBlank())
? appSettingsRepository.findByApplicationAndEnvironment(application, environment)
.map(AppSettings::slaThresholdMs).orElse(300)
: 300;
double sla = searchService.slaCompliance(from, end, threshold, application, routeId, environment);
return ResponseEntity.ok(stats.withSlaCompliance(sla));
}

View File

@@ -120,31 +120,37 @@ public class SensitiveKeysAdminController {
* not yet include that field accessor.
*/
private CommandGroupResponse fanOutToAllAgents(List<String> globalKeys) {
// Collect all distinct application IDs
Set<String> applications = new LinkedHashSet<>();
configRepository.findAll().stream()
.map(ApplicationConfig::getApplication)
.filter(a -> a != null && !a.isBlank())
.forEach(applications::add);
// Collect every (application, environment) slice we know about: persisted config rows
// PLUS currently-registered live agents (which may have no stored config yet).
// Global sensitive keys are server-wide, but per-app overrides live per env, so the
// push is scoped per (app, env) so each slice gets its own merged keys.
Set<AppEnv> slices = new LinkedHashSet<>();
for (ApplicationConfig cfg : configRepository.findAll()) {
if (cfg.getApplication() != null && !cfg.getApplication().isBlank()
&& cfg.getEnvironment() != null && !cfg.getEnvironment().isBlank()) {
slices.add(new AppEnv(cfg.getApplication(), cfg.getEnvironment()));
}
}
registryService.findAll().stream()
.map(a -> a.applicationId())
.filter(a -> a != null && !a.isBlank())
.forEach(applications::add);
.filter(a -> a.applicationId() != null && !a.applicationId().isBlank()
&& a.environmentId() != null && !a.environmentId().isBlank())
.forEach(a -> slices.add(new AppEnv(a.applicationId(), a.environmentId())));
if (applications.isEmpty()) {
if (slices.isEmpty()) {
return new CommandGroupResponse(true, 0, 0, List.of(), List.of());
}
// Shared 10-second deadline across all applications
// Shared 10-second deadline across all slices
long deadline = System.currentTimeMillis() + 10_000;
List<CommandGroupResponse.AgentResponse> allResponses = new ArrayList<>();
List<String> allTimedOut = new ArrayList<>();
int totalAgents = 0;
for (String application : applications) {
// Load per-app sensitive keys via JsonNode to avoid dependency on
for (AppEnv slice : slices) {
// Load per-(app,env) sensitive keys via JsonNode to avoid dependency on
// ApplicationConfig.getSensitiveKeys() which may not be in the published jar yet.
List<String> perAppKeys = configRepository.findByApplication(application)
List<String> perAppKeys = configRepository
.findByApplicationAndEnvironment(slice.application(), slice.environment())
.map(cfg -> extractSensitiveKeys(cfg))
.orElse(null);
@@ -153,19 +159,22 @@ public class SensitiveKeysAdminController {
// Build a minimal payload map — only sensitiveKeys + application fields.
Map<String, Object> payloadMap = new LinkedHashMap<>();
payloadMap.put("application", application);
payloadMap.put("application", slice.application());
payloadMap.put("sensitiveKeys", mergedKeys);
String payloadJson;
try {
payloadJson = objectMapper.writeValueAsString(payloadMap);
} catch (JsonProcessingException e) {
log.error("Failed to serialize sensitive keys push payload for application '{}'", application, e);
log.error("Failed to serialize sensitive keys push payload for {}/{}",
slice.application(), slice.environment(), e);
continue;
}
Map<String, CompletableFuture<CommandReply>> futures =
registryService.addGroupCommandWithReplies(application, null, CommandType.CONFIG_UPDATE, payloadJson);
registryService.addGroupCommandWithReplies(
slice.application(), slice.environment(),
CommandType.CONFIG_UPDATE, payloadJson);
totalAgents += futures.size();
@@ -213,4 +222,7 @@ public class SensitiveKeysAdminController {
return null;
}
}
/** (application, environment) slice used by the fan-out loop. */
private record AppEnv(String application, String environment) {}
}

View File

@@ -33,9 +33,9 @@ public record AppSettingsRequest(
Double healthSlaCrit
) {
public AppSettings toSettings(String appId) {
public AppSettings toSettings(String appId, String environment) {
Instant now = Instant.now();
return new AppSettings(appId, slaThresholdMs, healthErrorWarn, healthErrorCrit,
return new AppSettings(appId, environment, slaThresholdMs, healthErrorWarn, healthErrorCrit,
healthSlaWarn, healthSlaCrit, now, now);
}

View File

@@ -41,8 +41,8 @@ public class ClickHouseDiagramStore implements DiagramStore {
private static final String INSERT_SQL = """
INSERT INTO route_diagrams
(tenant_id, content_hash, route_id, instance_id, application_id, definition, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
(tenant_id, content_hash, route_id, instance_id, application_id, environment, definition, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""";
private static final String SELECT_BY_HASH = """
@@ -59,7 +59,7 @@ public class ClickHouseDiagramStore implements DiagramStore {
private static final String SELECT_DEFINITIONS_FOR_APP = """
SELECT DISTINCT route_id, definition FROM route_diagrams
WHERE tenant_id = ? AND application_id = ?
WHERE tenant_id = ? AND application_id = ? AND environment = ?
""";
private final String tenantId;
@@ -104,6 +104,8 @@ public class ClickHouseDiagramStore implements DiagramStore {
RouteGraph graph = diagram.graph();
String agentId = diagram.instanceId() != null ? diagram.instanceId() : "";
String applicationId = diagram.applicationId() != null ? diagram.applicationId() : "";
String environment = (diagram.environment() != null && !diagram.environment().isBlank())
? diagram.environment() : "default";
String json = objectMapper.writeValueAsString(graph);
String contentHash = sha256Hex(json);
String routeId = graph.getRouteId() != null ? graph.getRouteId() : "";
@@ -114,6 +116,7 @@ public class ClickHouseDiagramStore implements DiagramStore {
routeId,
agentId,
applicationId,
environment,
json,
Timestamp.from(Instant.now()));
@@ -197,10 +200,10 @@ public class ClickHouseDiagramStore implements DiagramStore {
}
@Override
public Map<String, String> findProcessorRouteMapping(String applicationId) {
public Map<String, String> findProcessorRouteMapping(String applicationId, String environment) {
Map<String, String> mapping = new HashMap<>();
List<Map<String, Object>> rows = jdbc.queryForList(
SELECT_DEFINITIONS_FOR_APP, tenantId, applicationId);
SELECT_DEFINITIONS_FOR_APP, tenantId, applicationId, environment);
for (Map<String, Object> row : rows) {
String routeId = (String) row.get("route_id");
String json = (String) row.get("definition");
@@ -211,7 +214,8 @@ public class ClickHouseDiagramStore implements DiagramStore {
RouteGraph graph = objectMapper.readValue(json, RouteGraph.class);
collectNodeIds(graph.getRoot(), routeId, mapping);
} catch (JsonProcessingException e) {
log.warn("Failed to deserialize RouteGraph for route={} app={}", routeId, applicationId, e);
log.warn("Failed to deserialize RouteGraph for route={} app={} env={}",
routeId, applicationId, environment, e);
}
}
return mapping;

View File

@@ -16,6 +16,7 @@ public class PostgresAppSettingsRepository implements AppSettingsRepository {
private static final RowMapper<AppSettings> ROW_MAPPER = (rs, rowNum) -> new AppSettings(
rs.getString("application_id"),
rs.getString("environment"),
rs.getInt("sla_threshold_ms"),
rs.getDouble("health_error_warn"),
rs.getDouble("health_error_crit"),
@@ -29,24 +30,27 @@ public class PostgresAppSettingsRepository implements AppSettingsRepository {
}
@Override
public Optional<AppSettings> findByApplicationId(String applicationId) {
public Optional<AppSettings> findByApplicationAndEnvironment(String applicationId, String environment) {
List<AppSettings> results = jdbc.query(
"SELECT * FROM app_settings WHERE application_id = ?", ROW_MAPPER, applicationId);
"SELECT * FROM app_settings WHERE application_id = ? AND environment = ?",
ROW_MAPPER, applicationId, environment);
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
@Override
public List<AppSettings> findAll() {
return jdbc.query("SELECT * FROM app_settings ORDER BY application_id", ROW_MAPPER);
public List<AppSettings> findByEnvironment(String environment) {
return jdbc.query(
"SELECT * FROM app_settings WHERE environment = ? ORDER BY application_id",
ROW_MAPPER, environment);
}
@Override
public AppSettings save(AppSettings settings) {
jdbc.update("""
INSERT INTO app_settings (application_id, sla_threshold_ms, health_error_warn,
INSERT INTO app_settings (application_id, environment, sla_threshold_ms, health_error_warn,
health_error_crit, health_sla_warn, health_sla_crit, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, now(), now())
ON CONFLICT (application_id) DO UPDATE SET
VALUES (?, ?, ?, ?, ?, ?, ?, now(), now())
ON CONFLICT (application_id, environment) DO UPDATE SET
sla_threshold_ms = EXCLUDED.sla_threshold_ms,
health_error_warn = EXCLUDED.health_error_warn,
health_error_crit = EXCLUDED.health_error_crit,
@@ -54,14 +58,15 @@ public class PostgresAppSettingsRepository implements AppSettingsRepository {
health_sla_crit = EXCLUDED.health_sla_crit,
updated_at = now()
""",
settings.applicationId(), settings.slaThresholdMs(),
settings.applicationId(), settings.environment(), settings.slaThresholdMs(),
settings.healthErrorWarn(), settings.healthErrorCrit(),
settings.healthSlaWarn(), settings.healthSlaCrit());
return findByApplicationId(settings.applicationId()).orElseThrow();
return findByApplicationAndEnvironment(settings.applicationId(), settings.environment()).orElseThrow();
}
@Override
public void delete(String appId) {
jdbc.update("DELETE FROM app_settings WHERE application_id = ?", appId);
public void delete(String appId, String environment) {
jdbc.update("DELETE FROM app_settings WHERE application_id = ? AND environment = ?",
appId, environment);
}
}

View File

@@ -4,6 +4,7 @@ import com.cameleer.common.model.ApplicationConfig;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;
import java.util.List;
@@ -20,39 +21,55 @@ public class PostgresApplicationConfigRepository {
this.objectMapper = objectMapper;
}
public List<ApplicationConfig> findAll() {
return jdbc.query(
"SELECT config_val, version, updated_at FROM application_config ORDER BY application",
(rs, rowNum) -> {
try {
ApplicationConfig cfg = objectMapper.readValue(rs.getString("config_val"), ApplicationConfig.class);
cfg.setVersion(rs.getInt("version"));
cfg.setUpdatedAt(rs.getTimestamp("updated_at").toInstant());
return cfg;
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to deserialize application config", e);
}
});
/**
* Row mapper — columns (application, environment) are authoritative and always
* overwrite whatever was in the stored JSON body. Callers must SELECT them.
*/
private RowMapper<ApplicationConfig> rowMapper() {
return (rs, rowNum) -> {
try {
ApplicationConfig cfg = objectMapper.readValue(rs.getString("config_val"), ApplicationConfig.class);
cfg.setApplication(rs.getString("application"));
cfg.setEnvironment(rs.getString("environment"));
cfg.setVersion(rs.getInt("version"));
cfg.setUpdatedAt(rs.getTimestamp("updated_at").toInstant());
return cfg;
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to deserialize application config", e);
}
};
}
public Optional<ApplicationConfig> findByApplication(String application) {
private static final String SELECT_FIELDS =
"application, environment, config_val, version, updated_at";
public List<ApplicationConfig> findByEnvironment(String environment) {
return jdbc.query(
"SELECT " + SELECT_FIELDS + " FROM application_config "
+ "WHERE environment = ? ORDER BY application",
rowMapper(), environment);
}
public List<ApplicationConfig> findAll() {
return jdbc.query(
"SELECT " + SELECT_FIELDS + " FROM application_config "
+ "ORDER BY application, environment",
rowMapper());
}
public Optional<ApplicationConfig> findByApplicationAndEnvironment(String application, String environment) {
List<ApplicationConfig> results = jdbc.query(
"SELECT config_val, version, updated_at FROM application_config WHERE application = ?",
(rs, rowNum) -> {
try {
ApplicationConfig cfg = objectMapper.readValue(rs.getString("config_val"), ApplicationConfig.class);
cfg.setVersion(rs.getInt("version"));
cfg.setUpdatedAt(rs.getTimestamp("updated_at").toInstant());
return cfg;
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to deserialize application config", e);
}
},
application);
"SELECT " + SELECT_FIELDS + " FROM application_config "
+ "WHERE application = ? AND environment = ?",
rowMapper(), application, environment);
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
public ApplicationConfig save(String application, ApplicationConfig config, String updatedBy) {
public ApplicationConfig save(String application, String environment, ApplicationConfig config, String updatedBy) {
// Authoritative fields stamped on the DTO before serialization — guarantees the stored
// JSON agrees with the row's columns.
config.setApplication(application);
config.setEnvironment(environment);
String json;
try {
json = objectMapper.writeValueAsString(config);
@@ -60,18 +77,22 @@ public class PostgresApplicationConfigRepository {
throw new RuntimeException("Failed to serialize application config", e);
}
// Upsert: insert or update, auto-increment version
jdbc.update("""
INSERT INTO application_config (application, config_val, version, updated_at, updated_by)
VALUES (?, ?::jsonb, 1, now(), ?)
ON CONFLICT (application) DO UPDATE SET
INSERT INTO application_config (application, environment, config_val, version, updated_at, updated_by)
VALUES (?, ?, ?::jsonb, 1, now(), ?)
ON CONFLICT (application, environment) DO UPDATE SET
config_val = EXCLUDED.config_val,
version = application_config.version + 1,
updated_at = now(),
updated_by = EXCLUDED.updated_by
""",
application, json, updatedBy);
application, environment, json, updatedBy);
return findByApplication(application).orElseThrow();
return findByApplicationAndEnvironment(application, environment).orElseThrow();
}
public void delete(String application, String environment) {
jdbc.update("DELETE FROM application_config WHERE application = ? AND environment = ?",
application, environment);
}
}

View File

@@ -83,22 +83,26 @@ CREATE TABLE server_config (
-- =============================================================
CREATE TABLE application_config (
application TEXT PRIMARY KEY,
application TEXT NOT NULL,
environment TEXT NOT NULL,
config_val JSONB NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by TEXT
updated_by TEXT,
PRIMARY KEY (application, environment)
);
CREATE TABLE app_settings (
application_id TEXT PRIMARY KEY,
application_id TEXT NOT NULL,
environment TEXT NOT NULL,
sla_threshold_ms INTEGER NOT NULL DEFAULT 300,
health_error_warn DOUBLE PRECISION NOT NULL DEFAULT 1.0,
health_error_crit DOUBLE PRECISION NOT NULL DEFAULT 5.0,
health_sla_warn DOUBLE PRECISION NOT NULL DEFAULT 99.0,
health_sla_crit DOUBLE PRECISION NOT NULL DEFAULT 95.0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (application_id, environment)
);
-- =============================================================

View File

@@ -59,7 +59,7 @@ class ClickHouseDiagramStoreIT {
}
private TaggedDiagram tagged(String instanceId, String applicationId, RouteGraph graph) {
return new TaggedDiagram(instanceId, applicationId, graph);
return new TaggedDiagram(instanceId, applicationId, "default", graph);
}
// ── Tests ─────────────────────────────────────────────────────────────
@@ -180,7 +180,7 @@ class ClickHouseDiagramStoreIT {
jdbc.execute("OPTIMIZE TABLE route_diagrams FINAL");
Map<String, String> mapping = store.findProcessorRouteMapping("app-mapping");
Map<String, String> mapping = store.findProcessorRouteMapping("app-mapping", "default");
assertThat(mapping).containsEntry("proc-from-1", "route-5");
assertThat(mapping).containsEntry("proc-to-2", "route-5");
@@ -196,7 +196,7 @@ class ClickHouseDiagramStoreIT {
jdbc.execute("OPTIMIZE TABLE route_diagrams FINAL");
Map<String, String> mapping = store.findProcessorRouteMapping("multi-app");
Map<String, String> mapping = store.findProcessorRouteMapping("multi-app", "default");
assertThat(mapping).containsEntry("proc-a1", "route-a");
assertThat(mapping).containsEntry("proc-a2", "route-a");
@@ -205,7 +205,7 @@ class ClickHouseDiagramStoreIT {
@Test
void findProcessorRouteMapping_unknownAppReturnsEmpty() {
Map<String, String> mapping = store.findProcessorRouteMapping("nonexistent-app");
Map<String, String> mapping = store.findProcessorRouteMapping("nonexistent-app", "default");
assertThat(mapping).isEmpty();
}
}

View File

@@ -0,0 +1,117 @@
package com.cameleer.server.app.storage;
import com.cameleer.common.model.ApplicationConfig;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.core.admin.AppSettings;
import com.cameleer.server.core.admin.AppSettingsRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Verifies that the two per-app-per-env Postgres tables isolate data correctly between
* environments — writing to (app=X, env=dev) must not affect reads for (app=X, env=prod).
* Regression test for the pre-1.0 env-scoping gap (plans/2026-04-16-environment-scoping.md).
*/
class ConfigEnvIsolationIT extends AbstractPostgresIT {
@Autowired PostgresApplicationConfigRepository configRepo;
@Autowired AppSettingsRepository settingsRepo;
@Autowired ObjectMapper objectMapper;
@Test
void applicationConfig_isolatesByEnvironment() {
ApplicationConfig dev = new ApplicationConfig();
dev.setSamplingRate(0.5);
dev.setApplicationLogLevel("DEBUG");
configRepo.save("order-svc", "dev", dev, "test");
ApplicationConfig prod = new ApplicationConfig();
prod.setSamplingRate(0.01);
prod.setApplicationLogLevel("WARN");
configRepo.save("order-svc", "prod", prod, "test");
ApplicationConfig readDev = configRepo.findByApplicationAndEnvironment("order-svc", "dev")
.orElseThrow();
assertThat(readDev.getEnvironment()).isEqualTo("dev");
assertThat(readDev.getSamplingRate()).isEqualTo(0.5);
assertThat(readDev.getApplicationLogLevel()).isEqualTo("DEBUG");
ApplicationConfig readProd = configRepo.findByApplicationAndEnvironment("order-svc", "prod")
.orElseThrow();
assertThat(readProd.getEnvironment()).isEqualTo("prod");
assertThat(readProd.getSamplingRate()).isEqualTo(0.01);
assertThat(readProd.getApplicationLogLevel()).isEqualTo("WARN");
assertThat(configRepo.findByApplicationAndEnvironment("order-svc", "staging")).isEmpty();
}
@Test
void applicationConfig_saveReplacesOnlySameEnv() {
ApplicationConfig original = new ApplicationConfig();
original.setSamplingRate(0.5);
configRepo.save("svc", "dev", original, "alice");
ApplicationConfig otherEnv = new ApplicationConfig();
otherEnv.setSamplingRate(0.1);
configRepo.save("svc", "prod", otherEnv, "alice");
ApplicationConfig updated = new ApplicationConfig();
updated.setSamplingRate(0.9);
configRepo.save("svc", "dev", updated, "bob");
assertThat(configRepo.findByApplicationAndEnvironment("svc", "dev").orElseThrow()
.getSamplingRate()).isEqualTo(0.9);
assertThat(configRepo.findByApplicationAndEnvironment("svc", "prod").orElseThrow()
.getSamplingRate()).isEqualTo(0.1);
}
@Test
void applicationConfig_findByEnvironment_excludesOtherEnvs() {
ApplicationConfig a = new ApplicationConfig();
a.setSamplingRate(1.0);
configRepo.save("a", "dev", a, "test");
configRepo.save("b", "dev", a, "test");
configRepo.save("a", "prod", a, "test");
assertThat(configRepo.findByEnvironment("dev"))
.extracting(ApplicationConfig::getApplication)
.containsExactlyInAnyOrder("a", "b");
assertThat(configRepo.findByEnvironment("prod"))
.extracting(ApplicationConfig::getApplication)
.containsExactly("a");
}
@Test
void appSettings_isolatesByEnvironment() {
settingsRepo.save(new AppSettings(
"svc", "dev", 500, 1.0, 5.0, 99.0, 95.0, null, null));
settingsRepo.save(new AppSettings(
"svc", "prod", 100, 0.5, 2.0, 99.9, 99.5, null, null));
AppSettings readDev = settingsRepo.findByApplicationAndEnvironment("svc", "dev").orElseThrow();
assertThat(readDev.slaThresholdMs()).isEqualTo(500);
assertThat(readDev.environment()).isEqualTo("dev");
AppSettings readProd = settingsRepo.findByApplicationAndEnvironment("svc", "prod").orElseThrow();
assertThat(readProd.slaThresholdMs()).isEqualTo(100);
assertThat(readProd.environment()).isEqualTo("prod");
assertThat(settingsRepo.findByApplicationAndEnvironment("svc", "staging")).isEmpty();
}
@Test
void appSettings_delete_scopedToSingleEnv() {
settingsRepo.save(new AppSettings(
"svc", "dev", 500, 1.0, 5.0, 99.0, 95.0, null, null));
settingsRepo.save(new AppSettings(
"svc", "prod", 100, 0.5, 2.0, 99.9, 99.5, null, null));
settingsRepo.delete("svc", "dev");
assertThat(settingsRepo.findByApplicationAndEnvironment("svc", "dev")).isEmpty();
assertThat(settingsRepo.findByApplicationAndEnvironment("svc", "prod")).isPresent();
}
}