Files
cameleer-server/docs/superpowers/plans/2026-04-14-sensitive-keys-server.md
hsiegeln 891abbfcfd
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m26s
CI / docker (push) Successful in 1m8s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 41s
docs: add sensitive keys feature documentation
- 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>
2026-04-14 18:29:15 +02:00

1390 lines
47 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<String> 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<SensitiveKeysConfig> 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<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**
```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<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**
```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<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**
```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<String> keys
) {}
```
- [ ] **Step 2: Create the response DTO**
```java
package com.cameleer3.server.app.dto;
import java.util.List;
public record SensitiveKeysResponse(
List<String> 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<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**
```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<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:
```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<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:
```java
// 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:
```java
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:
```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<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**
```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<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**
```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<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**
```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: <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:
```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<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:
```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<string[]>([]);
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 `</div>`:
```tsx
{/* ── 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) &middot; {(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:
```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"
```