From fcb53dd0108f48b132ca5de16c3e9a80305ab083 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 16 Apr 2026 23:19:55 +0200 Subject: [PATCH] fix!: require environment on diagram lookup and attribute keys queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes two cross-env data leakage paths. Both endpoints previously returned data aggregated across all environments, so a diagram or attribute key from dev could appear in a prod UI query (and vice versa). B1: GET /api/v1/diagrams?application=&routeId= now requires ?environment= and resolves agents via registryService.findByApplicationAndEnvironment instead of findByApplication. Prevents serving a dev diagram for a prod route. B2: GET /api/v1/search/attributes/keys now requires ?environment=. SearchIndex.distinctAttributeKeys gains an environment parameter and the ClickHouse query adds the env filter alongside the existing tenant_id filter. Prevents prod attribute names leaking into dev autocompletion (and vice versa). SPA hooks updated to thread environment through from useEnvironmentStore; query keys include environment so React Query re-fetches on env switch. No call-site changes needed — hook signatures unchanged. B3 (AgentMetricsController env scope) deferred to P3C: agent-env is effectively 1:1 today via the instance_id naming ({envSlug}-{appSlug}-{replicaIndex}), and the URL migration in P3C to /api/v1/environments/{env}/agents/{agentId}/metrics naturally introduces env from path. A minimal P1 fix would regress the "view metrics of a killed agent" case. BREAKING CHANGE: Both endpoints now require ?environment= (slug). Clients omitting the parameter receive 400. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/controller/DiagramRenderController.java | 10 ++++++---- .../server/app/controller/SearchController.java | 7 ++++--- .../server/app/search/ClickHouseSearchIndex.java | 6 +++--- .../cameleer/server/core/search/SearchService.java | 4 ++-- .../cameleer/server/core/storage/SearchIndex.java | 4 ++-- ui/src/api/queries/diagrams.ts | 8 +++++--- ui/src/api/queries/executions.ts | 12 ++++++++---- 7 files changed, 30 insertions(+), 21 deletions(-) diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DiagramRenderController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DiagramRenderController.java index 0213155e..703025f1 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DiagramRenderController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DiagramRenderController.java @@ -91,15 +91,17 @@ public class DiagramRenderController { } @GetMapping - @Operation(summary = "Find diagram by application and route ID", - description = "Resolves application to agent IDs and finds the latest diagram for the route") + @Operation(summary = "Find diagram by application, environment, and route ID", + description = "Resolves (application, environment) to agent IDs and finds the latest diagram for the route. " + + "The environment filter prevents cross-env diagram leakage — without it a dev route could return a prod diagram (or vice versa).") @ApiResponse(responseCode = "200", description = "Diagram layout returned") - @ApiResponse(responseCode = "404", description = "No diagram found for the given application and route") + @ApiResponse(responseCode = "404", description = "No diagram found for the given application, environment, and route") public ResponseEntity findByApplicationAndRoute( @RequestParam String application, + @RequestParam String environment, @RequestParam String routeId, @RequestParam(defaultValue = "LR") String direction) { - List agentIds = registryService.findByApplication(application).stream() + List agentIds = registryService.findByApplicationAndEnvironment(application, environment).stream() .map(AgentInfo::instanceId) .toList(); diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/SearchController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/SearchController.java index 68112c04..b7d5fb2d 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/SearchController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/SearchController.java @@ -165,9 +165,10 @@ public class SearchController { } @GetMapping("/attributes/keys") - @Operation(summary = "Distinct attribute key names across all executions") - public ResponseEntity> attributeKeys() { - return ResponseEntity.ok(searchService.distinctAttributeKeys()); + @Operation(summary = "Distinct attribute key names for the given environment", + description = "Scoped to an environment to prevent cross-env attribute leakage in UI completions") + public ResponseEntity> attributeKeys(@RequestParam String environment) { + return ResponseEntity.ok(searchService.distinctAttributeKeys(environment)); } @GetMapping("/errors/top") diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/search/ClickHouseSearchIndex.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/search/ClickHouseSearchIndex.java index 4569af5b..d23eef3f 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/search/ClickHouseSearchIndex.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/search/ClickHouseSearchIndex.java @@ -318,14 +318,14 @@ public class ClickHouseSearchIndex implements SearchIndex { } @Override - public List distinctAttributeKeys() { + public List distinctAttributeKeys(String environment) { try { return jdbc.queryForList(""" SELECT DISTINCT arrayJoin(JSONExtractKeys(attributes)) AS attr_key FROM executions FINAL - WHERE tenant_id = ? AND attributes != '' AND attributes != '{}' + WHERE tenant_id = ? AND environment = ? AND attributes != '' AND attributes != '{}' ORDER BY attr_key - """, String.class, tenantId); + """, String.class, tenantId, environment); } catch (Exception e) { log.error("Failed to query distinct attribute keys", e); return List.of(); diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/search/SearchService.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/search/SearchService.java index 4f6109f7..b6e6cb3b 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/search/SearchService.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/search/SearchService.java @@ -25,8 +25,8 @@ public class SearchService { return searchIndex.count(request); } - public List distinctAttributeKeys() { - return searchIndex.distinctAttributeKeys(); + public List distinctAttributeKeys(String environment) { + return searchIndex.distinctAttributeKeys(environment); } public ExecutionStats stats(Instant from, Instant to) { diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/storage/SearchIndex.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/storage/SearchIndex.java index 0f02682a..1d7c9287 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/storage/SearchIndex.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/storage/SearchIndex.java @@ -17,6 +17,6 @@ public interface SearchIndex { void delete(String executionId); - /** Returns distinct attribute key names across all executions. */ - List distinctAttributeKeys(); + /** Returns distinct attribute key names across executions in the given environment. */ + List distinctAttributeKeys(String environment); } diff --git a/ui/src/api/queries/diagrams.ts b/ui/src/api/queries/diagrams.ts index eb63d8fe..12e26cd0 100644 --- a/ui/src/api/queries/diagrams.ts +++ b/ui/src/api/queries/diagrams.ts @@ -1,5 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '../client'; +import { useEnvironmentStore } from '../environment-store'; export interface DiagramNode { id?: string; @@ -53,15 +54,16 @@ export function useDiagramByRoute( routeId: string | undefined, direction: 'LR' | 'TB' = 'LR', ) { + const environment = useEnvironmentStore((s) => s.environment); return useQuery({ - queryKey: ['diagrams', 'byRoute', application, routeId, direction], + queryKey: ['diagrams', 'byRoute', environment, application, routeId, direction], queryFn: async () => { const { data, error } = await api.GET('/diagrams', { - params: { query: { application: application!, routeId: routeId!, direction } }, + params: { query: { application: application!, environment: environment!, routeId: routeId!, direction } }, }); if (error) throw new Error('Failed to load diagram for route'); return data as DiagramLayout; }, - enabled: !!application && !!routeId, + enabled: !!application && !!routeId && !!environment, }); } diff --git a/ui/src/api/queries/executions.ts b/ui/src/api/queries/executions.ts index 27cdf137..d5286cfb 100644 --- a/ui/src/api/queries/executions.ts +++ b/ui/src/api/queries/executions.ts @@ -1,5 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '../client'; +import { useEnvironmentStore } from '../environment-store'; import type { SearchRequest } from '../types'; import { useLiveQuery } from './use-refresh-interval'; @@ -35,17 +36,20 @@ export function useExecutionStats( } export function useAttributeKeys() { + const environment = useEnvironmentStore((s) => s.environment); return useQuery({ - queryKey: ['search', 'attribute-keys'], + queryKey: ['search', 'attribute-keys', environment], queryFn: async () => { const token = (await import('../../auth/auth-store')).useAuthStore.getState().accessToken; const { config } = await import('../../config'); - const res = await fetch(`${config.apiBaseUrl}/search/attributes/keys`, { - headers: { Authorization: `Bearer ${token}` }, - }); + const res = await fetch( + `${config.apiBaseUrl}/search/attributes/keys?environment=${encodeURIComponent(environment!)}`, + { headers: { Authorization: `Bearer ${token}` } }, + ); if (!res.ok) throw new Error('Failed to load attribute keys'); return res.json() as Promise; }, + enabled: !!environment, staleTime: 60_000, }); }