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"
|
|||
|
|
```
|