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

@@ -9,7 +9,7 @@ paths:
## controller/ — REST endpoints
- `AgentRegistrationController` — POST /register, POST /heartbeat, GET / (list), POST /refresh-token
- `AgentRegistrationController` — POST /register (requires `environmentId` in body; 400 if missing/blank), POST /heartbeat (env from body `environmentId` → JWT `env` claim; 400 if neither present during auto-heal), GET / (list), POST /refresh-token (rejects tokens with no `env` claim)
- `AgentSseController` — GET /sse (Server-Sent Events connection)
- `AgentCommandController` — POST /broadcast, POST /{agentId}, POST /{agentId}/ack
- `AppController` — CRUD /api/v1/apps, POST /{appId}/upload-jar, GET /{appId}/versions
@@ -25,17 +25,17 @@ paths:
- `RoleAdminController` — CRUD /api/v1/admin/roles
- `GroupAdminController` — CRUD /api/v1/admin/groups
- `OidcConfigAdminController` — GET/POST /api/v1/admin/oidc, POST /test
- `SensitiveKeysAdminController` — GET/PUT /api/v1/admin/sensitive-keys. GET returns 200 with config or 204 if not configured. PUT accepts `{ keys: [...] }` with optional `?pushToAgents=true` to fan out merged keys to all LIVE agents. Stored in `server_config` table (key `sensitive_keys`).
- `SensitiveKeysAdminController` — GET/PUT /api/v1/admin/sensitive-keys. GET returns 200 with config or 204 if not configured. PUT accepts `{ keys: [...] }` with optional `?pushToAgents=true`. The fan-out iterates over every distinct `(application, environment)` slice (from persisted `application_config` rows plus currently-registered agents) and pushes per-slice merged keys — intentional global baseline + per-env overrides. Stored in `server_config` table (key `sensitive_keys`).
- `AuditLogController` — GET /api/v1/admin/audit
- `MetricsController` — GET /api/v1/metrics, GET /timeseries
- `DiagramController` — GET /api/v1/diagrams/{id}, POST /
- `DiagramController` — GET /api/v1/diagrams/{id}, POST /api/v1/data/diagrams. Ingestion resolves applicationId + environment from the agent registry (keyed on JWT subject) and stamps both on the stored `TaggedDiagram`. `route_diagrams` CH table has an `environment` column; queries like `findProcessorRouteMapping(app, env)` filter by it.
- `DiagramRenderController` — POST /api/v1/diagrams/render (ELK layout)
- `ClaimMappingAdminController` — CRUD /api/v1/admin/claim-mappings, POST /test (accepts inline rules + claims for preview without saving)
- `LicenseAdminController` — GET/POST /api/v1/admin/license
- `AgentEventsController` — GET /api/v1/agent-events (agent state change history)
- `AgentMetricsController` — GET /api/v1/agent-metrics (JVM/Camel metrics per agent instance)
- `AppSettingsController` — GET/PUT /api/v1/apps/{appId}/settings
- `ApplicationConfigController`GET/PUT /api/v1/apps/{appId}/config (traced processors, route recording, sensitive keys per app)
- `AppSettingsController` — GET/PUT /api/v1/admin/app-settings (list), /api/v1/admin/app-settings/{appId} (per-app). All endpoints require `?environment=`.
- `ApplicationConfigController``/api/v1/config` (agent/admin observability config: traced processors, taps, route recording, per-app sensitive keys). GET list requires `?environment=`. GET/PUT/DELETE for a single app are env-scoped: for AGENT role the env comes from the JWT `env` claim (query param ignored, agents cannot spoof env); for non-agent callers env must be supplied via `?environment=` (user JWTs carry a placeholder env="default" that is NOT authoritative). `defaultConfig(application, environment)` is returned when no row exists.
- `ClickHouseAdminController` — GET /api/v1/admin/clickhouse (ClickHouse admin, conditional on infrastructure endpoints)
- `DatabaseAdminController` — GET /api/v1/admin/database (PG admin, conditional on infrastructure endpoints)
- `DetailController` — GET /api/v1/detail (execution detail with processor tree)
@@ -67,7 +67,7 @@ paths:
- `PostgresDeploymentRepository` — includes JSONB replica_states, deploy_stage, findByContainerId
- `PostgresUserRepository`, `PostgresRoleRepository`, `PostgresGroupRepository`
- `PostgresAuditRepository`, `PostgresOidcConfigRepository`, `PostgresClaimMappingRepository`, `PostgresSensitiveKeysRepository`
- `PostgresAppSettingsRepository`, `PostgresApplicationConfigRepository`, `PostgresThresholdRepository`
- `PostgresAppSettingsRepository`, `PostgresApplicationConfigRepository`, `PostgresThresholdRepository`. Both `app_settings` and `application_config` are env-scoped (PK `(app_id, environment)` / `(application, environment)`); finders take `(app, env)` — no env-agnostic variants.
## storage/ — ClickHouse stores

