- 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>
1390 lines
47 KiB
Markdown
1390 lines
47 KiB
Markdown
# 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) · {(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"
|
||
```
|