# Sensitive Keys Server-Side Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add server-side support for unified sensitive key masking — global admin baseline (enforced, additive-only), per-app additions, SSE push to agents, and admin UI. **Architecture:** Global keys stored in `server_config` table (existing JSONB KV store). Per-app additions stored as part of `ApplicationConfig` JSONB. `SensitiveKeysMerger` computes `global ∪ per-app` for agent payloads. New admin endpoint follows existing ThresholdAdmin pattern. UI: new admin page + new section in existing AppConfigDetailPage. **Tech Stack:** Java 17 (Spring Boot 3.4.3), PostgreSQL (JSONB), React + TypeScript (Vite), @cameleer/design-system, TanStack Query --- ## Task 1: Core — SensitiveKeysConfig Record **Files:** - Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/SensitiveKeysConfig.java` - [ ] **Step 1: Create the config record** ```java package com.cameleer3.server.core.admin; import java.util.List; public record SensitiveKeysConfig(List keys) { public SensitiveKeysConfig { keys = keys != null ? List.copyOf(keys) : List.of(); } } ``` - [ ] **Step 2: Commit** ```bash git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/SensitiveKeysConfig.java git commit -m "feat: add SensitiveKeysConfig record" ``` --- ## Task 2: Core — SensitiveKeysRepository Interface **Files:** - Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/SensitiveKeysRepository.java` - [ ] **Step 1: Create the repository interface** Follow the `ThresholdRepository` pattern exactly. ```java package com.cameleer3.server.core.admin; import java.util.Optional; public interface SensitiveKeysRepository { Optional find(); void save(SensitiveKeysConfig config, String updatedBy); } ``` - [ ] **Step 2: Commit** ```bash git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/SensitiveKeysRepository.java git commit -m "feat: add SensitiveKeysRepository interface" ``` --- ## Task 3: Core — SensitiveKeysMerger (TDD) **Files:** - Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/SensitiveKeysMerger.java` - Create: `cameleer3-server-core/src/test/java/com/cameleer3/server/core/admin/SensitiveKeysMergerTest.java` - [ ] **Step 1: Write failing tests** ```java package com.cameleer3.server.core.admin; import org.junit.jupiter.api.Test; import java.util.List; import static org.junit.jupiter.api.Assertions.*; class SensitiveKeysMergerTest { @Test void bothNull_returnsNull() { assertNull(SensitiveKeysMerger.merge(null, null)); } @Test void globalOnly_returnsGlobal() { List result = SensitiveKeysMerger.merge( List.of("Authorization", "Cookie"), null); assertEquals(List.of("Authorization", "Cookie"), result); } @Test void perAppOnly_returnsPerApp() { List result = SensitiveKeysMerger.merge( null, List.of("X-Internal-*")); assertEquals(List.of("X-Internal-*"), result); } @Test void union_mergesWithoutDuplicates() { List result = SensitiveKeysMerger.merge( List.of("Authorization", "Cookie"), List.of("Cookie", "X-Custom")); assertEquals(List.of("Authorization", "Cookie", "X-Custom"), result); } @Test void caseInsensitiveDedup_preservesFirstCasing() { List result = SensitiveKeysMerger.merge( List.of("Authorization"), List.of("authorization", "AUTHORIZATION")); assertEquals(List.of("Authorization"), result); } @Test void emptyGlobal_returnsEmptyList() { // Explicit empty list = opt-out (agents mask nothing) List result = SensitiveKeysMerger.merge(List.of(), null); assertEquals(List.of(), result); } @Test void emptyGlobalWithPerApp_returnsPerApp() { // Explicit empty global + per-app additions = just per-app List result = SensitiveKeysMerger.merge( List.of(), List.of("X-Custom")); assertEquals(List.of("X-Custom"), result); } @Test void globPatterns_preserved() { List result = SensitiveKeysMerger.merge( List.of("*password*", "*secret*"), List.of("X-Internal-*")); assertEquals(List.of("*password*", "*secret*", "X-Internal-*"), result); } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `mvn test -pl cameleer3-server-core -Dtest=SensitiveKeysMergerTest -Dsurefire.failIfNoSpecifiedTests=false` Expected: compilation error — `SensitiveKeysMerger` does not exist - [ ] **Step 3: Write implementation** ```java package com.cameleer3.server.core.admin; import java.util.ArrayList; import java.util.List; import java.util.TreeSet; /** * Merges global (enforced) sensitive keys with per-app additions. * Union-only: per-app can add keys, never remove global keys. * Case-insensitive deduplication, preserves first-seen casing. */ public final class SensitiveKeysMerger { private SensitiveKeysMerger() {} /** * @param global enforced global keys (null = not configured) * @param perApp per-app additional keys (null = none) * @return merged list, or null if both inputs are null */ public static List merge(List global, List perApp) { if (global == null && perApp == null) { return null; } TreeSet seen = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); List result = new ArrayList<>(); if (global != null) { for (String key : global) { if (seen.add(key)) { result.add(key); } } } if (perApp != null) { for (String key : perApp) { if (seen.add(key)) { result.add(key); } } } return result; } } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `mvn test -pl cameleer3-server-core -Dtest=SensitiveKeysMergerTest` Expected: all 8 tests PASS - [ ] **Step 5: Commit** ```bash git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/SensitiveKeysMerger.java git add cameleer3-server-core/src/test/java/com/cameleer3/server/core/admin/SensitiveKeysMergerTest.java git commit -m "feat: add SensitiveKeysMerger with case-insensitive union dedup" ``` --- ## Task 4: App — PostgresSensitiveKeysRepository **Files:** - Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresSensitiveKeysRepository.java` - [ ] **Step 1: Create the repository** Follow `PostgresThresholdRepository` pattern exactly. ```java package com.cameleer3.server.app.storage; import com.cameleer3.server.core.admin.SensitiveKeysConfig; import com.cameleer3.server.core.admin.SensitiveKeysRepository; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; @Repository public class PostgresSensitiveKeysRepository implements SensitiveKeysRepository { private final JdbcTemplate jdbc; private final ObjectMapper objectMapper; public PostgresSensitiveKeysRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) { this.jdbc = jdbc; this.objectMapper = objectMapper; } @Override public Optional find() { List results = jdbc.query( "SELECT config_val FROM server_config WHERE config_key = 'sensitive_keys'", (rs, rowNum) -> { String json = rs.getString("config_val"); try { return objectMapper.readValue(json, SensitiveKeysConfig.class); } catch (JsonProcessingException e) { throw new RuntimeException("Failed to deserialize sensitive keys config", e); } }); return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); } @Override public void save(SensitiveKeysConfig config, String updatedBy) { String json; try { json = objectMapper.writeValueAsString(config); } catch (JsonProcessingException e) { throw new RuntimeException("Failed to serialize sensitive keys config", e); } jdbc.update(""" INSERT INTO server_config (config_key, config_val, updated_by, updated_at) VALUES ('sensitive_keys', ?::jsonb, ?, now()) ON CONFLICT (config_key) DO UPDATE SET config_val = EXCLUDED.config_val, updated_by = EXCLUDED.updated_by, updated_at = now() """, json, updatedBy); } } ``` - [ ] **Step 2: Commit** ```bash git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresSensitiveKeysRepository.java git commit -m "feat: add PostgresSensitiveKeysRepository" ``` --- ## Task 5: App — SensitiveKeysAdminController **Files:** - Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SensitiveKeysAdminController.java` - Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/SensitiveKeysRequest.java` - Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/SensitiveKeysResponse.java` - [ ] **Step 1: Create the request DTO** ```java 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 ) {} ``` - [ ] **Step 2: Create the response DTO** ```java package com.cameleer3.server.app.dto; import java.util.List; public record SensitiveKeysResponse( List keys, CommandGroupResponse pushResult ) {} ``` - [ ] **Step 3: Create the controller** ```java 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.AgentInfo; import com.cameleer3.server.core.agent.AgentRegistryService; import com.cameleer3.server.core.agent.AgentState; import com.cameleer3.server.core.agent.CommandReply; import com.cameleer3.server.core.agent.CommandType; import com.fasterxml.jackson.core.JsonProcessingException; 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 jakarta.validation.Valid; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import java.util.ArrayList; import java.util.HashSet; 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") @ApiResponse(responseCode = "200", description = "Config returned") @ApiResponse(responseCode = "204", description = "Not configured") public ResponseEntity getSensitiveKeys(HttpServletRequest httpRequest) { auditService.log("view_sensitive_keys", AuditCategory.CONFIG, "sensitive_keys", null, AuditResult.SUCCESS, httpRequest); return sensitiveKeysRepository.find() .map(ResponseEntity::ok) .orElse(ResponseEntity.noContent().build()); } @PutMapping @Operation(summary = "Update global sensitive keys", description = "Saves global keys. Set pushToAgents=true to push merged config to all LIVE agents.") @ApiResponse(responseCode = "200", description = "Config saved") public ResponseEntity updateSensitiveKeys( @Valid @RequestBody SensitiveKeysRequest request, @RequestParam(defaultValue = "false") boolean pushToAgents, HttpServletRequest httpRequest) { SensitiveKeysConfig config = new SensitiveKeysConfig(request.keys()); sensitiveKeysRepository.save(config, null); CommandGroupResponse pushResult = null; int appsPushed = 0; int totalAgents = 0; if (pushToAgents) { var fanOutResult = fanOutToAllAgents(config.keys()); pushResult = fanOutResult.aggregated; appsPushed = fanOutResult.appCount; totalAgents = fanOutResult.aggregated.total(); } auditService.log("update_sensitive_keys", AuditCategory.CONFIG, "sensitive_keys", Map.of("keys", config.keys(), "pushToAgents", pushToAgents, "appsPushed", appsPushed, "totalAgents", totalAgents), AuditResult.SUCCESS, httpRequest); log.info("Global sensitive keys updated ({} keys), pushToAgents={}, apps={}, agents={}", config.keys().size(), pushToAgents, appsPushed, totalAgents); return ResponseEntity.ok(new SensitiveKeysResponse(config.keys(), pushResult)); } private record FanOutResult(CommandGroupResponse aggregated, int appCount) {} private FanOutResult fanOutToAllAgents(List globalKeys) { // Collect distinct applications from stored configs + live agents Set applications = new HashSet<>(); for (ApplicationConfig cfg : configRepository.findAll()) { applications.add(cfg.getApplication()); } for (AgentInfo agent : registryService.findAll()) { if (agent.state() == AgentState.LIVE) { applications.add(agent.applicationId()); } } List allResponses = new ArrayList<>(); List allTimedOut = new ArrayList<>(); int totalAgents = 0; for (String application : applications) { ApplicationConfig appConfig = configRepository.findByApplication(application) .orElse(null); List perAppKeys = appConfig != null ? appConfig.getSensitiveKeys() : null; List merged = SensitiveKeysMerger.merge(globalKeys, perAppKeys); // Build a config with the merged keys for the SSE payload ApplicationConfig pushConfig = appConfig != null ? appConfig : new ApplicationConfig(); pushConfig.setApplication(application); pushConfig.setSensitiveKeys(merged); String payloadJson; try { payloadJson = objectMapper.writeValueAsString(pushConfig); } catch (JsonProcessingException e) { log.error("Failed to serialize config for {}", application, e); continue; } Map> futures = registryService.addGroupCommandWithReplies(application, null, CommandType.CONFIG_UPDATE, payloadJson); totalAgents += futures.size(); long deadline = System.currentTimeMillis() + 10_000; 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 FanOutResult( new CommandGroupResponse(allSuccess, totalAgents, allResponses.size(), allResponses, allTimedOut), applications.size()); } } ``` - [ ] **Step 4: Verify compilation** Run: `mvn clean compile -pl cameleer3-server-core,cameleer3-server-app` Expected: BUILD SUCCESS - [ ] **Step 5: Commit** ```bash git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/SensitiveKeysRequest.java git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/SensitiveKeysResponse.java git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SensitiveKeysAdminController.java git commit -m "feat: add SensitiveKeysAdminController with fan-out support" ``` --- ## Task 6: Enhance ApplicationConfigController — Merge Global Keys **Files:** - Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ApplicationConfigController.java` The GET endpoint must return `globalSensitiveKeys` and `mergedSensitiveKeys` so the UI can render read-only global pills. The PUT push must merge before sending to agents. Since `ApplicationConfig` lives in `cameleer3-common`, we cannot add fields to it directly. Instead, we wrap the response. - [ ] **Step 1: Create AppConfigResponse DTO** Create `cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AppConfigResponse.java`: ```java package com.cameleer3.server.app.dto; import com.cameleer3.common.model.ApplicationConfig; import java.util.List; /** * Wraps ApplicationConfig with additional server-computed fields for the UI. */ public record AppConfigResponse( ApplicationConfig config, List globalSensitiveKeys, List mergedSensitiveKeys ) {} ``` - [ ] **Step 2: Modify ApplicationConfigController** Add `SensitiveKeysRepository` to the constructor. Modify `getConfig()` to return `AppConfigResponse`. Modify `pushConfigToAgents()` to merge keys before sending. In `ApplicationConfigController.java`: **Constructor** — add parameter: ```java private final SensitiveKeysRepository sensitiveKeysRepository; public ApplicationConfigController(PostgresApplicationConfigRepository configRepository, AgentRegistryService registryService, ObjectMapper objectMapper, AuditService auditService, DiagramStore diagramStore, SensitiveKeysRepository sensitiveKeysRepository) { this.configRepository = configRepository; this.registryService = registryService; this.objectMapper = objectMapper; this.auditService = auditService; this.diagramStore = diagramStore; this.sensitiveKeysRepository = sensitiveKeysRepository; } ``` **getConfig()** — return wrapped response: ```java @GetMapping("/{application}") @Operation(summary = "Get application config", description = "Returns the current configuration for an application with merged sensitive keys.") @ApiResponse(responseCode = "200", description = "Config returned") public ResponseEntity getConfig(@PathVariable String application, HttpServletRequest httpRequest) { auditService.log("view_app_config", AuditCategory.CONFIG, application, null, AuditResult.SUCCESS, httpRequest); ApplicationConfig config = configRepository.findByApplication(application) .orElse(defaultConfig(application)); List globalKeys = sensitiveKeysRepository.find() .map(SensitiveKeysConfig::keys) .orElse(null); List merged = SensitiveKeysMerger.merge(globalKeys, config.getSensitiveKeys()); return ResponseEntity.ok(new AppConfigResponse(config, globalKeys, merged)); } ``` **updateConfig()** — merge before push: In the `updateConfig` method, after `ApplicationConfig saved = configRepository.save(...)`, merge the keys before pushing: ```java // Merge global + per-app sensitive keys for the SSE payload List globalKeys = sensitiveKeysRepository.find() .map(SensitiveKeysConfig::keys) .orElse(null); List mergedKeys = SensitiveKeysMerger.merge(globalKeys, saved.getSensitiveKeys()); // Push with merged keys ApplicationConfig pushConfig = cloneWithMergedKeys(saved, mergedKeys); CommandGroupResponse pushResult = pushConfigToAgents(application, environment, pushConfig); ``` Add a private helper: ```java private ApplicationConfig cloneWithMergedKeys(ApplicationConfig source, List mergedKeys) { String json; try { json = objectMapper.writeValueAsString(source); ApplicationConfig clone = objectMapper.readValue(json, ApplicationConfig.class); clone.setSensitiveKeys(mergedKeys); return clone; } catch (JsonProcessingException e) { log.warn("Failed to clone config for key merge, pushing original", e); return source; } } ``` Also add imports at the top of the file: ```java import com.cameleer3.server.app.dto.AppConfigResponse; import com.cameleer3.server.core.admin.SensitiveKeysConfig; import com.cameleer3.server.core.admin.SensitiveKeysMerger; import com.cameleer3.server.core.admin.SensitiveKeysRepository; ``` - [ ] **Step 3: Verify compilation** Run: `mvn clean compile -pl cameleer3-server-core,cameleer3-server-app` Expected: BUILD SUCCESS - [ ] **Step 4: Commit** ```bash git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AppConfigResponse.java git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ApplicationConfigController.java git commit -m "feat: merge global sensitive keys into app config GET and SSE push" ``` --- ## Task 7: Integration Test — SensitiveKeysAdminController **Files:** - Create: `cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/SensitiveKeysAdminControllerIT.java` - [ ] **Step 1: Write integration tests** Follow the `ThresholdAdminControllerIT` pattern. ```java package com.cameleer3.server.app.controller; import com.cameleer3.server.app.AbstractPostgresIT; import com.cameleer3.server.app.TestSecurityHelper; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.http.HttpEntity; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import static org.assertj.core.api.Assertions.assertThat; class SensitiveKeysAdminControllerIT extends AbstractPostgresIT { @Autowired private TestRestTemplate restTemplate; @Autowired private ObjectMapper objectMapper; @Autowired private TestSecurityHelper securityHelper; private String adminJwt; private String viewerJwt; @BeforeEach void setUp() { adminJwt = securityHelper.adminToken(); viewerJwt = securityHelper.viewerToken(); jdbcTemplate.update("DELETE FROM server_config WHERE config_key = 'sensitive_keys'"); } @Test void get_notConfigured_returns204() { ResponseEntity response = restTemplate.exchange( "/api/v1/admin/sensitive-keys", HttpMethod.GET, new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), String.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); } @Test void get_asViewer_returns403() { ResponseEntity response = restTemplate.exchange( "/api/v1/admin/sensitive-keys", HttpMethod.GET, new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)), String.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); } @Test void put_savesAndReturnsKeys() throws Exception { String json = """ { "keys": ["Authorization", "Cookie", "*password*"] } """; ResponseEntity response = restTemplate.exchange( "/api/v1/admin/sensitive-keys", HttpMethod.PUT, new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)), String.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); JsonNode body = objectMapper.readTree(response.getBody()); assertThat(body.path("keys").size()).isEqualTo(3); assertThat(body.path("keys").get(0).asText()).isEqualTo("Authorization"); assertThat(body.path("pushResult").isNull()).isTrue(); } @Test void put_thenGet_returnsStoredKeys() throws Exception { String json = """ { "keys": ["Authorization", "*secret*"] } """; restTemplate.exchange( "/api/v1/admin/sensitive-keys", HttpMethod.PUT, new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)), String.class); ResponseEntity getResponse = restTemplate.exchange( "/api/v1/admin/sensitive-keys", HttpMethod.GET, new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), String.class); assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK); JsonNode body = objectMapper.readTree(getResponse.getBody()); assertThat(body.path("keys").size()).isEqualTo(2); } @Test void put_withPushToAgents_returnsEmptyPushResult() throws Exception { // No agents connected, but push should still succeed with 0 agents String json = """ { "keys": ["Authorization"] } """; ResponseEntity response = restTemplate.exchange( "/api/v1/admin/sensitive-keys?pushToAgents=true", HttpMethod.PUT, new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)), String.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); JsonNode body = objectMapper.readTree(response.getBody()); assertThat(body.path("pushResult").path("total").asInt()).isEqualTo(0); } @Test void put_asViewer_returns403() { String json = """ { "keys": ["Authorization"] } """; ResponseEntity response = restTemplate.exchange( "/api/v1/admin/sensitive-keys", HttpMethod.PUT, new HttpEntity<>(json, securityHelper.authHeaders(viewerJwt)), String.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); } } ``` - [ ] **Step 2: Run tests** Run: `mvn verify -pl cameleer3-server-app -Dit.test=SensitiveKeysAdminControllerIT -Dsurefire.skip=true` Expected: all 5 tests PASS - [ ] **Step 3: Commit** ```bash git add cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/SensitiveKeysAdminControllerIT.java git commit -m "test: add SensitiveKeysAdminController integration tests" ``` --- ## Task 8: UI — Sensitive Keys API Query Hook **Files:** - Create: `ui/src/api/queries/admin/sensitive-keys.ts` - [ ] **Step 1: Create the query hook file** Follow the `thresholds.ts` pattern. ```typescript import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { adminFetch } from './admin-api'; // ── Types ────────────────────────────────────────────────────────────── export interface SensitiveKeysConfig { keys: string[]; } export interface SensitiveKeysResponse { keys: string[]; pushResult: { success: boolean; total: number; responded: number; responses: { agentId: string; status: string; message: string | null }[]; timedOut: string[]; } | null; } // ── Query Hooks ──────────────────────────────────────────────────────── export function useSensitiveKeys() { return useQuery({ queryKey: ['admin', 'sensitive-keys'], queryFn: () => adminFetch('/sensitive-keys'), }); } // ── Mutation Hooks ───────────────────────────────────────────────────── export function useUpdateSensitiveKeys() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ keys, pushToAgents }: { keys: string[]; pushToAgents: boolean }) => adminFetch(`/sensitive-keys?pushToAgents=${pushToAgents}`, { method: 'PUT', body: JSON.stringify({ keys }), }), onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin', 'sensitive-keys'] }); }, }); } ``` - [ ] **Step 2: Commit** ```bash git add ui/src/api/queries/admin/sensitive-keys.ts git commit -m "feat: add sensitive keys API query hooks" ``` --- ## Task 9: UI — SensitiveKeysPage Admin Page **Files:** - Create: `ui/src/pages/Admin/SensitiveKeysPage.tsx` - Create: `ui/src/pages/Admin/SensitiveKeysPage.module.css` - [ ] **Step 1: Create the CSS module** ```css .page { display: flex; flex-direction: column; gap: var(--space-lg); max-width: 720px; } .infoBanner { font-size: var(--font-size-sm); color: var(--text-secondary); background: var(--surface-secondary); padding: var(--space-md); border-radius: var(--radius-md); line-height: 1.5; } .pillList { display: flex; flex-wrap: wrap; gap: var(--space-xs); min-height: 36px; align-items: center; } .inputRow { display: flex; gap: var(--space-sm); align-items: center; } .inputRow input { flex: 1; } .footer { display: flex; gap: var(--space-sm); align-items: center; } ``` - [ ] **Step 2: Create the page component** ```tsx import { useState, useEffect, useCallback } from 'react'; import { Button, SectionHeader, Tag, Input, Toggle, Label, useToast } from '@cameleer/design-system'; import { PageLoader } from '../../components/PageLoader'; import { useSensitiveKeys, useUpdateSensitiveKeys } from '../../api/queries/admin/sensitive-keys'; import styles from './SensitiveKeysPage.module.css'; import sectionStyles from '../../styles/section-card.module.css'; export default function SensitiveKeysPage() { const { data, isLoading } = useSensitiveKeys(); const updateKeys = useUpdateSensitiveKeys(); const { toast } = useToast(); const [draft, setDraft] = useState([]); const [inputValue, setInputValue] = useState(''); const [pushToAgents, setPushToAgents] = useState(false); const [initialized, setInitialized] = useState(false); useEffect(() => { if (data !== undefined && !initialized) { setDraft(data?.keys ?? []); setInitialized(true); } }, [data, initialized]); const addKey = useCallback(() => { const trimmed = inputValue.trim(); if (!trimmed) return; if (draft.some((k) => k.toLowerCase() === trimmed.toLowerCase())) { toast({ title: 'Duplicate key', description: `"${trimmed}" is already in the list`, variant: 'warning' }); return; } setDraft((prev) => [...prev, trimmed]); setInputValue(''); }, [inputValue, draft, toast]); const removeKey = useCallback((index: number) => { setDraft((prev) => prev.filter((_, i) => i !== index)); }, []); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); addKey(); } }, [addKey]); function handleSave() { updateKeys.mutate({ keys: draft, pushToAgents }, { onSuccess: (result) => { if (result.pushResult) { toast({ title: 'Sensitive keys saved and pushed', description: `${result.pushResult.total} agent(s) notified, ${result.pushResult.responded} responded`, variant: result.pushResult.success ? 'success' : 'warning', }); } else { toast({ title: 'Sensitive keys saved', variant: 'success' }); } }, onError: () => { toast({ title: 'Failed to save sensitive keys', variant: 'error', duration: 86_400_000 }); }, }); } if (isLoading) return ; return (
Sensitive Keys
Agents ship with built-in defaults (Authorization, Cookie, Set-Cookie, X-API-Key, X-Auth-Token, Proxy-Authorization). Configuring keys here replaces agent defaults for all applications. Leave unconfigured to use agent defaults.
{draft.map((key, i) => ( removeKey(i)} /> ))} {draft.length === 0 && ( No keys configured — agents use built-in defaults )}
setInputValue(e.target.value)} onKeyDown={handleKeyDown} placeholder="Add key or glob pattern (e.g. *password*)" />
setPushToAgents((e.target as HTMLInputElement).checked)} label="Push to all connected agents immediately" />
); } ``` - [ ] **Step 3: Commit** ```bash git add ui/src/pages/Admin/SensitiveKeysPage.tsx ui/src/pages/Admin/SensitiveKeysPage.module.css git commit -m "feat: add SensitiveKeysPage admin page" ``` --- ## Task 10: UI — Wire Up Router and Sidebar **Files:** - Modify: `ui/src/router.tsx` - Modify: `ui/src/components/sidebar-utils.ts` - [ ] **Step 1: Add lazy import and route to router.tsx** In `ui/src/router.tsx`, add the lazy import near the other admin page imports: ```typescript const SensitiveKeysPage = lazy(() => import('./pages/Admin/SensitiveKeysPage')); ``` Add the route inside the admin children array, between `oidc` and `rbac` (alphabetical by path): ```typescript { path: 'sensitive-keys', element: }, ``` - [ ] **Step 2: Add sidebar node to sidebar-utils.ts** In `ui/src/components/sidebar-utils.ts`, inside `buildAdminTreeNodes()`, add the node between the OIDC and Users & Roles entries: ```typescript { id: 'admin:sensitive-keys', label: 'Sensitive Keys', path: '/admin/sensitive-keys' }, ``` The full `nodes` array should read: ```typescript const nodes: SidebarTreeNode[] = [ { id: 'admin:audit', label: 'Audit Log', path: '/admin/audit' }, ...(showInfra ? [{ id: 'admin:clickhouse', label: 'ClickHouse', path: '/admin/clickhouse' }] : []), ...(showInfra ? [{ id: 'admin:database', label: 'Database', path: '/admin/database' }] : []), { id: 'admin:environments', label: 'Environments', path: '/admin/environments' }, { id: 'admin:oidc', label: 'OIDC', path: '/admin/oidc' }, { id: 'admin:sensitive-keys', label: 'Sensitive Keys', path: '/admin/sensitive-keys' }, { id: 'admin:rbac', label: 'Users & Roles', path: '/admin/rbac' }, ]; ``` - [ ] **Step 3: Verify dev server starts** Run: `cd ui && npm run dev` Navigate to `/admin/sensitive-keys` in the browser. Verify: - Page loads with info banner and empty pill editor - Sidebar shows "Sensitive Keys" entry - Adding/removing pills works - Save calls the API - [ ] **Step 4: Commit** ```bash git add ui/src/router.tsx ui/src/components/sidebar-utils.ts git commit -m "feat: wire SensitiveKeysPage into router and admin sidebar" ``` --- ## Task 11: UI — Per-App Sensitive Keys in AppConfigDetailPage **Files:** - Modify: `ui/src/api/queries/commands.ts` - Modify: `ui/src/pages/Admin/AppConfigDetailPage.tsx` - [ ] **Step 1: Update ApplicationConfig TypeScript interface** In `ui/src/api/queries/commands.ts`, add to the `ApplicationConfig` interface: ```typescript sensitiveKeys?: string[]; globalSensitiveKeys?: string[]; mergedSensitiveKeys?: string[]; ``` The interface should now look like: ```typescript export interface ApplicationConfig { application: string version: number updatedAt?: string engineLevel?: string payloadCaptureMode?: string applicationLogLevel?: string agentLogLevel?: string metricsEnabled: boolean samplingRate: number tracedProcessors: Record taps: TapDefinition[] tapVersion: number routeRecording: Record compressSuccess: boolean sensitiveKeys?: string[] globalSensitiveKeys?: string[] mergedSensitiveKeys?: string[] } ``` Note: The GET endpoint now returns `AppConfigResponse` which wraps `config` + these extra fields. Update `useApplicationConfig` to unwrap the response. The query hook (in `commands.ts`) currently calls `GET /api/v1/config/{app}` — check its shape. Since the server response shape changed from `ApplicationConfig` to `AppConfigResponse`, the query hook needs to map the response: Find the `useApplicationConfig` hook and update it. The response now has shape `{ config: ApplicationConfig, globalSensitiveKeys: [...], mergedSensitiveKeys: [...] }`. Flatten it so consumers still get an `ApplicationConfig` with the extra fields populated: ```typescript export function useApplicationConfig(application?: string) { return useQuery({ queryKey: ['config', application], queryFn: async () => { const res = await authFetch(`/config/${application}`) if (!res.ok) throw new Error(`Failed to fetch config: ${res.status}`) const data = await res.json() // Server returns AppConfigResponse: { config, globalSensitiveKeys, mergedSensitiveKeys } const cfg = data.config ?? data cfg.globalSensitiveKeys = data.globalSensitiveKeys ?? null cfg.mergedSensitiveKeys = data.mergedSensitiveKeys ?? null return cfg as ApplicationConfig }, enabled: !!application, }) } ``` - [ ] **Step 2: Add sensitive keys section to AppConfigDetailPage** In `ui/src/pages/Admin/AppConfigDetailPage.tsx`, add state for the sensitive keys draft: ```typescript const [sensitiveKeysDraft, setSensitiveKeysDraft] = useState([]); const [sensitiveKeyInput, setSensitiveKeyInput] = useState(''); ``` In the `useEffect` that initializes from config, add: ```typescript setSensitiveKeysDraft([...(config.sensitiveKeys ?? [])]); ``` In `startEditing()`, add: ```typescript setSensitiveKeysDraft([...(config.sensitiveKeys ?? [])]); ``` In `handleSave()`, include the keys in the updated config: ```typescript const updated: ApplicationConfig = { ...config, ...form, tracedProcessors: tracedDraft, routeRecording: routeRecordingDraft, sensitiveKeys: sensitiveKeysDraft.length > 0 ? sensitiveKeysDraft : undefined, } as ApplicationConfig; ``` Add the UI section after the Route Recording section, before the closing ``: ```tsx {/* ── Sensitive Keys ──────────────────────────────────────────── */}
Sensitive Keys {config.globalSensitiveKeys && config.globalSensitiveKeys.length > 0 ? ( {config.globalSensitiveKeys.length} global (enforced) · {(editing ? sensitiveKeysDraft : config.sensitiveKeys ?? []).length} app-specific ) : ( No global sensitive keys configured. Agents use their built-in defaults. )} {/* Global keys — read-only pills */} {config.globalSensitiveKeys && config.globalSensitiveKeys.length > 0 && (
{config.globalSensitiveKeys.map((key) => ( ))}
)} {/* Per-app keys — editable */}
{(editing ? sensitiveKeysDraft : config.sensitiveKeys ?? []).map((key, i) => ( editing ? ( setSensitiveKeysDraft((prev) => prev.filter((_, idx) => idx !== i))} /> ) : ( ) ))} {(editing ? sensitiveKeysDraft : config.sensitiveKeys ?? []).length === 0 && ( None )}
{editing && (
setSensitiveKeyInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); const trimmed = sensitiveKeyInput.trim(); if (trimmed && !sensitiveKeysDraft.some((k) => k.toLowerCase() === trimmed.toLowerCase())) { setSensitiveKeysDraft((prev) => [...prev, trimmed]); setSensitiveKeyInput(''); } } }} placeholder="Add key or glob pattern" />
)}
{config.globalSensitiveKeys && config.globalSensitiveKeys.length > 0 && ( Global keys are enforced by your administrator and cannot be removed per-app. )}
``` - [ ] **Step 3: Add CSS for the new section** In `ui/src/pages/Admin/AppConfigDetailPage.module.css`, add: ```css .sensitiveKeysRow { display: flex; flex-direction: column; gap: var(--space-xs); } .pillList { display: flex; flex-wrap: wrap; gap: var(--space-xs); min-height: 28px; align-items: center; } .sensitiveKeyInput { display: flex; gap: var(--space-sm); max-width: 400px; } ``` - [ ] **Step 4: Add Tag import** At the top of `AppConfigDetailPage.tsx`, add `Tag` to the design-system import: ```typescript import { Button, SectionHeader, MonoText, Badge, DataTable, Spinner, Toggle, Select, Label, Tag, useToast, } from '@cameleer/design-system'; ``` - [ ] **Step 5: Verify in browser** Start the dev server, navigate to an app config page. Verify: - Global keys appear as greyed-out badges - Per-app keys appear as editable tags in edit mode - Adding/removing per-app keys works - Save includes sensitive keys in the payload - [ ] **Step 6: Commit** ```bash git add ui/src/api/queries/commands.ts ui/src/pages/Admin/AppConfigDetailPage.tsx ui/src/pages/Admin/AppConfigDetailPage.module.css git commit -m "feat: add per-app sensitive keys section to AppConfigDetailPage" ``` --- ## Task 12: Full Compilation and Test Verification **Files:** None (verification only) - [ ] **Step 1: Full backend build** Run: `mvn clean compile test-compile` Expected: BUILD SUCCESS - [ ] **Step 2: Run unit tests** Run: `mvn test -pl cameleer3-server-core` Expected: all tests pass, including `SensitiveKeysMergerTest` - [ ] **Step 3: Run frontend type check** Run: `cd ui && npx tsc --noEmit` Expected: no type errors - [ ] **Step 4: Regenerate openapi.json** Since backend REST API changed (new endpoint, modified response shapes), regenerate the OpenAPI spec: Run the server and fetch the spec, or run `mvn verify` if the project generates it during build. - [ ] **Step 5: Commit any remaining changes** ```bash git add -A git commit -m "chore: regenerate openapi.json after sensitive keys API changes" ```