From c3892151a5e3706eca97ad1c3616ea1b8daa2732 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:16:27 +0200 Subject: [PATCH] feat: add SensitiveKeysAdminController with fan-out support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET/PUT /api/v1/admin/sensitive-keys (ADMIN only). PUT accepts optional pushToAgents param — when true, fans out merged global+per-app sensitive keys to all live agents via CONFIG_UPDATE SSE commands with 10-second shared deadline. Per-app keys extracted via JsonNode to avoid depending on ApplicationConfig.getSensitiveKeys() not yet in the published cameleer3-common jar. Includes audit logging on every PUT. Co-Authored-By: Claude Sonnet 4.6 --- .../SensitiveKeysAdminController.java | 214 ++++++++++++++++++ .../server/app/dto/SensitiveKeysRequest.java | 13 ++ .../server/app/dto/SensitiveKeysResponse.java | 8 + 3 files changed, 235 insertions(+) create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SensitiveKeysAdminController.java create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/SensitiveKeysRequest.java create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/SensitiveKeysResponse.java diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SensitiveKeysAdminController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SensitiveKeysAdminController.java new file mode 100644 index 00000000..a9533651 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SensitiveKeysAdminController.java @@ -0,0 +1,214 @@ +package com.cameleer3.server.app.controller; + +import com.cameleer3.common.model.ApplicationConfig; +import com.cameleer3.server.app.dto.CommandGroupResponse; +import com.cameleer3.server.app.dto.SensitiveKeysRequest; +import com.cameleer3.server.app.dto.SensitiveKeysResponse; +import com.cameleer3.server.app.storage.PostgresApplicationConfigRepository; +import com.cameleer3.server.core.admin.AuditCategory; +import com.cameleer3.server.core.admin.AuditResult; +import com.cameleer3.server.core.admin.AuditService; +import com.cameleer3.server.core.admin.SensitiveKeysConfig; +import com.cameleer3.server.core.admin.SensitiveKeysMerger; +import com.cameleer3.server.core.admin.SensitiveKeysRepository; +import com.cameleer3.server.core.agent.AgentRegistryService; +import com.cameleer3.server.core.agent.CommandReply; +import com.cameleer3.server.core.agent.CommandType; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +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.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +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 java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +@RestController +@RequestMapping("/api/v1/admin/sensitive-keys") +@PreAuthorize("hasRole('ADMIN')") +@Tag(name = "Sensitive Keys Admin", description = "Global sensitive key masking configuration (ADMIN only)") +public class SensitiveKeysAdminController { + + private static final Logger log = LoggerFactory.getLogger(SensitiveKeysAdminController.class); + + private final SensitiveKeysRepository sensitiveKeysRepository; + private final PostgresApplicationConfigRepository configRepository; + private final AgentRegistryService registryService; + private final ObjectMapper objectMapper; + private final AuditService auditService; + + public SensitiveKeysAdminController(SensitiveKeysRepository sensitiveKeysRepository, + PostgresApplicationConfigRepository configRepository, + AgentRegistryService registryService, + ObjectMapper objectMapper, + AuditService auditService) { + this.sensitiveKeysRepository = sensitiveKeysRepository; + this.configRepository = configRepository; + this.registryService = registryService; + this.objectMapper = objectMapper; + this.auditService = auditService; + } + + @GetMapping + @Operation(summary = "Get global sensitive keys configuration") + public ResponseEntity getSensitiveKeys() { + return sensitiveKeysRepository.find() + .map(ResponseEntity::ok) + .orElse(ResponseEntity.noContent().build()); + } + + @PutMapping + @Operation(summary = "Update global sensitive keys configuration", + description = "Saves the global sensitive keys. Optionally fans out merged keys to all live agents.") + public ResponseEntity updateSensitiveKeys( + @Valid @RequestBody SensitiveKeysRequest request, + @RequestParam(required = false, defaultValue = "false") boolean pushToAgents, + Authentication auth, + HttpServletRequest httpRequest) { + + String updatedBy = auth != null ? auth.getName() : "system"; + SensitiveKeysConfig config = new SensitiveKeysConfig(request.keys()); + sensitiveKeysRepository.save(config, updatedBy); + + CommandGroupResponse pushResult = null; + if (pushToAgents) { + pushResult = fanOutToAllAgents(config.keys()); + log.info("Sensitive keys saved and pushed to all applications, {} agent(s) responded", + pushResult.responded()); + } else { + log.info("Sensitive keys saved ({} keys), push skipped", config.keys().size()); + } + + auditService.log("update_sensitive_keys", AuditCategory.CONFIG, "sensitive_keys", + Map.of("keyCount", config.keys().size(), "pushToAgents", pushToAgents), + AuditResult.SUCCESS, httpRequest); + + return ResponseEntity.ok(new SensitiveKeysResponse(config.keys(), pushResult)); + } + + /** + * Fan out the merged (global + per-app) sensitive keys to all known applications. + * Collects distinct application IDs from both stored configs and live agents. + * Builds a minimal JSON payload carrying only the sensitiveKeys field so that + * agents apply it as a partial config update. + *

+ * Per-app keys are read via JsonNode rather than ApplicationConfig.getSensitiveKeys() + * to remain compatible with older published versions of cameleer3-common that may + * not yet include that field accessor. + */ + private CommandGroupResponse fanOutToAllAgents(List globalKeys) { + // Collect all distinct application IDs + Set applications = new LinkedHashSet<>(); + configRepository.findAll().stream() + .map(ApplicationConfig::getApplication) + .filter(a -> a != null && !a.isBlank()) + .forEach(applications::add); + registryService.findAll().stream() + .map(a -> a.applicationId()) + .filter(a -> a != null && !a.isBlank()) + .forEach(applications::add); + + if (applications.isEmpty()) { + return new CommandGroupResponse(true, 0, 0, List.of(), List.of()); + } + + // Shared 10-second deadline across all applications + long deadline = System.currentTimeMillis() + 10_000; + List allResponses = new ArrayList<>(); + List allTimedOut = new ArrayList<>(); + int totalAgents = 0; + + for (String application : applications) { + // Load per-app sensitive keys via JsonNode to avoid dependency on + // ApplicationConfig.getSensitiveKeys() which may not be in the published jar yet. + List perAppKeys = configRepository.findByApplication(application) + .map(cfg -> extractSensitiveKeys(cfg)) + .orElse(null); + + // Merge global + per-app keys + List mergedKeys = SensitiveKeysMerger.merge(globalKeys, perAppKeys); + + // Build a minimal payload map — only sensitiveKeys + application fields. + Map payloadMap = new LinkedHashMap<>(); + payloadMap.put("application", 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); + continue; + } + + Map> futures = + registryService.addGroupCommandWithReplies(application, null, CommandType.CONFIG_UPDATE, payloadJson); + + totalAgents += futures.size(); + + for (var entry : futures.entrySet()) { + long remaining = deadline - System.currentTimeMillis(); + if (remaining <= 0) { + allTimedOut.add(entry.getKey()); + entry.getValue().cancel(false); + continue; + } + try { + CommandReply reply = entry.getValue().get(remaining, TimeUnit.MILLISECONDS); + allResponses.add(new CommandGroupResponse.AgentResponse( + entry.getKey(), reply.status(), reply.message())); + } catch (TimeoutException e) { + allTimedOut.add(entry.getKey()); + entry.getValue().cancel(false); + } catch (Exception e) { + allResponses.add(new CommandGroupResponse.AgentResponse( + entry.getKey(), "ERROR", e.getMessage())); + } + } + } + + boolean allSuccess = allTimedOut.isEmpty() && + allResponses.stream().allMatch(r -> "SUCCESS".equals(r.status())); + return new CommandGroupResponse(allSuccess, totalAgents, allResponses.size(), allResponses, allTimedOut); + } + + /** + * Extracts the sensitiveKeys list from an ApplicationConfig by round-tripping through + * JsonNode. This avoids a compile-time dependency on ApplicationConfig.getSensitiveKeys() + * which may not be present in older published versions of cameleer3-common. + */ + private List 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 TypeReference>() {}); + } catch (Exception e) { + log.warn("Failed to extract sensitiveKeys from ApplicationConfig", e); + return null; + } + } +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/SensitiveKeysRequest.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/SensitiveKeysRequest.java new file mode 100644 index 00000000..f6d6e7b8 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/SensitiveKeysRequest.java @@ -0,0 +1,13 @@ +package com.cameleer3.server.app.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +@Schema(description = "Global sensitive keys configuration") +public record SensitiveKeysRequest( + @NotNull + @Schema(description = "List of key names or glob patterns to mask") + List keys +) {} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/SensitiveKeysResponse.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/SensitiveKeysResponse.java new file mode 100644 index 00000000..b8a63a7b --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/SensitiveKeysResponse.java @@ -0,0 +1,8 @@ +package com.cameleer3.server.app.dto; + +import java.util.List; + +public record SensitiveKeysResponse( + List keys, + CommandGroupResponse pushResult +) {}