feat!: move config & settings under /api/v1/environments/{envSlug}/...

P3A of the taxonomy migration. Env-scoped config and settings endpoints
now live under the env-prefixed URL shape, making env a first-class
path segment instead of a query param. Agent-authoritative config is
split off into a dedicated endpoint so agent env comes from the JWT
only — never spoofable via URL.

Server:
- ApplicationConfigController: @RequestMapping("/api/v1/environments/
  {envSlug}"). Handlers use @EnvPath Environment env, appSlug as
  @PathVariable. Removed the dual-mode resolveEnvironmentForRead —
  user flow only; agent flow moved to AgentConfigController.
- AgentConfigController (new): GET /api/v1/agents/config. Reads
  instanceId from JWT subject, resolves (app, env) from registry,
  returns AppConfigResponse. Registry miss → falls back to JWT env
  claim for environment, but 404s if application cannot be derived
  (no other source without registry).
- AppSettingsController: @RequestMapping("/api/v1/environments/
  {envSlug}"). List at /app-settings, per-app at /apps/{appSlug}/
  settings. Access class-wide PreAuthorize preserved (ADMIN/OPERATOR).

SPA:
- commands.ts: useAllApplicationConfigs, useApplicationConfig,
  useUpdateApplicationConfig, useProcessorRouteMapping,
  useTestExpression — rewritten URLs to /environments/{env}/apps/
  {app}/... shape. environment now required on every call. Query
  keys include environment so cache is env-scoped.
- dashboard.ts: useAppSettings, useAllAppSettings, useUpdateAppSettings
  rewritten.
- TapConfigModal: new required environment prop; callers updated.
- RouteDetail, ExchangesPage: thread selectedEnv into test-expression
  and modal.

Config changes in SecurityConfig for the new shape landed earlier in
P0.2; no security rule changes needed in this commit.

BREAKING CHANGE: /api/v1/config/** and /api/v1/admin/app-settings/**
paths removed. Agents must use /api/v1/agents/config instead of
GET /api/v1/config/{app}; users must use /api/v1/environments/{env}/
apps/{app}/config and /api/v1/environments/{env}/apps/{app}/settings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-16 23:33:25 +02:00
parent 6b5ee10944
commit 969cdb3bd0
8 changed files with 214 additions and 144 deletions

View File

@@ -0,0 +1,115 @@
package com.cameleer.server.app.controller;
import com.cameleer.common.model.ApplicationConfig;
import com.cameleer.server.app.dto.AppConfigResponse;
import com.cameleer.server.app.security.JwtAuthenticationFilter;
import com.cameleer.server.app.storage.PostgresApplicationConfigRepository;
import com.cameleer.server.core.admin.SensitiveKeysConfig;
import com.cameleer.server.core.admin.SensitiveKeysMerger;
import com.cameleer.server.core.admin.SensitiveKeysRepository;
import com.cameleer.server.core.agent.AgentInfo;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.security.JwtService.JwtValidationResult;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* Agent-authoritative application config read. Env and application are derived
* from the agent's JWT + registry entry — not from the URL or query params, so
* agents cannot spoof env.
*/
@RestController
@RequestMapping("/api/v1/agents")
@Tag(name = "Agent Config", description = "Agent-authoritative config read (AGENT only)")
public class AgentConfigController {
private final PostgresApplicationConfigRepository configRepository;
private final AgentRegistryService registryService;
private final SensitiveKeysRepository sensitiveKeysRepository;
private final ObjectMapper objectMapper;
public AgentConfigController(PostgresApplicationConfigRepository configRepository,
AgentRegistryService registryService,
SensitiveKeysRepository sensitiveKeysRepository,
ObjectMapper objectMapper) {
this.configRepository = configRepository;
this.registryService = registryService;
this.sensitiveKeysRepository = sensitiveKeysRepository;
this.objectMapper = objectMapper;
}
@GetMapping("/config")
@Operation(summary = "Get application config for the calling agent",
description = "Resolves (application, environment) from the agent's JWT + registry. "
+ "Prefers the registry entry (heartbeat-authoritative); falls back to the JWT env claim. "
+ "Returns 404 if neither identifies a valid agent.")
@ApiResponse(responseCode = "200", description = "Config returned")
@ApiResponse(responseCode = "404", description = "Calling agent could not be resolved")
public ResponseEntity<AppConfigResponse> getConfigForAgent(Authentication auth,
HttpServletRequest request) {
String instanceId = auth != null ? auth.getName() : null;
if (instanceId == null || instanceId.isBlank()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
AgentInfo agent = registryService.findById(instanceId);
String application;
String environment;
if (agent != null) {
application = agent.applicationId();
environment = agent.environmentId();
} else {
// Registry miss — fall back to JWT env claim; application can't be
// derived from JWT alone, so without a registry entry we 404.
environment = environmentFromJwt(request);
application = null;
}
if (application == null || application.isBlank() || environment == null || environment.isBlank()) {
return ResponseEntity.notFound().build();
}
ApplicationConfig config = configRepository.findByApplicationAndEnvironment(application, environment)
.orElse(ApplicationConfigController.defaultConfig(application, environment));
List<String> globalKeys = sensitiveKeysRepository.find()
.map(SensitiveKeysConfig::keys)
.orElse(null);
List<String> merged = SensitiveKeysMerger.merge(globalKeys, extractSensitiveKeys(config));
return ResponseEntity.ok(new AppConfigResponse(config, globalKeys, merged));
}
private static String environmentFromJwt(HttpServletRequest request) {
Object attr = request.getAttribute(JwtAuthenticationFilter.JWT_RESULT_ATTR);
if (attr instanceof JwtValidationResult result) {
return result.environment();
}
return null;
}
private List<String> extractSensitiveKeys(ApplicationConfig config) {
try {
JsonNode node = objectMapper.valueToTree(config);
JsonNode keysNode = node.get("sensitiveKeys");
if (keysNode == null || keysNode.isNull() || !keysNode.isArray()) {
return null;
}
return objectMapper.convertValue(keysNode, new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {});
} catch (Exception e) {
return null;
}
}
}

View File

@@ -1,11 +1,13 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.dto.AppSettingsRequest;
import com.cameleer.server.app.web.EnvPath;
import com.cameleer.server.core.admin.AppSettings;
import com.cameleer.server.core.admin.AppSettingsRepository;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
import com.cameleer.server.core.runtime.Environment;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
@@ -19,7 +21,6 @@ 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;
@@ -27,7 +28,7 @@ import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/admin/app-settings")
@RequestMapping("/api/v1/environments/{envSlug}")
@PreAuthorize("hasAnyRole('ADMIN', 'OPERATOR')")
@Tag(name = "App Settings", description = "Per-application dashboard settings (ADMIN/OPERATOR)")
public class AppSettingsController {
@@ -40,25 +41,25 @@ public class AppSettingsController {
this.auditService = auditService;
}
@GetMapping
@Operation(summary = "List application settings in an environment")
public ResponseEntity<List<AppSettings>> getAll(@RequestParam String environment) {
return ResponseEntity.ok(repository.findByEnvironment(environment));
@GetMapping("/app-settings")
@Operation(summary = "List application settings in this environment")
public ResponseEntity<List<AppSettings>> getAll(@EnvPath Environment env) {
return ResponseEntity.ok(repository.findByEnvironment(env.slug()));
}
@GetMapping("/{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));
@GetMapping("/apps/{appSlug}/settings")
@Operation(summary = "Get settings for an application in this environment (returns defaults if not configured)")
public ResponseEntity<AppSettings> getByAppId(@EnvPath Environment env,
@PathVariable String appSlug) {
AppSettings settings = repository.findByApplicationAndEnvironment(appSlug, env.slug())
.orElse(AppSettings.defaults(appSlug, env.slug()));
return ResponseEntity.ok(settings);
}
@PutMapping("/{appId}")
@Operation(summary = "Create or update settings for an application in an environment")
public ResponseEntity<AppSettings> update(@PathVariable String appId,
@RequestParam String environment,
@PutMapping("/apps/{appSlug}/settings")
@Operation(summary = "Create or update settings for an application in this environment")
public ResponseEntity<AppSettings> update(@EnvPath Environment env,
@PathVariable String appSlug,
@Valid @RequestBody AppSettingsRequest request,
HttpServletRequest httpRequest) {
List<String> errors = request.validate();
@@ -66,20 +67,20 @@ public class AppSettingsController {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, String.join("; ", errors));
}
AppSettings saved = repository.save(request.toSettings(appId, environment));
auditService.log("update_app_settings", AuditCategory.CONFIG, appId,
Map.of("environment", environment, "settings", saved), AuditResult.SUCCESS, httpRequest);
AppSettings saved = repository.save(request.toSettings(appSlug, env.slug()));
auditService.log("update_app_settings", AuditCategory.CONFIG, appSlug,
Map.of("environment", env.slug(), "settings", saved), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(saved);
}
@DeleteMapping("/{appId}")
@Operation(summary = "Delete application settings for an environment (reverts to defaults)")
public ResponseEntity<Void> delete(@PathVariable String appId,
@RequestParam String environment,
@DeleteMapping("/apps/{appSlug}/settings")
@Operation(summary = "Delete application settings for this environment (reverts to defaults)")
public ResponseEntity<Void> delete(@EnvPath Environment env,
@PathVariable String appSlug,
HttpServletRequest httpRequest) {
repository.delete(appId, environment);
auditService.log("delete_app_settings", AuditCategory.CONFIG, appId,
Map.of("environment", environment), AuditResult.SUCCESS, httpRequest);
repository.delete(appSlug, env.slug());
auditService.log("delete_app_settings", AuditCategory.CONFIG, appSlug,
Map.of("environment", env.slug()), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.noContent().build();
}
}

View File

@@ -6,9 +6,8 @@ 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.app.web.EnvPath;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
@@ -20,6 +19,7 @@ import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.agent.AgentState;
import com.cameleer.server.core.agent.CommandReply;
import com.cameleer.server.core.agent.CommandType;
import com.cameleer.server.core.runtime.Environment;
import com.cameleer.server.core.storage.DiagramStore;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -43,12 +43,13 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/**
* Per-application configuration management.
* Agents fetch config at startup; the UI modifies config which is persisted and pushed to agents via SSE.
* Per-application configuration for UI/admin callers. Env comes from the path,
* app comes from the path. Agents use {@link AgentConfigController} instead —
* env is derived from the JWT there, not spoofable via URL.
*/
@RestController
@RequestMapping("/api/v1/config")
@Tag(name = "Application Config", description = "Per-application observability configuration")
@RequestMapping("/api/v1/environments/{envSlug}")
@Tag(name = "Application Config", description = "Per-application observability configuration (user-facing)")
public class ApplicationConfigController {
private static final Logger log = LoggerFactory.getLogger(ApplicationConfigController.class);
@@ -74,39 +75,28 @@ public class ApplicationConfigController {
this.sensitiveKeysRepository = sensitiveKeysRepository;
}
@GetMapping
@Operation(summary = "List application configs in an environment",
description = "Returns stored configurations for all applications in the given environment")
@GetMapping("/config")
@Operation(summary = "List application configs in this environment")
@ApiResponse(responseCode = "200", description = "Configs returned")
public ResponseEntity<List<ApplicationConfig>> listConfigs(@RequestParam String environment,
public ResponseEntity<List<ApplicationConfig>> listConfigs(@EnvPath Environment env,
HttpServletRequest httpRequest) {
auditService.log("view_app_configs", AuditCategory.CONFIG, null,
Map.of("environment", environment), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(configRepository.findByEnvironment(environment));
Map.of("environment", env.slug()), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(configRepository.findByEnvironment(env.slug()));
}
@GetMapping("/{application}")
@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.")
@GetMapping("/apps/{appSlug}/config")
@Operation(summary = "Get application config for this environment",
description = "Returns stored config merged with global sensitive keys. "
+ "Falls back to defaults if no row is persisted yet.")
@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,
public ResponseEntity<AppConfigResponse> getConfig(@EnvPath Environment env,
@PathVariable String appSlug,
HttpServletRequest httpRequest) {
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));
auditService.log("view_app_config", AuditCategory.CONFIG, appSlug,
Map.of("environment", env.slug()), AuditResult.SUCCESS, httpRequest);
ApplicationConfig config = configRepository.findByApplicationAndEnvironment(appSlug, env.slug())
.orElse(defaultConfig(appSlug, env.slug()));
List<String> globalKeys = sensitiveKeysRepository.find()
.map(SensitiveKeysConfig::keys)
@@ -116,51 +106,32 @@ 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 for an environment",
@PutMapping("/apps/{appSlug}/config")
@Operation(summary = "Update application config for this 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 String environment,
public ResponseEntity<ConfigUpdateResponse> updateConfig(@EnvPath Environment env,
@PathVariable String appSlug,
@RequestBody ApplicationConfig config,
Authentication auth,
HttpServletRequest httpRequest) {
String updatedBy = auth != null ? auth.getName() : "system";
config.setApplication(application);
ApplicationConfig saved = configRepository.save(application, environment, config, updatedBy);
config.setApplication(appSlug);
ApplicationConfig saved = configRepository.save(appSlug, env.slug(), config, updatedBy);
// Merge global + per-app sensitive keys for the SSE push payload
List<String> globalKeys = sensitiveKeysRepository.find()
.map(SensitiveKeysConfig::keys)
.orElse(null);
List<String> perAppKeys = extractSensitiveKeys(saved);
List<String> mergedKeys = SensitiveKeysMerger.merge(globalKeys, perAppKeys);
// Push with merged sensitive keys injected into the payload
CommandGroupResponse pushResult = pushConfigToAgentsWithMergedKeys(application, environment, saved, mergedKeys);
CommandGroupResponse pushResult = pushConfigToAgentsWithMergedKeys(appSlug, env.slug(), saved, mergedKeys);
log.info("Config v{} saved for '{}', pushed to {} agent(s), {} responded",
saved.getVersion(), application, pushResult.total(), pushResult.responded());
saved.getVersion(), appSlug, pushResult.total(), pushResult.responded());
auditService.log("update_app_config", AuditCategory.CONFIG, application,
Map.of("environment", environment, "version", saved.getVersion(),
auditService.log("update_app_config", AuditCategory.CONFIG, appSlug,
Map.of("environment", env.slug(), "version", saved.getVersion(),
"agentsPushed", pushResult.total(),
"responded", pushResult.responded(), "timedOut", pushResult.timedOut().size()),
AuditResult.SUCCESS, httpRequest);
@@ -168,35 +139,34 @@ public class ApplicationConfigController {
return ResponseEntity.ok(new ConfigUpdateResponse(saved, pushResult));
}
@GetMapping("/{application}/processor-routes")
@Operation(summary = "Get processor to route mapping for an environment",
@GetMapping("/apps/{appSlug}/processor-routes")
@Operation(summary = "Get processor to route mapping for this 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,
@RequestParam String environment) {
return ResponseEntity.ok(diagramStore.findProcessorRouteMapping(application, environment));
public ResponseEntity<Map<String, String>> getProcessorRouteMapping(@EnvPath Environment env,
@PathVariable String appSlug) {
return ResponseEntity.ok(diagramStore.findProcessorRouteMapping(appSlug, env.slug()));
}
@PostMapping("/{application}/test-expression")
@Operation(summary = "Test a tap expression against sample data via a live agent in an environment")
@PostMapping("/apps/{appSlug}/config/test-expression")
@Operation(summary = "Test a tap expression against sample data via a live agent in this environment")
@ApiResponse(responseCode = "200", description = "Expression evaluated successfully")
@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 String environment,
@EnvPath Environment env,
@PathVariable String appSlug,
@RequestBody TestExpressionRequest request) {
AgentInfo agent = registryService.findByApplicationAndEnvironment(application, environment).stream()
AgentInfo agent = registryService.findByApplicationAndEnvironment(appSlug, env.slug()).stream()
.filter(a -> a.state() == AgentState.LIVE)
.findFirst()
.orElse(null);
if (agent == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new TestExpressionResponse(null, "No live agent available for application: " + application));
.body(new TestExpressionResponse(null, "No live agent available for application: " + appSlug));
}
// Build payload JSON
String payloadJson;
try {
payloadJson = objectMapper.writeValueAsString(Map.of(
@@ -211,7 +181,6 @@ public class ApplicationConfigController {
.body(new TestExpressionResponse(null, "Failed to serialize request"));
}
// Send command and await reply
CompletableFuture<CommandReply> future = registryService.addCommandWithReply(
agent.instanceId(), CommandType.TEST_EXPRESSION, payloadJson);
@@ -233,23 +202,6 @@ 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.
*/
private List<String> extractSensitiveKeys(ApplicationConfig config) {
try {
com.fasterxml.jackson.databind.JsonNode node = objectMapper.valueToTree(config);
@@ -263,14 +215,10 @@ public class ApplicationConfigController {
}
}
/**
* Push config to agents with merged sensitive keys injected into the JSON payload.
*/
private CommandGroupResponse pushConfigToAgentsWithMergedKeys(String application, String environment,
ApplicationConfig config, List<String> mergedKeys) {
String payloadJson;
try {
// Serialize config to a mutable map, inject merged keys
@SuppressWarnings("unchecked")
Map<String, Object> configMap = objectMapper.convertValue(config, Map.class);
configMap.put("sensitiveKeys", mergedKeys);
@@ -316,7 +264,7 @@ public class ApplicationConfigController {
return new CommandGroupResponse(allSuccess, futures.size(), responses.size(), responses, timedOut);
}
private static ApplicationConfig defaultConfig(String application, String environment) {
static ApplicationConfig defaultConfig(String application, String environment) {
ApplicationConfig config = new ApplicationConfig();
config.setApplication(application);
config.setEnvironment(environment);