fix!: require environment on diagram lookup and attribute keys queries

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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-16 23:19:55 +02:00
parent c97d0ea061
commit fcb53dd010
7 changed files with 30 additions and 21 deletions

View File

@@ -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<DiagramLayout> findByApplicationAndRoute(
@RequestParam String application,
@RequestParam String environment,
@RequestParam String routeId,
@RequestParam(defaultValue = "LR") String direction) {
List<String> agentIds = registryService.findByApplication(application).stream()
List<String> agentIds = registryService.findByApplicationAndEnvironment(application, environment).stream()
.map(AgentInfo::instanceId)
.toList();

View File

@@ -165,9 +165,10 @@ public class SearchController {
}
@GetMapping("/attributes/keys")
@Operation(summary = "Distinct attribute key names across all executions")
public ResponseEntity<List<String>> 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<List<String>> attributeKeys(@RequestParam String environment) {
return ResponseEntity.ok(searchService.distinctAttributeKeys(environment));
}
@GetMapping("/errors/top")

View File

@@ -318,14 +318,14 @@ public class ClickHouseSearchIndex implements SearchIndex {
}
@Override
public List<String> distinctAttributeKeys() {
public List<String> 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();

View File

@@ -25,8 +25,8 @@ public class SearchService {
return searchIndex.count(request);
}
public List<String> distinctAttributeKeys() {
return searchIndex.distinctAttributeKeys();
public List<String> distinctAttributeKeys(String environment) {
return searchIndex.distinctAttributeKeys(environment);
}
public ExecutionStats stats(Instant from, Instant to) {

View File

@@ -17,6 +17,6 @@ public interface SearchIndex {
void delete(String executionId);
/** Returns distinct attribute key names across all executions. */
List<String> distinctAttributeKeys();
/** Returns distinct attribute key names across executions in the given environment. */
List<String> distinctAttributeKeys(String environment);
}

View File

@@ -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,
});
}

View File

@@ -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<string[]>;
},
enabled: !!environment,
staleTime: 60_000,
});
}