View File

@@ -74,7 +74,7 @@ paths:
- `SensitiveKeysConfig` — record: keys (List<String>, immutable)
- `SensitiveKeysRepository` — interface: find(), save()
- `SensitiveKeysMerger` — pure function: merge(global, perApp) -> union with case-insensitive dedup, preserves first-seen casing. Returns null when both inputs null.
- `AppSettings`, `AppSettingsRepository` — per-app settings config and persistence
- `AppSettings`, `AppSettingsRepository` — per-app-per-env settings config and persistence. Record carries `(applicationId, environment, …)`; repository methods are `findByApplicationAndEnvironment`, `findByEnvironment`, `save`, `delete(appId, env)`. `AppSettings.defaults(appId, env)` produces a default instance scoped to an environment.
- `ThresholdConfig`, `ThresholdRepository` — alerting threshold config and persistence
- `AuditService` — audit logging facade
- `AuditRecord`, `AuditResult`, `AuditCategory`, `AuditRepository` — audit trail records and persistence
@@ -95,4 +95,4 @@ paths:
- `ChunkAccumulator` — batches data for efficient flush
- `WriteBuffer` — bounded ring buffer for async flush
- `BufferedLogEntry` — log entry wrapper with metadata
- `MergedExecution`, `TaggedExecution`, `TaggedDiagram` — tagged ingestion records
- `MergedExecution`, `TaggedExecution`, `TaggedDiagram` — tagged ingestion records. `TaggedDiagram` carries `(instanceId, applicationId, environment, graph)` — env is resolved from the agent registry in the controller and stamped on the ClickHouse `route_diagrams` row.

View File

