- CLAUDE.md: add SensitiveKeysConfig, SensitiveKeysRepository, SensitiveKeysMerger to core admin classes; add SensitiveKeysAdminController endpoint; add PostgresSensitiveKeysRepository; add sensitive keys convention; add admin page to UI structure - Design spec and implementation plan for the feature Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
47 KiB
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
package com.cameleer3.server.core.admin;
import java.util.List;
public record SensitiveKeysConfig(List<String> keys) {
public SensitiveKeysConfig {
keys = keys != null ? List.copyOf(keys) : List.of();
}
}
- Step 2: Commit
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.
package com.cameleer3.server.core.admin;
import java.util.Optional;
public interface SensitiveKeysRepository {
Optional<SensitiveKeysConfig> find();
void save(SensitiveKeysConfig config, String updatedBy);
}
- Step 2: Commit
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
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<String> result = SensitiveKeysMerger.merge(
List.of("Authorization", "Cookie"), null);
assertEquals(List.of("Authorization", "Cookie"), result);
}
@Test
void perAppOnly_returnsPerApp() {
List<String> result = SensitiveKeysMerger.merge(
null, List.of("X-Internal-*"));
assertEquals(List.of("X-Internal-*"), result);
}
@Test
void union_mergesWithoutDuplicates() {
List<String> 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<String> 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<String> result = SensitiveKeysMerger.merge(List.of(), null);
assertEquals(List.of(), result);
}
@Test
void emptyGlobalWithPerApp_returnsPerApp() {
// Explicit empty global + per-app additions = just per-app
List<String> result = SensitiveKeysMerger.merge(
List.of(), List.of("X-Custom"));
assertEquals(List.of("X-Custom"), result);
}
@Test
void globPatterns_preserved() {
List<String> 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
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<String> merge(List<String> global, List<String> perApp) {
if (global == null && perApp == null) {
return null;
}
TreeSet<String> seen = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
List<String> 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
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.
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<SensitiveKeysConfig> find() {
List<SensitiveKeysConfig> 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
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
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<String> keys
) {}
- Step 2: Create the response DTO
package com.cameleer3.server.app.dto;
import java.util.List;
public record SensitiveKeysResponse(
List<String> keys,
CommandGroupResponse pushResult
) {}
- Step 3: Create the controller
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<SensitiveKeysConfig> 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<SensitiveKeysResponse> 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<String> globalKeys) {
// Collect distinct applications from stored configs + live agents
Set<String> 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<CommandGroupResponse.AgentResponse> allResponses = new ArrayList<>();
List<String> allTimedOut = new ArrayList<>();
int totalAgents = 0;
for (String application : applications) {
ApplicationConfig appConfig = configRepository.findByApplication(application)
.orElse(null);
List<String> perAppKeys = appConfig != null ? appConfig.getSensitiveKeys() : null;
List<String> 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<String, CompletableFuture<CommandReply>> 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
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:
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<String> globalSensitiveKeys,
List<String> 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:
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:
@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<AppConfigResponse> 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<String> globalKeys = sensitiveKeysRepository.find()
.map(SensitiveKeysConfig::keys)
.orElse(null);
List<String> 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:
// Merge global + per-app sensitive keys for the SSE payload
List<String> globalKeys = sensitiveKeysRepository.find()
.map(SensitiveKeysConfig::keys)
.orElse(null);
List<String> 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:
private ApplicationConfig cloneWithMergedKeys(ApplicationConfig source, List<String> 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:
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
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.
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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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
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.
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<SensitiveKeysConfig | null>('/sensitive-keys'),
});
}
// ── Mutation Hooks ─────────────────────────────────────────────────────
export function useUpdateSensitiveKeys() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ keys, pushToAgents }: { keys: string[]; pushToAgents: boolean }) =>
adminFetch<SensitiveKeysResponse>(`/sensitive-keys?pushToAgents=${pushToAgents}`, {
method: 'PUT',
body: JSON.stringify({ keys }),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'sensitive-keys'] });
},
});
}
- Step 2: Commit
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
.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
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<string[]>([]);
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 <PageLoader />;
return (
<div className={styles.page}>
<SectionHeader>Sensitive Keys</SectionHeader>
<div className={styles.infoBanner}>
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.
</div>
<div className={sectionStyles.section}>
<Label>Global sensitive keys</Label>
<div className={styles.pillList}>
{draft.map((key, i) => (
<Tag key={`${key}-${i}`} label={key} onRemove={() => removeKey(i)} />
))}
{draft.length === 0 && (
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--font-size-sm)' }}>
No keys configured — agents use built-in defaults
</span>
)}
</div>
<div className={styles.inputRow}>
<Input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Add key or glob pattern (e.g. *password*)"
/>
<Button variant="secondary" size="sm" onClick={addKey} disabled={!inputValue.trim()}>
Add
</Button>
</div>
</div>
<div className={styles.footer}>
<Toggle
checked={pushToAgents}
onChange={(e) => setPushToAgents((e.target as HTMLInputElement).checked)}
label="Push to all connected agents immediately"
/>
<Button variant="primary" onClick={handleSave} loading={updateKeys.isPending}>
Save
</Button>
</div>
</div>
);
}
- Step 3: Commit
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:
const SensitiveKeysPage = lazy(() => import('./pages/Admin/SensitiveKeysPage'));
Add the route inside the admin children array, between oidc and rbac (alphabetical by path):
{ path: 'sensitive-keys', element: <SuspenseWrapper><SensitiveKeysPage /></SuspenseWrapper> },
- 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:
{ id: 'admin:sensitive-keys', label: 'Sensitive Keys', path: '/admin/sensitive-keys' },
The full nodes array should read:
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
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:
sensitiveKeys?: string[];
globalSensitiveKeys?: string[];
mergedSensitiveKeys?: string[];
The interface should now look like:
export interface ApplicationConfig {
application: string
version: number
updatedAt?: string
engineLevel?: string
payloadCaptureMode?: string
applicationLogLevel?: string
agentLogLevel?: string
metricsEnabled: boolean
samplingRate: number
tracedProcessors: Record<string, string>
taps: TapDefinition[]
tapVersion: number
routeRecording: Record<string, boolean>
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:
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:
const [sensitiveKeysDraft, setSensitiveKeysDraft] = useState<string[]>([]);
const [sensitiveKeyInput, setSensitiveKeyInput] = useState('');
In the useEffect that initializes from config, add:
setSensitiveKeysDraft([...(config.sensitiveKeys ?? [])]);
In startEditing(), add:
setSensitiveKeysDraft([...(config.sensitiveKeys ?? [])]);
In handleSave(), include the keys in the updated config:
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 </div>:
{/* ── Sensitive Keys ──────────────────────────────────────────── */}
<div className={sectionStyles.section}>
<SectionHeader>Sensitive Keys</SectionHeader>
{config.globalSensitiveKeys && config.globalSensitiveKeys.length > 0 ? (
<span className={styles.sectionSummary}>
{config.globalSensitiveKeys.length} global (enforced) · {(editing ? sensitiveKeysDraft : config.sensitiveKeys ?? []).length} app-specific
</span>
) : (
<span className={styles.sectionSummary}>
No global sensitive keys configured. Agents use their built-in defaults.
</span>
)}
{/* Global keys — read-only pills */}
{config.globalSensitiveKeys && config.globalSensitiveKeys.length > 0 && (
<div className={styles.sensitiveKeysRow}>
<Label>Global (enforced)</Label>
<div className={styles.pillList}>
{config.globalSensitiveKeys.map((key) => (
<Badge key={key} label={key} color="auto" variant="filled" />
))}
</div>
</div>
)}
{/* Per-app keys — editable */}
<div className={styles.sensitiveKeysRow}>
<Label>Application-specific</Label>
<div className={styles.pillList}>
{(editing ? sensitiveKeysDraft : config.sensitiveKeys ?? []).map((key, i) => (
editing ? (
<Tag key={`${key}-${i}`} label={key} onRemove={() => setSensitiveKeysDraft((prev) => prev.filter((_, idx) => idx !== i))} />
) : (
<Badge key={key} label={key} color="primary" variant="filled" />
)
))}
{(editing ? sensitiveKeysDraft : config.sensitiveKeys ?? []).length === 0 && (
<span className={styles.hint}>None</span>
)}
</div>
{editing && (
<div className={styles.sensitiveKeyInput}>
<input
className={styles.numberInput}
style={{ flex: 1 }}
value={sensitiveKeyInput}
onChange={(e) => 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"
/>
</div>
)}
</div>
{config.globalSensitiveKeys && config.globalSensitiveKeys.length > 0 && (
<span className={styles.hint}>
Global keys are enforced by your administrator and cannot be removed per-app.
</span>
)}
</div>
- Step 3: Add CSS for the new section
In ui/src/pages/Admin/AppConfigDetailPage.module.css, add:
.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:
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
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
git add -A
git commit -m "chore: regenerate openapi.json after sensitive keys API changes"