@@ -38,7 +38,7 @@ java -jar cameleer-server-app/target/cameleer-server-app-1.0-SNAPSHOT.jar
- Jackson `JavaTimeModule` for `Instant` deserialization
- Communication: receives HTTP POST data from agents (executions, diagrams, metrics, logs), serves SSE event streams for config push/commands (config-update, deep-trace, replay, route-control)
- Environment filtering: all data queries filter by the selected environment. All commands target only agents in the selected environment. Backend endpoints accept optional `environment` query parameter; null = all environments (backward compatible).
- Maintains agent instance registry (in-memory) with states: LIVE -> STALE -> DEAD. Auto-heals from JWT `env` claim + heartbeat body on heartbeat/SSE after server restart (priority: heartbeat `environmentId` > JWT `env` claim > `"default"`). Capabilities and route states updated on every heartbeat (protocol v2). Route catalog merges three sources: in-memory agent registry, persistent `route_catalog` table (ClickHouse), and `stats_1m_route` execution stats. The persistent catalog tracks `first_seen`/`last_seen` per route per environment, updated on every registration and heartbeat. Routes appear in the sidebar when their lifecycle overlaps the selected time window (`first_seen <= to AND last_seen >= from`), so historical routes remain visible even after being dropped from newer app versions.
- Maintains agent instance registry (in-memory) with states: LIVE -> STALE -> DEAD. Auto-heals from JWT `env` claim + heartbeat body on heartbeat/SSE after server restart (priority: heartbeat `environmentId` > JWT `env` claim; no silent default — missing env on heartbeat auto-heal returns 400). Registration (`POST /api/v1/agents/register`) requires `environmentId` in the request body; missing or blank returns 400. Capabilities and route states updated on every heartbeat (protocol v2). Route catalog merges three sources: in-memory agent registry, persistent `route_catalog` table (ClickHouse), and `stats_1m_route` execution stats. The persistent catalog tracks `first_seen`/`last_seen` per route per environment, updated on every registration and heartbeat. Routes appear in the sidebar when their lifecycle overlaps the selected time window (`first_seen <= to AND last_seen >= from`), so historical routes remain visible even after being dropped from newer app versions.
- Multi-tenancy: each server instance serves one tenant (configured via `CAMELEER_SERVER_TENANT_ID`, default: `"default"`). Environments (dev/staging/prod) are first-class. PostgreSQL isolated via schema-per-tenant (`?currentSchema=tenant_{id}`) and `ApplicationName=tenant_{id}` on the JDBC URL. ClickHouse shared DB with `tenant_id` + `environment` columns, partitioned by `(tenant_id, toYYYYMM(timestamp))`.
- Storage: PostgreSQL for RBAC, config, and audit; ClickHouse for all observability data (executions, search, logs, metrics, stats, diagrams). ClickHouse schema migrations in `clickhouse/*.sql`, run idempotently on startup by `ClickHouseSchemaInitializer`. Use `IF NOT EXISTS` for CREATE and ADD PROJECTION.
- Log exchange correlation: `ClickHouseLogStore` extracts `exchange_id` from log entry MDC, preferring `cameleer.exchangeId` over `camel.exchangeId` (fallback for older agents). For `ON_COMPLETION` exchange copies, the agent sets `cameleer.exchangeId` to the parent's exchange ID via `CORRELATION_ID`.
@@ -54,7 +54,7 @@ java -jar cameleer-server-app/target/cameleer-server-app-1.0-SNAPSHOT.jar
## Database Migrations
PostgreSQL (Flyway): `cameleer-server-app/src/main/resources/db/migration/`
- V1 — RBAC (users, roles, groups, audit_log)
- V1 — RBAC (users, roles, groups, audit_log). `application_config` PK is `(application, environment)`; `app_settings` PK is `(application_id, environment)` — both tables are env-scoped.
- V2 — Claim mappings (OIDC)
- V3 — Runtime management (apps, environments, deployments, app_versions)
- V4 — Environment config (default_container_config JSONB)
@@ -78,7 +78,7 @@ When adding, removing, or renaming classes, controllers, endpoints, UI component
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **cameleer-server** (6281 symbols, 15871 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **cameleer-server** (6364 symbols, 16045 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

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) -> {
/**
* 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) {
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);
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);
}
},
application);
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 " + 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();
}
}

View File

@@ -4,6 +4,7 @@ import java.time.Instant;
public record AppSettings(
String applicationId,
String environment,
int slaThresholdMs,
double healthErrorWarn,
double healthErrorCrit,
@@ -12,8 +13,8 @@ public record AppSettings(
Instant createdAt,
Instant updatedAt) {
public static AppSettings defaults(String applicationId) {
public static AppSettings defaults(String applicationId, String environment) {
Instant now = Instant.now();
return new AppSettings(applicationId, 300, 1.0, 5.0, 99.0, 95.0, now, now);
return new AppSettings(applicationId, environment, 300, 1.0, 5.0, 99.0, 95.0, now, now);
}
}

View File

@@ -4,8 +4,8 @@ import java.util.List;
import java.util.Optional;
public interface AppSettingsRepository {
Optional<AppSettings> findByApplicationId(String applicationId);
List<AppSettings> findAll();
Optional<AppSettings> findByApplicationAndEnvironment(String applicationId, String environment);
List<AppSettings> findByEnvironment(String environment);
AppSettings save(AppSettings settings);
void delete(String applicationId);
void delete(String applicationId, String environment);
}

View File

@@ -3,9 +3,11 @@ package com.cameleer.server.core.ingestion;
import com.cameleer.common.graph.RouteGraph;
/**
* Pairs a {@link RouteGraph} with the authenticated agent identity.
* Pairs a {@link RouteGraph} with the authenticated agent identity and environment.
* <p>
* The agent ID is extracted from the SecurityContext in the controller layer
* and carried through the write buffer so the flush scheduler can persist it.
* The agent ID is extracted from the SecurityContext in the controller layer,
* the environment from the agent registry (which in turn came from the agent's JWT
* at registration), and all are carried through the write buffer so the flush
* scheduler can persist them.
*/
public record TaggedDiagram(String instanceId, String applicationId, RouteGraph graph) {}
public record TaggedDiagram(String instanceId, String applicationId, String environment, RouteGraph graph) {}

View File

@@ -17,5 +17,5 @@ public interface DiagramStore {
Optional<String> findContentHashForRouteByAgents(String routeId, List<String> instanceIds);
Map<String, String> findProcessorRouteMapping(String applicationId);
Map<String, String> findProcessorRouteMapping(String applicationId, String environment);
}

View File

@@ -23,7 +23,7 @@ class ChunkAccumulatorTest {
public Optional<com.cameleer.common.graph.RouteGraph> findByContentHash(String h) { return Optional.empty(); }
public Optional<String> findContentHashForRoute(String r, String a) { return Optional.empty(); }
public Optional<String> findContentHashForRouteByAgents(String r, List<String> a) { return Optional.empty(); }
public Map<String, String> findProcessorRouteMapping(String app) { return Map.of(); }
public Map<String, String> findProcessorRouteMapping(String app, String env) { return Map.of(); }
};
private CopyOnWriteArrayList<MergedExecution> executionSink;

View File

@@ -0,0 +1,135 @@
# Environment-scoped config — fixing cross-env data bleed
**Date:** 2026-04-16
**Status:** Not started
**Backwards compatibility:** None (pre-1.0; user will wipe dev DB)
## Problem
Two PostgreSQL tables key per-app state on the application name alone, despite environments (`dev`/`staging`/`prod`) being first-class in the rest of the system:
- `application_config` PK `(application)` — traced processors, taps, route recording, per-app sensitive keys. All env-sensitive.
- `app_settings` PK `(application_id)` — SLA threshold, health warn/crit thresholds. All env-sensitive.
Consequences:
- **Config corruption**: `PUT /api/v1/config/{app}?environment=dev` correctly scopes the SSE fan-out but overwrites the single DB row, so when `prod` agents restart and fetch config they get the `dev` config.
- **Agent startup is env-blind**: `GET /api/v1/config/{app}` reads neither JWT `env` claim nor any request parameter; returns whichever row exists.
- **Dashboard settings ambiguous**: `AppSettings` endpoints have no env parameter; SLA/health displayed without env context.
- Ancillary: `ClickHouseDiagramStore.findProcessorRouteMapping(appId)` doesn't filter by env even though the table has an `environment` column.
- Ancillary: `AgentRegistrationController` accepts registrations without `environmentId` and silently defaults to `"default"` — masks misconfigured agents.
## Non-goals / working correctly (do not touch)
- All ClickHouse observability tables (executions, logs, metrics, stats_1m_*) — already env-scoped.
- `AgentCommandController` / SSE command fan-out — already env-filtered via `AgentRegistryService.findByApplicationAndEnvironment`.
- `SearchController` search path — fixed in commit `e2d9428`.
- RBAC (users/roles/groups/claim mappings) — tenant-wide by design.
- Global sensitive-keys push to all envs (`SensitiveKeysAdminController.fanOutToAllAgents`) — by design; global baseline.
- Admin UI per-page env indicator — not needed, already shown in top-right of the shell.
## Design decisions (fixed)
| Question | Answer |
|---|---|
| Schema migration strategy | Edit `V1__init.sql` in place. User wipes dev DB. |
| Agent config fetch with no/unknown env | Return `404 Not Found`. No `"default"` fallback. |
| `cameleer-common` `ApplicationConfig` model | Add `environment` field in-place; agent team coordinates the bump (SNAPSHOT). |
| Agent registration without `environmentId` | Return `400 Bad Request`. Registration MUST include env. |
| UI per-screen env display | Already covered by top-right global env indicator — no extra UI work. |
## Plan
### Phase 1 — PostgreSQL schema
1. Edit `cameleer-server-app/src/main/resources/db/migration/V1__init.sql`:
- `application_config`: add `environment TEXT NOT NULL` column; change PK to `(application, environment)`.
- `app_settings`: add `environment TEXT NOT NULL` column; change PK to `(application_id, environment)`.
2. Commit message MUST call out: "Wipe dev DB before deploying — Flyway V1 checksum changes."
### Phase 2 — Shared model (`cameleer-common`)
3. **`ApplicationConfig` stays untouched on the server side.** The agent team is adding `environment` to the common class separately; the server doesn't depend on it. On the server, `environment` flows as a sidecar parameter through repositories/controllers and as a dedicated `environment` column on `application_config`. The stored JSON body contains only the config content. If/when the field appears in the common class, we'll hydrate it from the DB column into the returned DTO — no code change needed today.
4. Add `environment` field to `AppSettings` record in `cameleer-server-core` (`admin/AppSettings.java`). **Done.**
### Phase 3 — Repositories
5. `PostgresApplicationConfigRepository`:
- `findByApplicationAndEnvironment(String app, String env)` replaces `findByApplication(app)`.
- `findAll(String env)` (env required) replaces `findAll()`.
- `save(String app, String env, ApplicationConfig, String updatedBy)` replaces `save(app, config, updatedBy)`.
- Keep behaviour identical except for the PK.
6. `AppSettingsRepository` interface (core) and `PostgresAppSettingsRepository` (app) — same treatment with `(applicationId, environment)`.
### Phase 4 — REST controllers
7. `ApplicationConfigController`:
- `getConfig(@PathVariable app)`: dual-mode by caller role. For AGENT role → env taken from JWT `env` claim, query param ignored (agents cannot spoof env). For non-agent callers (admin UI, with user JWTs whose `env="default"` is a placeholder) → env must be passed via `?environment=` query param. If neither produces a value → **404**.
- `updateConfig(@PathVariable app, @RequestParam String environment, ...)`: make `environment` required. Forward to repo save. SSE push already env-scoped — keep.
- `listConfigs(@RequestParam String environment)`: require env; filter.
- `getProcessorRouteMapping(@PathVariable app, @RequestParam String environment)`: require env; forward to ClickHouse.
- `testExpression(@PathVariable app, @RequestParam String environment, ...)`: make env required (already accepted as optional — tighten).
8. `AppSettingsController`:
- `GET /api/v1/admin/app-settings?environment=`: list filtered.
- `GET /api/v1/admin/app-settings/{appId}?environment=`: require env.
- `PUT /api/v1/admin/app-settings/{appId}?environment=`: require env.
9. `SensitiveKeysAdminController`: review — global sensitive keys are server-wide (one row in `server_config`), no change needed. Add code comment clarifying env-wide push is intentional.
10. `SearchController.stats`: the SLA threshold lookup `appSettingsRepository.findByApplicationId(app)` becomes env-aware via the existing `environment` query param.
### Phase 5 — Storage
11. `ClickHouseDiagramStore.findProcessorRouteMapping(app)``findProcessorRouteMapping(app, env)`. Include `environment = ?` in `WHERE`.
### Phase 6 — JWT surface
12. Expose `env` claim via Spring `Authentication` principal — simplest path is a small custom `AuthenticationPrincipal` or `@RequestAttribute("env")` populated by `JwtAuthenticationFilter`. Keep scope minimal; only `ApplicationConfigController.getConfig` needs it directly for the 404 rule.
### Phase 7 — Agent registration hardening
13. `AgentRegistrationController.register`:
- If `request.environmentId()` is null or blank → `400 Bad Request` with an explicit error message. Drop the `"default"` fallback on line 122.
- Log the rejection (agent identity + remote IP) at INFO for diagnostics.
14. `AgentRegistrationController.refreshToken`: remove the `"default"` fallback at line 211 (dead after Phase 7.13, but harmless to clean up).
15. `AgentRegistrationController.heartbeat`: already falls back to JWT claim; after Phase 7.13 every JWT has a real env, so the `"default"` fallback at line 247 is dead code — remove.
### Phase 8 — UI queries
16. `ui/src/api/queries/dashboard.ts`: `useAppSettings(appId)``useAppSettings(appId, environment)`; same for `useAllAppSettings()`. Pull env from `useEnvironmentStore`.
17. `ui/src/api/queries/commands.ts`: verify `useApplicationConfig(appId)` / `useUpdateApplicationConfig` already pass env. Add if missing. (Audit pass only, may be no-op.)
18. Verify no other UI hook fetches per-app state without env.
### Phase 9 — Tests
19. Integration: write config for `(app=X, env=dev)`; read for `(app=X, env=prod)` returns empty/default.
20. Integration: agent JWT with `env=dev` calling `GET /api/v1/config/X` returns the dev config row. JWT with no env claim → 404.
21. Integration: `POST /api/v1/agents/register` with no `environmentId` → 400.
22. Unit: `AppSettingsRepository` env-isolation test.
### Phase 10 — Documentation
23. `CLAUDE.md`:
- "Storage" section: update `application_config` and `app_settings` PK description.
- Agent lifecycle section: note that registration requires `environmentId` (was optional, defaulted to `"default"`).
- Remove the "priority: heartbeat `environmentId` > JWT `env` claim > `"default"`" note — after fix, every agent has a real env on every path.
24. `.claude/rules/app-classes.md`:
- `ApplicationConfigController` — reflect env-required endpoints.
- `AppSettingsController` — reflect env-required endpoints.
- `AgentRegistrationController` — note env required.
25. `.claude/rules/core-classes.md`:
- `PostgresApplicationConfigRepository`, `PostgresAppSettingsRepository` — updated signatures.
## Execution order
Phases are mostly sequential by dependency: 1 → 2 → 3 → (4, 5 in parallel) → 6 → 7 → 8 → 9 → 10. Phase 6 (JWT surfacing) is a small dependency for Phase 4 controller changes; do them together.
## Verification
- `mvn clean verify` passes.
- `detect_changes` scope matches the files touched per phase.
- Manual: spin up two envs (`dev` + `prod`) locally; configure tap in `dev`; confirm `prod` agent doesn't receive it and its DB row is untouched.
- Manual: stop an agent without env in the registration payload; confirm server returns 400.
## Out of scope / follow-ups
- `audit_log` has no `environment` column; filtering audit by env would be nice-to-have but not a correctness issue. Defer.
- Agent bootstrap-token scoping to env (so a dev token can't register as prod) — security hardening for after 1.0.

View File

@@ -56,11 +56,12 @@ export function useAllApplicationConfigs() {
})
}
export function useApplicationConfig(application: string | undefined) {
export function useApplicationConfig(application: string | undefined, environment: string | undefined) {
return useQuery({
queryKey: ['applicationConfig', application],
queryKey: ['applicationConfig', application, environment],
queryFn: async () => {
const res = await authFetch(`/config/${application}`)
const envParam = environment ? `?environment=${encodeURIComponent(environment)}` : ''
const res = await authFetch(`/config/${application}${envParam}`)
if (!res.ok) throw new Error(`Failed to fetch config: ${res.status}`)
const data = await res.json()
// Server returns AppConfigResponse: { config, globalSensitiveKeys, mergedSensitiveKeys }
@@ -69,7 +70,7 @@ export function useApplicationConfig(application: string | undefined) {
cfg.mergedSensitiveKeys = data.mergedSensitiveKeys ?? null
return cfg as ApplicationConfig
},
enabled: !!application,
enabled: !!application && !!environment,
})
}
@@ -100,15 +101,16 @@ export function useUpdateApplicationConfig() {
// ── Processor → Route Mapping ─────────────────────────────────────────────
export function useProcessorRouteMapping(application?: string) {
export function useProcessorRouteMapping(application?: string, environment?: string) {
return useQuery({
queryKey: ['config', application, 'processor-routes'],
queryKey: ['config', application, environment, 'processor-routes'],
queryFn: async () => {
const res = await authFetch(`/config/${application}/processor-routes`)
const res = await authFetch(
`/config/${application}/processor-routes?environment=${encodeURIComponent(environment!)}`)
if (!res.ok) throw new Error('Failed to fetch processor-route mapping')
return res.json() as Promise<Record<string, string>>
},
enabled: !!application,
enabled: !!application && !!environment,
})
}

View File

@@ -115,6 +115,7 @@ export function usePunchcard(application?: string, environment?: string) {
export interface AppSettings {
appId: string;
environment: string;
slaThresholdMs: number;
healthErrorWarn: number;
healthErrorCrit: number;
@@ -124,19 +125,22 @@ export interface AppSettings {
updatedAt: string;
}
export function useAppSettings(appId?: string) {
export function useAppSettings(appId?: string, environment?: string) {
return useQuery({
queryKey: ['app-settings', appId],
queryFn: () => fetchJson<AppSettings>(`/admin/app-settings/${appId}`),
enabled: !!appId,
queryKey: ['app-settings', appId, environment],
queryFn: () => fetchJson<AppSettings>(
`/admin/app-settings/${appId}?environment=${encodeURIComponent(environment!)}`),
enabled: !!appId && !!environment,
staleTime: 60_000,
});
}
export function useAllAppSettings() {
export function useAllAppSettings(environment?: string) {
return useQuery({
queryKey: ['app-settings', 'all'],
queryFn: () => fetchJson<AppSettings[]>('/admin/app-settings'),
queryKey: ['app-settings', 'all', environment],
queryFn: () => fetchJson<AppSettings[]>(
`/admin/app-settings?environment=${encodeURIComponent(environment!)}`),
enabled: !!environment,
staleTime: 60_000,
});
}
@@ -144,9 +148,11 @@ export function useAllAppSettings() {
export function useUpdateAppSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ appId, settings }: { appId: string; settings: Omit<AppSettings, 'appId' | 'createdAt' | 'updatedAt'> }) => {
const token = useAuthStore.getState().accessToken;
const res = await fetch(`${config.apiBaseUrl}/admin/app-settings/${appId}`, {
mutationFn: async ({ appId, environment, settings }:
{ appId: string; environment: string; settings: Omit<AppSettings, 'appId' | 'createdAt' | 'updatedAt'> }) => {
const res = await fetch(
`${config.apiBaseUrl}/admin/app-settings/${appId}?environment=${encodeURIComponent(environment)}`,
{
method: 'PUT',
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(settings),

View File

@@ -77,7 +77,7 @@ export default function AppConfigDetailPage() {
const navigate = useNavigate();
const { toast } = useToast();
const selectedEnv = useEnvironmentStore((s) => s.environment);
const { data: config, isLoading } = useApplicationConfig(appId);
const { data: config, isLoading } = useApplicationConfig(appId, selectedEnv);
const updateConfig = useUpdateApplicationConfig();
const { data: catalog } = useCatalog();

View File

@@ -164,7 +164,7 @@ export default function AgentHealth() {
const { toast } = useToast();
const selectedEnv = useEnvironmentStore((s) => s.environment);
const { data: agents } = useAgents(undefined, appId, selectedEnv);
const { data: appConfig } = useApplicationConfig(appId);
const { data: appConfig } = useApplicationConfig(appId, selectedEnv);
const updateConfig = useUpdateApplicationConfig();
const isAdmin = useIsAdmin();

View File

@@ -941,11 +941,12 @@ interface RouteRecordingRow { id: string; routeId: string; recording: boolean; }
function ConfigSubTab({ app, environment }: { app: App; environment?: Environment }) {
const { toast } = useToast();
const navigate = useNavigate();
const { data: agentConfig } = useApplicationConfig(app.slug);
const envSlug = environment?.slug;
const { data: agentConfig } = useApplicationConfig(app.slug, envSlug);
const updateAgentConfig = useUpdateApplicationConfig();
const updateContainerConfig = useUpdateContainerConfig();
const { data: catalog } = useCatalog();
const { data: processorToRoute = {} } = useProcessorRouteMapping(app.slug);
const { data: processorToRoute = {} } = useProcessorRouteMapping(app.slug, envSlug);
const isProd = environment?.production ?? false;
const [editing, setEditing] = useState(false);
const [configTab, setConfigTab] = useState<'monitoring' | 'resources' | 'variables' | 'traces' | 'recording'>('monitoring');

View File

@@ -305,7 +305,7 @@ export default function DashboardL1() {
const { data: timeseriesByApp } = useTimeseriesByApp(timeFrom, timeTo, selectedEnv);
const { data: topErrors } = useTopErrors(timeFrom, timeTo, undefined, undefined, selectedEnv);
const { data: punchcardData } = usePunchcard(undefined, selectedEnv);
const { data: allAppSettings } = useAllAppSettings();
const { data: allAppSettings } = useAllAppSettings(selectedEnv);
// Build settings lookup map
const settingsMap = useMemo(() => {

View File

@@ -287,7 +287,7 @@ export default function DashboardL2() {
const { data: timeseriesByRoute } = useTimeseriesByRoute(timeFrom, timeTo, appId, selectedEnv);
const { data: errors } = useTopErrors(timeFrom, timeTo, appId, undefined, selectedEnv);
const { data: punchcardData } = usePunchcard(appId, selectedEnv);
const { data: appSettings } = useAppSettings(appId);
const { data: appSettings } = useAppSettings(appId, selectedEnv);
const slaThresholdMs = appSettings?.slaThresholdMs ?? 300;

View File

@@ -261,7 +261,7 @@ export default function DashboardL3() {
const { data: processorMetrics } = useProcessorMetrics(routeId ?? null, appId, selectedEnv);
const { data: topErrors } = useTopErrors(timeFrom, timeTo, appId, routeId, selectedEnv);
const { data: diagramLayout } = useDiagramByRoute(appId, routeId);
const { data: appSettings } = useAppSettings(appId);
const { data: appSettings } = useAppSettings(appId, selectedEnv);
const slaThresholdMs = appSettings?.slaThresholdMs ?? 300;

View File

@@ -209,7 +209,7 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS
}, [catalog]);
// Build nodeConfigs from app config (for TRACE/TAP badges)
const { data: appConfig } = useApplicationConfig(appId);
const { data: appConfig } = useApplicationConfig(appId, selectedEnv);
const nodeConfigs = useMemo(() => {
const map = new Map<string, NodeConfig>();
if (appConfig?.tracedProcessors) {

View File

@@ -340,7 +340,7 @@ export default function RouteDetail() {
});
// ── Application config ──────────────────────────────────────────────────────
const config = useApplicationConfig(appId);
const config = useApplicationConfig(appId, selectedEnv);
const updateConfig = useUpdateApplicationConfig();
const testExpressionMutation = useTestExpression();